Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions .github/scripts/test_update_renovate_changelogs.py
Original file line number Diff line number Diff line change
@@ -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", "[email protected]")
(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()
107 changes: 62 additions & 45 deletions .github/scripts/update_renovate_changelogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<tool>[^`]+)` version `(?P<old>[^`]+)` -> `(?P<new>[^`]+)`\. "
rf"\((?P<links>{PR_LINK}(?:, {PR_LINK})*)\)(?P<trailing>[ \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:
Expand Down
17 changes: 17 additions & 0 deletions .github/workflows/renovate-changelog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
Loading