diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index ed333e723..86b53977d 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -141,78 +141,11 @@ jobs: git checkout -b "$BRANCH" - python3 - <<'PY' - import os - import re - from pathlib import Path - - tag = os.environ["TAG"] - previous_tag = os.environ["PREVIOUS_TAG"] - today = os.environ["TODAY"] - - path = Path("CHANGELOG.md") - text = path.read_text() - - begin = "BEGIN_UNRELEASED_TEMPLATE" - end = "END_UNRELEASED_TEMPLATE" - begin_idx = text.find(begin) - end_idx = text.find(end) - if begin_idx == -1 or end_idx == -1: - raise SystemExit("Unreleased template markers not found in CHANGELOG.md") - - template_block = text[begin_idx:end_idx].splitlines() - template_lines = [] - in_block = False - for line in template_block: - if line.strip() == begin: - in_block = True - continue - if in_block: - template_lines.append(line) - template = "\n".join(template_lines).strip("\n") - if not template: - raise SystemExit("Unreleased template content is empty") - - search_start = end_idx - unreleased_match = re.search( - r'\n## \[Unreleased\]\n', - text[search_start:], - ) - if not unreleased_match: - raise SystemExit("Unreleased section not found in CHANGELOG.md") - unreleased_start = search_start + unreleased_match.start() - - next_anchor = re.search(r'\n\n## \[', text[unreleased_start + 1 :]) - if not next_anchor: - raise SystemExit("Unable to find end of Unreleased section") - unreleased_end = unreleased_start + 1 + next_anchor.start() - - unreleased_section = text[unreleased_start:unreleased_end].strip("\n") - - release_section = unreleased_section - release_section = release_section.replace('', f'', 1) - release_section = release_section.replace('## [Unreleased]', f'## [{tag}] - {today}', 1) - release_section = re.sub( - r'\[Unreleased\]: .*', - f'[{tag}]: https://github.com/MobileNativeFoundation/rules_xcodeproj/compare/{previous_tag}...{tag}', - release_section, - count=1, - ) - - new_unreleased = template.replace("%PREVIOUS_TAG%", tag) - - new_text = ( - text[:unreleased_start].rstrip("\n") - + "\n\n" - + new_unreleased.strip("\n") - + "\n\n" - + release_section.strip("\n") - + "\n" - + text[unreleased_end:].lstrip("\n") - ) - - path.write_text(new_text) - PY + bazel run --run_in_cwd //tools/changelog:update_release_changelog -- \ + --changelog CHANGELOG.md \ + --tag "$TAG" \ + --previous-tag "$PREVIOUS_TAG" \ + --today "$TODAY" git add CHANGELOG.md if git diff --cached --quiet; then diff --git a/tools/changelog/BUILD b/tools/changelog/BUILD new file mode 100644 index 000000000..123d6f215 --- /dev/null +++ b/tools/changelog/BUILD @@ -0,0 +1,26 @@ +load("@rules_python//python:defs.bzl", "py_binary", "py_library", "py_test") + +py_library( + name = "update_release_changelog_library", + srcs = ["update_release_changelog.py"], + srcs_version = "PY3", + visibility = ["//visibility:public"], +) + +py_binary( + name = "update_release_changelog", + srcs = ["update_release_changelog.py"], + python_version = "PY3", + srcs_version = "PY3", + visibility = ["//visibility:public"], + deps = [":update_release_changelog_library"], +) + +py_test( + name = "update_release_changelog_tests", + srcs = ["update_release_changelog_tests.py"], + deps = [ + ":update_release_changelog_library", + "//:py_init_shim", + ], +) diff --git a/tools/changelog/update_release_changelog.py b/tools/changelog/update_release_changelog.py new file mode 100644 index 000000000..d355e05bc --- /dev/null +++ b/tools/changelog/update_release_changelog.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 + +"""Updates CHANGELOG.md for a release and resets the Unreleased section.""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + +BEGIN_TEMPLATE_MARKER = "BEGIN_UNRELEASED_TEMPLATE" +END_TEMPLATE_MARKER = "END_UNRELEASED_TEMPLATE" +UNRELEASED_SECTION_RE = re.compile( + r'\n## \[Unreleased\]\n', +) +NEXT_SECTION_RE = re.compile(r'\n\n## \[') +SECTION_RE = re.compile(r"(?m)^### .+$") +TBD_LINE_RE = re.compile(r"\s*[*-]\s*TBD\s*") + + +def _extract_template(text: str) -> str: + begin_idx = text.find(BEGIN_TEMPLATE_MARKER) + end_idx = text.find(END_TEMPLATE_MARKER) + if begin_idx == -1 or end_idx == -1: + raise ValueError("Unreleased template markers not found in CHANGELOG.md") + + template_block = text[begin_idx:end_idx].splitlines() + template_lines: list[str] = [] + in_block = False + for line in template_block: + if line.strip() == BEGIN_TEMPLATE_MARKER: + in_block = True + continue + if in_block: + template_lines.append(line) + + template = "\n".join(template_lines).strip("\n") + if not template: + raise ValueError("Unreleased template content is empty") + + return template + + +def _find_unreleased_section(text: str, search_start: int) -> tuple[int, int]: + unreleased_match = UNRELEASED_SECTION_RE.search(text[search_start:]) + if not unreleased_match: + raise ValueError("Unreleased section not found in CHANGELOG.md") + unreleased_start = search_start + unreleased_match.start() + + next_anchor = NEXT_SECTION_RE.search(text[unreleased_start + 1 :]) + if not next_anchor: + raise ValueError("Unable to find end of Unreleased section") + unreleased_end = unreleased_start + 1 + next_anchor.start() + + return unreleased_start, unreleased_end + + +def _remove_tbd_only_sections(release_section: str) -> str: + section_matches = list(SECTION_RE.finditer(release_section)) + if not section_matches: + return release_section + + preamble = release_section[: section_matches[0].start()].rstrip("\n") + kept_sections: list[str] = [] + for idx, match in enumerate(section_matches): + start = match.start() + end = ( + section_matches[idx + 1].start() + if idx + 1 < len(section_matches) + else len(release_section) + ) + section = release_section[start:end].strip("\n") + lines = section.splitlines() + heading = lines[0] + body_lines = [line for line in lines[1:] if not TBD_LINE_RE.fullmatch(line)] + while body_lines and not body_lines[0].strip(): + body_lines.pop(0) + while body_lines and not body_lines[-1].strip(): + body_lines.pop() + if body_lines: + kept_sections.append("\n".join([heading, "", *body_lines])) + + if not kept_sections: + return preamble + + return f"{preamble}\n\n" + "\n\n".join(kept_sections) + + +def render_updated_changelog( + text: str, + *, + tag: str, + previous_tag: str, + today: str, +) -> str: + template = _extract_template(text) + search_start = text.find(END_TEMPLATE_MARKER) + unreleased_start, unreleased_end = _find_unreleased_section(text, search_start) + + unreleased_section = text[unreleased_start:unreleased_end].strip("\n") + release_section = unreleased_section + release_section = release_section.replace( + '', f'', 1 + ) + release_section = release_section.replace( + "## [Unreleased]", f"## [{tag}] - {today}", 1 + ) + release_section = re.sub( + r"\[Unreleased\]: .*", + f"[{tag}]: https://github.com/MobileNativeFoundation/rules_xcodeproj/compare/{previous_tag}...{tag}", + release_section, + count=1, + ) + release_section = _remove_tbd_only_sections(release_section) + + new_unreleased = template.replace("%PREVIOUS_TAG%", tag) + return ( + text[:unreleased_start].rstrip("\n") + + "\n\n" + + new_unreleased.strip("\n") + + "\n\n" + + release_section.strip("\n") + + "\n\n" + + text[unreleased_end:].lstrip("\n") + ) + + +def update_changelog(path: Path, *, tag: str, previous_tag: str, today: str) -> None: + text = path.read_text() + updated_text = render_updated_changelog( + text, + tag=tag, + previous_tag=previous_tag, + today=today, + ) + path.write_text(updated_text) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--changelog", default="CHANGELOG.md") + parser.add_argument("--tag", required=True) + parser.add_argument("--previous-tag", required=True) + parser.add_argument("--today", required=True) + return parser.parse_args() + + +def main() -> None: + args = _parse_args() + update_changelog( + Path(args.changelog), + tag=args.tag, + previous_tag=args.previous_tag, + today=args.today, + ) + + +if __name__ == "__main__": + main() diff --git a/tools/changelog/update_release_changelog_tests.py b/tools/changelog/update_release_changelog_tests.py new file mode 100644 index 000000000..3a14b88b9 --- /dev/null +++ b/tools/changelog/update_release_changelog_tests.py @@ -0,0 +1,111 @@ +"""Tests for update_release_changelog.""" + +from pathlib import Path +import sys +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from tools.changelog import update_release_changelog + + +class update_release_changelog_test(unittest.TestCase): + def test_rewrites_release_and_drops_tbd_only_sections(self): + changelog = """# Changelog + + + + +## [Unreleased] + +[Unreleased]: https://github.com/MobileNativeFoundation/rules_xcodeproj/compare/3.4.1...HEAD + +### New + +* Added thing +* TBD + +### Fixed + +* TBD + + +## [3.4.1] - 2025-11-19 +""" + + actual = update_release_changelog.render_updated_changelog( + changelog, + tag="3.5.0", + previous_tag="3.4.1", + today="2026-05-03", + ) + + expected = """# Changelog + + + + +## [Unreleased] + +[Unreleased]: https://github.com/MobileNativeFoundation/rules_xcodeproj/compare/3.5.0...HEAD + +### New + +* TBD + +### Fixed + +* TBD + + +## [3.5.0] - 2026-05-03 + +[3.5.0]: https://github.com/MobileNativeFoundation/rules_xcodeproj/compare/3.4.1...3.5.0 + +### New + +* Added thing + + +## [3.4.1] - 2025-11-19 +""" + + self.assertEqual(actual, expected) + + +if __name__ == "__main__": + unittest.main()