diff --git a/.github/scripts/test_update_renovate_changelogs.py b/.github/scripts/test_update_renovate_changelogs.py new file mode 100644 index 0000000000..15b17e2c07 --- /dev/null +++ b/.github/scripts/test_update_renovate_changelogs.py @@ -0,0 +1,137 @@ +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from update_renovate_changelogs import insert_changelog_entries + + +def bump(old, new, *prs, tool="palantir-java-format"): + links = ", ".join(f"[#{pr}](https://github.com/diffplug/spotless/pull/{pr})" for pr in prs) + return f"- Bump default `{tool}` version `{old}` -> `{new}`. ({links})" + + +def changelog(*entries): + return "# Releases\n\n## [Unreleased]\n\n### Changes\n" + "\n".join(entries) + "\n\n" + + +class ChangelogTest(unittest.TestCase): + def test_consecutive_bumps_preserve_start_and_links(self): + original = changelog(bump("2.98.0", "2.99.0", 3103), "- Another change.") + expected = changelog(bump("2.98.0", "2.102.0", 3103, 3132), "- Another change.") + self.assertEqual(expected, insert_changelog_entries(original, [bump("2.99.0", "2.102.0", 3132)])) + + def test_rerun_preserves_manual_consolidation(self): + original = changelog(bump("2.98.0", "2.102.0", 3103, 3132)) + self.assertEqual(original, insert_changelog_entries(original, [bump("2.99.0", "2.102.0", 3132)])) + + def test_repairs_duplicate_reintroduced_after_manual_consolidation(self): + incoming = bump("2.99.0", "2.102.0", 3132) + original = changelog(bump("2.98.0", "2.102.0", 3103, 3132), incoming) + expected = changelog(bump("2.98.0", "2.102.0", 3103, 3132)) + self.assertEqual(expected, insert_changelog_entries(original, [incoming])) + + def test_consolidates_multiple_existing_bumps(self): + original = changelog(bump("1", "2", 1), "- Another change.", bump("2", "3", 2)) + expected = changelog(bump("1", "4", 1, 2, 3), "- Another change.") + self.assertEqual(expected, insert_changelog_entries(original, [bump("3", "4", 3)])) + + def test_same_pr_can_change_target_version_without_duplicate_link(self): + original = changelog(bump("2.98.0", "2.100.0", 3103, 3132)) + expected = changelog(bump("2.98.0", "2.102.0", 3103, 3132)) + self.assertEqual(expected, insert_changelog_entries(original, [bump("2.99.0", "2.102.0", 3132)])) + + def test_grouped_update_merges_and_inserts_independent_formatters(self): + original = changelog(bump("1", "2", 1)) + entries = [bump("2", "3", 2), bump("0.64", "0.65", 2, tool="ktfmt")] + expected = changelog(bump("1", "3", 1, 2), entries[1]) + result = insert_changelog_entries(original, entries) + self.assertEqual(expected, result) + self.assertEqual(result, insert_changelog_entries(result, entries)) + + def test_preserves_continuation_notes_and_markdown_hard_break(self): + original = changelog(bump("1", "2", 1) + " ", " Coordinate migration details.") + expected = changelog(bump("1", "3", 1, 2) + " ", " Coordinate migration details.") + self.assertEqual(expected, insert_changelog_entries(original, [bump("2", "3", 2)])) + + def test_only_changes_in_unreleased_are_consolidated(self): + suffix = ( + "### Fixed\n" + bump("1", "2", 1) + "\n\n" + "## [1.0] - 2026-01-01\n\n### Changes\n" + bump("0", "1", 0) + "\n" + ) + original = changelog("- Another change.") + suffix + incoming = bump("2", "3", 2) + self.assertEqual(changelog("- Another change.", incoming) + suffix, + insert_changelog_entries(original, [incoming])) + + def test_empty_unreleased_does_not_consume_next_release(self): + suffix = "## [1.0] - 2026-01-01\n\n### Changes\n" + bump("0", "1", 1) + "\n" + original = "# Releases\n\n## [Unreleased]\n\n" + suffix + incoming = bump("1", "2", 2) + self.assertEqual(changelog(incoming) + suffix, insert_changelog_entries(original, [incoming])) + + def test_creates_changes_before_existing_section(self): + original = "# Releases\n\n## [Unreleased]\n\n### Fixed\n- Bug fix.\n" + incoming = bump("1", "2", 2) + self.assertEqual(changelog(incoming) + "### Fixed\n- Bug fix.\n", + insert_changelog_entries(original, [incoming])) + + def test_missing_unreleased_and_empty_updates_are_unchanged(self): + released = "## [1.0]\n\n### Changes\n" + bump("1", "2", 1) + "\n" + self.assertEqual(released, insert_changelog_entries(released, [bump("2", "3", 2)])) + original = changelog(bump("1", "2", 1)) + self.assertEqual(original, insert_changelog_entries(original, [])) + + def test_handwritten_bump_with_inline_explanation_is_preserved(self): + handwritten = bump("1", "2", 1) + " Requires Java 21." + incoming = bump("2", "3", 2) + self.assertEqual(changelog(handwritten, incoming), + insert_changelog_entries(changelog(handwritten), [incoming])) + + +class CommandLineTest(unittest.TestCase): + def test_updates_all_changelogs_using_merge_base_and_is_idempotent(self): + script = Path(__file__).with_name("update_renovate_changelogs.py").resolve() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + + def git(*args): + return subprocess.run(["git", *args], cwd=root, check=True, capture_output=True, text=True) + + def catalog(version): + (root / "gradle/libs.versions.toml").write_text( + '[libraries]\npalantir-java-format = ' + f'"com.palantir.javaformat:palantir-java-format:{version}"\n', encoding="utf-8") + + git("init", "-b", "main") + git("config", "user.name", "Test") + git("config", "user.email", "test@example.com") + (root / "gradle").mkdir() + catalog("2.99.0") + paths = [root / name for name in ("CHANGES.md", "plugin-gradle/CHANGES.md", "plugin-maven/CHANGES.md")] + for path in paths: + path.parent.mkdir(exist_ok=True) + path.write_text(changelog(bump("2.98.0", "2.99.0", 3103)), encoding="utf-8") + git("add", ".") + git("commit", "-m", "Base release") + git("checkout", "-b", "renovate/test") + catalog("2.102.0") + git("commit", "-am", "Bump formatter") + git("checkout", "main") + catalog("2.100.0") + git("commit", "-am", "Advance base branch") + git("checkout", "renovate/test") + + command = [sys.executable, str(script), "--base-ref", "main", "--pr-number", "3132", + "--pr-url", "https://github.com/diffplug/spotless/pull/3132"] + for _ in range(2): + result = subprocess.run(command, cwd=root, check=True, capture_output=True, text=True) + self.assertIn("`2.99.0` -> `2.102.0`", result.stdout) + for path in paths: + self.assertEqual(changelog(bump("2.98.0", "2.102.0", 3103, 3132)), + path.read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/update_renovate_changelogs.py b/.github/scripts/update_renovate_changelogs.py index 21698f0307..0383bd0144 100755 --- a/.github/scripts/update_renovate_changelogs.py +++ b/.github/scripts/update_renovate_changelogs.py @@ -61,58 +61,75 @@ def extract_formatter_versions(toml_str: str) -> dict[str, str]: return result +PR_LINK = r"\[#\d+\]\([^\s)]+\)" +BUMP_ENTRY = re.compile( + r"- Bump default `(?P[^`]+)` version `(?P[^`]+)` -> `(?P[^`]+)`\. " + rf"\((?P{PR_LINK}(?:, {PR_LINK})*)\)(?P[ \t]*)" +) + + +def merge_changelog_entry(changes: str, entry: str) -> str: + """Consolidate generated bumps, leaving handwritten entries and continuation lines intact.""" + incoming = BUMP_ENTRY.fullmatch(entry) + lines = changes.splitlines(keepends=True) + matches = [] + if incoming: + for index, line in enumerate(lines): + existing = BUMP_ENTRY.fullmatch(line.rstrip("\n")) + if existing and existing["tool"] == incoming["tool"]: + matches.append((index, existing)) + + if not matches: + if entry in changes.splitlines(): + return changes + prefix = changes.rstrip() + return prefix + ("\n" if prefix else "") + entry + "\n\n" + + # The first unreleased entry records the version users last received. The catalog's + # current version is authoritative, even when Renovate updates the same PR again. + first_index, first = matches[0] + links = [] + for match in [existing for _, existing in matches] + [incoming]: + for link in re.findall(PR_LINK, match["links"]): + if link not in links: + links.append(link) + lines[first_index] = ( + f"- Bump default `{incoming['tool']}` version `{first['old']}` -> `{incoming['new']}`. " + f"({', '.join(links)}){first['trailing']}\n" + ) + for index, _ in reversed(matches[1:]): + del lines[index] + return "".join(lines) + + def insert_changelog_entries(content: str, entries: list[str]) -> str: - unreleased_match = re.search(r"(##\s*\[Unreleased\]\s*\n+)", content) + if not entries: + return content + unreleased_match = re.search(r"^##[ \t]*\[Unreleased\][ \t]*\n", content, re.MULTILINE) if not unreleased_match: return content unreleased_start = unreleased_match.end() - next_release_match = re.search(r"\n##\s*\[", content[unreleased_start:]) - if next_release_match: - unreleased_end = unreleased_start + next_release_match.start() - unreleased_block = content[unreleased_start:unreleased_end] - after_block = content[unreleased_end:] + next_release = re.search(r"^##[ \t]+", content[unreleased_start:], re.MULTILINE) + unreleased_end = unreleased_start + next_release.start() if next_release else len(content) + block = content[unreleased_start:unreleased_end] + + changes_header = re.search(r"^###[ \t]+Changes[ \t]*\n", block, re.MULTILINE) + if changes_header: + start = changes_header.end() + next_section = re.search(r"^###[ \t]+", block[start:], re.MULTILINE) + end = start + next_section.start() if next_section else len(block) + changes = block[start:end] + for entry in entries: + changes = merge_changelog_entry(changes, entry) + block = block[:start] + changes + block[end:] else: - unreleased_block = content[unreleased_start:] - after_block = "" - - new_entries = [e for e in entries if e not in unreleased_block] - if not new_entries: - return content - - entries_text = "\n".join(new_entries) - - changes_match = re.search(r"(###\s*Changes\s*\n)", unreleased_block) - if changes_match: - changes_header_end = changes_match.end() - # Find the end of ### Changes section (either next ### or next ## or end of block) - next_section_match = re.search(r"\n(###\s+\w+|##\s*\[)", unreleased_block[changes_header_end:]) - if next_section_match: - changes_end = changes_header_end + next_section_match.start() - changes_content = unreleased_block[changes_header_end:changes_end].rstrip() - rest = unreleased_block[changes_end:].lstrip("\n") - unreleased_block = ( - unreleased_block[:changes_header_end] - + (changes_content + "\n" if changes_content else "") - + entries_text - + "\n\n" - + rest - ) - else: - changes_content = unreleased_block[changes_header_end:].rstrip() - unreleased_block = ( - unreleased_block[:changes_header_end] - + (changes_content + "\n" if changes_content else "") - + entries_text - + "\n\n" - ) - else: - if unreleased_block.strip(): - unreleased_block = "### Changes\n" + entries_text + "\n\n" + unreleased_block.lstrip() - else: - unreleased_block = "### Changes\n" + entries_text + "\n\n" + changes = "" + for entry in entries: + changes = merge_changelog_entry(changes, entry) + block = "\n### Changes\n" + changes.lstrip("\n") + block.lstrip("\n") - return content[:unreleased_start] + unreleased_block + after_block + return content[:unreleased_start] + block + content[unreleased_end:] def main() -> None: diff --git a/.github/workflows/renovate-changelog.yml b/.github/workflows/renovate-changelog.yml index be4f44f42f..610559a4b8 100644 --- a/.github/workflows/renovate-changelog.yml +++ b/.github/workflows/renovate-changelog.yml @@ -5,10 +5,25 @@ on: types: [ opened, synchronize, reopened ] paths: - 'gradle/libs.versions.toml' + - '.github/scripts/*renovate_changelogs.py' + - '.github/workflows/renovate-changelog.yml' jobs: + test-updater: + name: Test Changelog Updater + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + - run: python3 -m unittest discover -s .github/scripts -p 'test_*.py' -v + update-changelog: name: Update Changelog + needs: test-updater if: github.actor == 'renovate[bot]' || startsWith(github.head_ref, 'renovate/') runs-on: ubuntu-latest permissions: @@ -20,6 +35,8 @@ jobs: ref: ${{ github.head_ref }} fetch-depth: 0 - uses: actions/setup-python@v7 + with: + python-version: '3.12' # branch and PR fields arrive through env, never spliced into the shell: a branch name is # attacker-controlled text, and ${{ }} would run it as part of the command - name: Update Changelogs