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()