diff --git a/CHANGELOG.md b/CHANGELOG.md index e1d371293a..21108ffda2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,24 +20,14 @@ A brief description of the categories of changes: * Particular sub-systems are identified using parentheses, e.g. `(bzlmod)` or `(docs)`. - -{#v0-0-0} +{#unreleased} ## Unreleased -[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0 +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased -Unreleased changes are tracked as individual files in the [news/](./news) directory. +Unreleased changes are tracked as individual files in the [news/](./news) +directory, or view the [latest generated +changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). {#v2-1-0} ## [2.1.0] - 2026-06-17 diff --git a/docs/BUILD.bazel b/docs/BUILD.bazel index 94cb588018..315010c12d 100644 --- a/docs/BUILD.bazel +++ b/docs/BUILD.bazel @@ -17,6 +17,7 @@ load("@sphinxdocs//sphinxdocs:readthedocs.bzl", "readthedocs_install") load("@sphinxdocs//sphinxdocs:sphinx.bzl", "sphinx_build_binary", "sphinx_docs") load("@sphinxdocs//sphinxdocs:sphinx_docs_library.bzl", "sphinx_docs_library") load("@sphinxdocs//sphinxdocs:sphinx_stardoc.bzl", "sphinx_stardoc", "sphinx_stardocs") +load("//python:defs.bzl", "py_binary") load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") # buildifier: disable=bzl-visibility load("//python/private:common_labels.bzl", "labels") # buildifier: disable=bzl-visibility load("//python/uv:lock.bzl", "lock") # buildifier: disable=bzl-visibility @@ -37,6 +38,27 @@ _TARGET_COMPATIBLE_WITH = select({ "//conditions:default": ["@platforms//:incompatible"], }) if BZLMOD_ENABLED else ["@platforms//:incompatible"] +py_binary( + name = "merge_changelog", + srcs = ["merge_changelog.py"], + target_compatible_with = _TARGET_COMPATIBLE_WITH, + deps = [ + "//tools/private/release:changelog_news", + ], +) + +genrule( + name = "merged_changelog", + srcs = [ + "//:CHANGELOG.md", + "//news:news_files", + ], + outs = ["changelog.md"], + cmd = "$(location :merge_changelog) --changelog $(location //:CHANGELOG.md) --news-dir news --output $@", + target_compatible_with = _TARGET_COMPATIBLE_WITH, + tools = [":merge_changelog"], +) + # See README.md for instructions. Short version: # * `bazel run //docs:docs.serve` in a separate terminal # * `ibazel build //docs:docs` to automatically rebuild docs @@ -60,8 +82,8 @@ sphinx_docs( "html", ], renamed_srcs = { - "//:CHANGELOG.md": "changelog.md", "//:CONTRIBUTING.md": "contributing.md", + ":merged_changelog": "changelog.md", "@sphinxdocs//sphinxdocs/inventories:bazel_inventory": "bazel_inventory.inv", }, sphinx = ":sphinx-build", diff --git a/docs/merge_changelog.py b/docs/merge_changelog.py new file mode 100644 index 0000000000..47af98b2a2 --- /dev/null +++ b/docs/merge_changelog.py @@ -0,0 +1,38 @@ +"""A tool to merge news entries into CHANGELOG.md for documentation preview.""" + +import argparse +import pathlib + +from tools.private.release import changelog_news + + +def main(): + parser = argparse.ArgumentParser(description="Merge news entries into CHANGELOG.md") + parser.add_argument( + "--changelog", type=pathlib.Path, required=True, help="Path to CHANGELOG.md" + ) + parser.add_argument( + "--news-dir", type=pathlib.Path, required=True, help="Path to news directory" + ) + parser.add_argument( + "--output", + type=pathlib.Path, + required=True, + help="Path to output merged changelog", + ) + args = parser.parse_args() + + # Call the public merge_new_into_changelog function + # Using deterministic date "0000-00-00" and version "0.0.0" + changelog_news.merge_new_into_changelog( + changelog_path=args.changelog, + output_path=args.output, + news_dir=args.news_dir, + version="unreleased", + release_date="0000-00-00", + delete_news=False, + ) + + +if __name__ == "__main__": + main() diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index 3335a5f65f..84c6abeaf3 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -5,22 +5,7 @@ import unittest from unittest.mock import patch -from tools.private.release import release as releaser - -_UNRELEASED_TEMPLATE = """ - -""" +from tools.private.release import changelog_news, release as releaser class ReleaserTest(unittest.TestCase): @@ -33,94 +18,37 @@ def setUp(self): # NOTE: On windows, this must be done before files are deleted. self.addCleanup(os.chdir, self.original_cwd) - def test_update_changelog(self): - changelog = f""" -# Changelog - -{_UNRELEASED_TEMPLATE} - -{{#v0-0-0}} -## Unreleased - -[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0 - -{{#v0-0-0-changed}} -### Changed -* Nothing changed - -{{#v0-0-0-fixed}} -### Fixed -* Nothing fixed - -{{#v0-0-0-added}} -### Added -* Nothing added - -{{#v0-0-0-removed}} -### Removed -* Nothing removed. -""" - changelog_path = self.tmpdir / "CHANGELOG.md" - changelog_path.write_text(changelog) - - # Act - releaser.update_changelog( - "1.23.4", - "2025-01-01", - changelog_path=changelog_path, - ) - - # Assert - new_content = changelog_path.read_text() - - self.assertIn( - _UNRELEASED_TEMPLATE, new_content, msg=f"ACTUAL:\n\n{new_content}\n\n" - ) - self.assertIn("## [1.23.4] - 2025-01-01", new_content) - self.assertIn( - "[1.23.4]: https://github.com/bazel-contrib/rules_python/releases/tag/1.23.4", - new_content, - ) - self.assertIn("{#v1-23-4}", new_content) - self.assertIn("{#v1-23-4-changed}", new_content) - self.assertIn("{#v1-23-4-fixed}", new_content) - self.assertIn("{#v1-23-4-added}", new_content) - self.assertIn("{#v1-23-4-removed}", new_content) - def test_update_changelog_with_news(self): # Arrange - changelog = f""" -# Changelog + changelog = """# Changelog -{_UNRELEASED_TEMPLATE} - -{{#v0-0-0}} +{#unreleased} ## Unreleased -[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0 +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased -{{#v0-0-0-removed}} +{#unreleased-removed} ### Removed * Nothing removed. -{{#v0-0-0-changed}} +{#unreleased-changed} ### Changed * Nothing changed. -{{#v0-0-0-fixed}} +{#unreleased-fixed} ### Fixed * Nothing fixed. -{{#v0-0-0-added}} +{#unreleased-added} ### Added * Nothing added. -{{#v2-0-2}} +{#v2-0-2} ## [2.0.2] - 2026-05-14 [2.0.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.2 -{{#v2-0-2-added}} +{#v2-0-2-added} ### Added * (toolchains) Some older change. """ @@ -140,7 +68,7 @@ def test_update_changelog_with_news(self): (news_dir / "invalid_name.md").write_text("Should be ignored") # Act - releaser.update_changelog( + changelog_news.update_changelog( "3.0.0", "2026-06-16", changelog_path=changelog_path, @@ -157,20 +85,17 @@ def test_update_changelog_with_news(self): new_content = changelog_path.read_text() - # 2. Unreleased template comment should still be there - self.assertIn( - _UNRELEASED_TEMPLATE, new_content, msg=f"ACTUAL:\n\n{new_content}\n\n" - ) - - # 3. A fresh active Unreleased section should be present - self.assertIn("{#v0-0-0}", new_content) + # 2. A fresh active Unreleased section should be present + self.assertIn("{#unreleased}", new_content) self.assertIn("## Unreleased", new_content) self.assertIn( - "Unreleased changes are tracked as individual files in the [news/](./news) directory.", + "Unreleased changes are tracked as individual files in the [news/](./news)\n" + "directory, or view the [latest generated\n" + "changelog](https://rules-python.readthedocs.io/en/latest/changelog.html).", new_content, ) - # 4. The new release section should be present + # 3. The new release section should be present self.assertIn("{#v3-0-0}", new_content) self.assertIn("## [3.0.0] - 2026-06-16", new_content) self.assertIn( @@ -178,7 +103,7 @@ def test_update_changelog_with_news(self): new_content, ) - # 5. Correct categories and content + # 4. Correct categories and content self.assertIn( "{#v3-0-0-fixed}\n### Fixed\n* Fixed a bug in the compiler", new_content ) @@ -187,34 +112,33 @@ def test_update_changelog_with_news(self): new_content, ) - # 6. Omitted categories should NOT be present in the new release + # 5. Omitted categories should NOT be present in the new release self.assertNotIn("{#v3-0-0-removed}", new_content) self.assertNotIn("{#v3-0-0-changed}", new_content) - # 7. Old release should still be there + # 6. Old release should still be there self.assertIn("{#v2-0-2}", new_content) self.assertIn("## [2.0.2] - 2026-05-14", new_content) def test_update_changelog_sorting(self): # Arrange - changelog = f""" -# Changelog - -{_UNRELEASED_TEMPLATE} + changelog = """# Changelog -{{#v0-0-0}} +{#unreleased} ## Unreleased -[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0 +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased -Unreleased changes are tracked as individual files in the [news/](./news) directory. +Unreleased changes are tracked as individual files in the [news/](./news) +directory, or view the [latest generated +changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). -{{#v2-0-2}} +{#v2-0-2} ## [2.0.2] - 2026-05-14 [2.0.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.2 -{{#v2-0-2-added}} +{#v2-0-2-added} ### Added * (toolchains) Some older change. """ @@ -232,7 +156,7 @@ def test_update_changelog_sorting(self): (news_dir / "5.fixed.md").write_text("No subcategory A") # Act - releaser.update_changelog( + changelog_news.update_changelog( "3.0.0", "2026-06-16", changelog_path=changelog_path, @@ -273,24 +197,23 @@ def side_effect(path_self, *args, **kwargs): mock_read_text.side_effect = side_effect - changelog = f""" -# Changelog + changelog = """# Changelog -{_UNRELEASED_TEMPLATE} - -{{#v0-0-0}} +{#unreleased} ## Unreleased -[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0 +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased -Unreleased changes are tracked as individual files in the [news/](./news) directory. +Unreleased changes are tracked as individual files in the [news/](./news) +directory, or view the [latest generated +changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). -{{#v2-0-2}} +{#v2-0-2} ## [2.0.2] - 2026-05-14 [2.0.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.2 -{{#v2-0-2-added}} +{#v2-0-2-added} ### Added * (toolchains) Some older change. """ @@ -311,7 +234,7 @@ def side_effect(path_self, *args, **kwargs): # Act & Assert # It should raise IOError with self.assertRaises(IOError): - releaser.update_changelog( + changelog_news.update_changelog( "3.0.0", "2026-06-16", changelog_path=changelog_path, @@ -328,24 +251,23 @@ def side_effect(path_self, *args, **kwargs): def test_update_changelog_merge_existing(self): # Arrange - changelog = f""" -# Changelog - -{_UNRELEASED_TEMPLATE} + changelog = """# Changelog -{{#v0-0-0}} +{#unreleased} ## Unreleased -[0.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/0.0.0 +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased -Unreleased changes are tracked as individual files in the [news/](./news) directory. +Unreleased changes are tracked as individual files in the [news/](./news) +directory, or view the [latest generated +changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). -{{#v2-0-3}} +{#v2-0-3} ## [2.0.3] - 2026-06-15 [2.0.3]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.3 -{{#v2-0-3-fixed}} +{#v2-0-3-fixed} ### Fixed * (pypi) Old fix multi-line detail @@ -365,7 +287,7 @@ def test_update_changelog_merge_existing(self): (news_dir / "2.added.md").write_text("(toolchains) New feature") # Act - releaser.update_changelog( + changelog_news.update_changelog( "2.0.3", "2026-06-15", changelog_path=changelog_path, @@ -400,6 +322,102 @@ def test_update_changelog_merge_existing(self): # Active Unreleased section should NOT be touched (should still be empty/pointing to news) self.assertIn("Unreleased changes are tracked as individual files", new_content) + def test_update_changelog_does_not_leak(self): + # Arrange + changelog = """# Changelog + +{#unreleased} +## Unreleased + +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased + +Unreleased changes are tracked as individual files in the [news/](./news) +directory, or view the [latest generated +changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). + +{#v2-0-2} +## [2.0.2] - 2026-05-14 + +[2.0.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.2 + +This release body mentions the word unreleased and {#unreleased} anchor to test leaks. +""" + changelog_path = self.tmpdir / "CHANGELOG.md" + changelog_path.write_text(changelog) + + news_dir = self.tmpdir / "news" + news_dir.mkdir() + (news_dir / "1.fixed.md").write_text("Some fix") + + # Act + changelog_news.update_changelog( + "3.0.0", + "2026-06-16", + changelog_path=changelog_path, + news_dir=news_dir, + ) + + # Assert + new_content = changelog_path.read_text() + + # The 2.0.2 body should NOT be modified + self.assertIn( + "This release body mentions the word unreleased and {#unreleased} anchor to test leaks.", + new_content, + ) + + def test_update_changelog_empty_news(self): + # Arrange + changelog = """# Changelog + +{#unreleased} +## Unreleased + +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased + +Unreleased changes are tracked as individual files in the [news/](./news) +directory, or view the [latest generated +changelog](https://rules-python.readthedocs.io/en/latest/changelog.html). + +{#v2-0-2} +## [2.0.2] - 2026-05-14 + +[2.0.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.2 + +{#v2-0-2-added} +### Added +* (toolchains) Some older change. +""" + changelog_path = self.tmpdir / "CHANGELOG.md" + changelog_path.write_text(changelog) + + news_dir = self.tmpdir / "news" + news_dir.mkdir() + + # Act + changelog_news.update_changelog( + "3.0.0", + "2026-06-16", + changelog_path=changelog_path, + news_dir=news_dir, + ) + + # Assert + new_content = changelog_path.read_text() + + # The new release section should be present and contain "No notable changes." + self.assertIn("{#v3-0-0}", new_content) + self.assertIn("## [3.0.0] - 2026-06-16", new_content) + self.assertIn( + "[3.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/3.0.0", + new_content, + ) + self.assertIn("No notable changes.", new_content) + + # Verify that we didn't accidentally create any categories + self.assertNotIn("{#v3-0-0-fixed}", new_content) + self.assertNotIn("{#v3-0-0-added}", new_content) + def test_replace_version_next(self): # Arrange mock_file_content = """ diff --git a/tools/private/release/BUILD.bazel b/tools/private/release/BUILD.bazel index 31cc3a0239..0afb23962e 100644 --- a/tools/private/release/BUILD.bazel +++ b/tools/private/release/BUILD.bazel @@ -1,12 +1,18 @@ -load("@rules_python//python:defs.bzl", "py_binary") +load("@rules_python//python:defs.bzl", "py_binary", "py_library") package(default_visibility = ["//visibility:public"]) +py_library( + name = "changelog_news", + srcs = ["changelog_news.py"], +) + py_binary( name = "release", srcs = ["release.py"], main = "release.py", deps = [ + ":changelog_news", "@dev_pip//packaging", ], ) diff --git a/tools/private/release/changelog_news.py b/tools/private/release/changelog_news.py new file mode 100644 index 0000000000..b896af1f6b --- /dev/null +++ b/tools/private/release/changelog_news.py @@ -0,0 +1,323 @@ +"""Utility functions for handling news entries and merging them into CHANGELOG.md.""" + +import pathlib +import re + +_UNRELEASED_TEMPLATE_BODY = """## Unreleased + +[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased + +Unreleased changes are tracked as individual files in the [news/](./news) +directory, or view the [latest generated +changelog](https://rules-python.readthedocs.io/en/latest/changelog.html).""" + + +def _get_sub_category(content): + """Extracts the sub-category in parentheses from the entry content.""" + match = re.match(r"^(?:\*|-)\s*\(([^)]+)\)", content) + if match: + return match.group(1).lower() + return "" + + +def is_news_file(path): + """Checks if a file path is a valid news file.""" + path = pathlib.Path(path) + if not path.is_file(): + return False + if path.suffix != ".md": + return False + parts = path.name.split(".") + if len(parts) < 3: + return False + return True + + +def _get_news_files(news_dir): + """Returns a list of news files matching the ..md pattern.""" + news_path = pathlib.Path(news_dir) + if not news_path.exists(): + return [] + + return [p for p in news_path.iterdir() if is_news_file(p)] + + +def _parse_new_files(news_files): + """Parses news files and groups them by category.""" + entries = {} + for p in news_files: + if not is_news_file(p): + continue + parts = p.name.split(".") + category = parts[1].lower() + + content = p.read_text(encoding="utf-8").strip() + + if not content: + continue + + # Format as list item if not already + if not (content.startswith("* ") or content.startswith("- ")): + content = f"* {content}" + + if category not in entries: + entries[category] = [] + entries[category].append(content) + + return entries + + +def generate_release_block(version, release_date, news_entries): + """Generates the markdown block for the release.""" + header_version = version.replace(".", "-") + lines = [ + f"{{#v{header_version}}}", + f"## [{version}] - {release_date}", + "", + f"[{version}]: https://github.com/bazel-contrib/rules_python/releases/tag/{version}", + "", + ] + + # Standard categories in preferred order + category_order = ["removed", "changed", "fixed", "added"] + # Add any other categories found + for cat in news_entries: + if cat not in category_order: + category_order.append(cat) + + has_entries = False + for cat in category_order: + if cat in news_entries and news_entries[cat]: + has_entries = True + lines.append(f"{{#v{header_version}-{cat}}}") + lines.append(f"### {cat.capitalize()}") + + # Sort entries by sub-category, then by content + sorted_entries = sorted( + news_entries[cat], key=lambda e: (_get_sub_category(e), e) + ) + + for entry in sorted_entries: + lines.append(entry) + lines.append("") + + if not has_entries: + lines.append("No notable changes.") + lines.append("") + + return "\n".join(lines) + + +def _add_news_to_changelog(input_path, output_path, version, entries, release_date): + """Adds or merges news entries into CHANGELOG.md.""" + input_path = pathlib.Path(input_path) + output_path = pathlib.Path(output_path) + changelog_content = input_path.read_text(encoding="utf-8") + + if version == "unreleased": + header_version = "unreleased" + version_anchor = "{#unreleased}" + category_anchor_fmt = "{{#unreleased-{cat}}}" + category_anchor_pattern = r"\{#unreleased-(?P[a-z]+)\}" + else: + header_version = version.replace(".", "-") + version_anchor = f"{{#v{header_version}}}" + category_anchor_fmt = "{{#v" + header_version + "-{cat}}}" + category_anchor_pattern = ( + r"\{#v" + re.escape(header_version) + r"-(?P[a-z]+)\}" + ) + + version_exists = version_anchor in changelog_content + + if version_exists: + if not entries and version != "unreleased": + print( + f"Version {version} already exists and no news entries found" + " to merge. Doing nothing." + ) + output_path.write_text(changelog_content, encoding="utf-8") + return + + print(f"Version {version} already exists in changelog. Merging news entries...") + # Extract the existing version block + pattern = ( + r"(?P" + + re.escape(version_anchor) + + r")(?P.*?)(?=\n\s*\{#v\d+-\d+-\d+\}|\Z)" + ) + match = re.search(pattern, changelog_content, re.DOTALL) + if not match: + raise RuntimeError( + f"Could not find content for existing version {version} in CHANGELOG.md" + ) + + content_block = match.group("content") + + # Strip the "Unreleased changes..." sentence for Unreleased preview + if version == "unreleased": + content_block = re.sub( + r"Unreleased\s+changes\s+are\s+tracked\s+as\s+individual\s+files\s+in\s+the\s+\[news/\]\(\./news\)\s+directory,\s+or\s+view\s+the\s+\[latest\s+generated\s+changelog\]\(https://rules-python\.readthedocs\.io/en/latest/changelog\.html\)\.\s*\n*", + "", + content_block, + ) + + # Parse existing categories + match_cat = re.search(category_anchor_pattern, content_block) + if match_cat: + header_end_idx = match_cat.start() + header_str = content_block[:header_end_idx] + categories_str = content_block[header_end_idx:] + else: + header_str = content_block + categories_str = "" + + existing_entries = {} + if categories_str: + cat_matches = list(re.finditer(category_anchor_pattern, categories_str)) + for i, m in enumerate(cat_matches): + cat = m.group("cat") + start_idx = m.end() + end_idx = ( + cat_matches[i + 1].start() + if i + 1 < len(cat_matches) + else len(categories_str) + ) + cat_content = categories_str[start_idx:end_idx].strip() + + lines = cat_content.splitlines() + cat_entries = [] + current_entry = [] + for line in lines: + if not line.strip() or line.strip().startswith("### "): + continue + if line.startswith("* ") or line.startswith("- "): + if current_entry: + cat_entries.append("\n".join(current_entry)) + current_entry = [line] + else: + if current_entry: + current_entry.append(line) + if current_entry: + cat_entries.append("\n".join(current_entry)) + existing_entries[cat] = cat_entries + + # Merge news entries + merged_entries = dict(existing_entries) + for cat, cat_entries in entries.items(): + if cat not in merged_entries: + merged_entries[cat] = [] + merged_entries[cat].extend(cat_entries) + + # Reconstruct categories + reconstructed_lines = [] + category_order = ["removed", "changed", "fixed", "added"] + for cat in merged_entries: + if cat not in category_order: + category_order.append(cat) + + for cat in category_order: + if cat in merged_entries and merged_entries[cat]: + reconstructed_lines.append(category_anchor_fmt.format(cat=cat)) + reconstructed_lines.append(f"### {cat.capitalize()}") + + sorted_entries = sorted( + merged_entries[cat], key=lambda e: (_get_sub_category(e), e) + ) + + for entry in sorted_entries: + reconstructed_lines.append(entry) + reconstructed_lines.append("") + + new_categories_str = "\n".join(reconstructed_lines) + new_release_block = ( + header_str.rstrip() + "\n\n" + new_categories_str.strip() + "\n" + ) + if version == "unreleased" and not new_categories_str.strip(): + new_release_block = ( + header_str.rstrip() + "\n\nNo notable unreleased changes.\n" + ) + + # Replace in changelog_content + new_content = re.sub( + pattern, + r"\g\n" + new_release_block.strip() + "\n", + changelog_content, + count=1, + flags=re.DOTALL, + ) + output_path.write_text(new_content, encoding="utf-8") + + else: + print( + f"Version {version} does not exist in changelog. Creating new" + " release section from news entries..." + ) + new_release_block = generate_release_block(version, release_date, entries) + replacement = ( + f"{{#unreleased}}\n{_UNRELEASED_TEMPLATE_BODY}\n\n{new_release_block}\n" + ) + + # Replace the active Unreleased section (from {#unreleased} to the first release anchor) + pattern = ( + r"(?P\{#unreleased\})(?P.*?)(?=\n\s*\{#v\d+-\d+-\d+\}|\Z)" + ) + + if not re.search(pattern, changelog_content, re.DOTALL): + raise RuntimeError( + "Could not find active Unreleased section to replace in CHANGELOG.md" + ) + + new_content = re.sub( + pattern, + replacement, + changelog_content, + count=1, + flags=re.DOTALL, + ) + output_path.write_text(new_content, encoding="utf-8") + + +def merge_new_into_changelog( + changelog_path, + output_path, + news_dir, + version, + release_date, + delete_news=False, +): + """Merges news entries from news_dir into changelog_path and writes to output_path.""" + news_files = _get_news_files(news_dir) + entries = _parse_new_files(news_files) + _add_news_to_changelog( + input_path=changelog_path, + output_path=output_path, + version=version, + entries=entries, + release_date=release_date, + ) + if delete_news: + for p in news_files: + p.unlink() + if news_files: + print(f"Removed {len(news_files)} processed news files.") + + +def update_changelog( + version, + release_date, + changelog_path="CHANGELOG.md", + output_path=None, + news_dir="news", + delete_news=True, +): + """Performs the version replacements in CHANGELOG.md.""" + if output_path is None: + output_path = changelog_path + merge_new_into_changelog( + changelog_path=changelog_path, + output_path=output_path, + news_dir=news_dir, + version=version, + release_date=release_date, + delete_news=delete_news, + ) diff --git a/tools/private/release/release.py b/tools/private/release/release.py index 4f956bd56c..9cd949ae99 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -4,12 +4,13 @@ import datetime import fnmatch import os -import pathlib import re import subprocess from packaging.version import parse as parse_version +from tools.private.release import changelog_news + _EXCLUDE_PATTERNS = [ "./.git/*", "./.github/*", @@ -162,294 +163,6 @@ def determine_next_version(branch_name=None): return f"{major}.{minor}.{patch + 1}" -def _get_sub_category(content): - """Extracts the sub-category in parentheses from the entry content.""" - match = re.match(r"^(?:\*|-)\s*\(([^)]+)\)", content) - if match: - return match.group(1).lower() - return "" - - -def _get_news_files(news_dir): - """Returns a list of news files matching the ..md pattern.""" - news_path = pathlib.Path(news_dir) - if not news_path.exists(): - return [] - - valid_files = [] - for p in news_path.iterdir(): - if not p.is_file(): - continue - if p.suffix != ".md": - continue - parts = p.name.split(".") - if len(parts) < 3: - continue - valid_files.append(p) - - return valid_files - - -def _parse_new_files(news_files): - """Parses news files and groups them by category.""" - entries = {} - for p in news_files: - parts = p.name.split(".") - category = parts[1].lower() - - content = p.read_text(encoding="utf-8").strip() - - if not content: - continue - - # Format as list item if not already - if not (content.startswith("* ") or content.startswith("- ")): - content = f"* {content}" - - if category not in entries: - entries[category] = [] - entries[category].append(content) - - return entries - - -def generate_release_block(version, release_date, news_entries): - """Generates the markdown block for the release.""" - header_version = version.replace(".", "-") - lines = [ - f"{{#v{header_version}}}", - f"## [{version}] - {release_date}", - "", - f"[{version}]: https://github.com/bazel-contrib/rules_python/releases/tag/{version}", - "", - ] - - # Standard categories in preferred order - category_order = ["removed", "changed", "fixed", "added"] - # Add any other categories found - for cat in news_entries: - if cat not in category_order: - category_order.append(cat) - - for cat in category_order: - if cat in news_entries and news_entries[cat]: - lines.append(f"{{#v{header_version}-{cat}}}") - lines.append(f"### {cat.capitalize()}") - - # Sort entries by sub-category, then by content - sorted_entries = sorted( - news_entries[cat], key=lambda e: (_get_sub_category(e), e) - ) - - for entry in sorted_entries: - lines.append(entry) - lines.append("") - - return "\n".join(lines) - - -def _add_news_to_changelog(changelog_path, version, entries, release_date): - """Adds or merges news entries into CHANGELOG.md.""" - changelog_path_obj = pathlib.Path(changelog_path) - changelog_content = changelog_path_obj.read_text(encoding="utf-8") - - header_version = version.replace(".", "-") - version_anchor = f"{{#v{header_version}}}" - version_exists = version_anchor in changelog_content - - if version_exists: - if not entries: - print( - f"Version {version} already exists and no news entries found" - " to merge. Doing nothing." - ) - return - - print(f"Version {version} already exists in changelog. Merging news entries...") - # Extract the existing version block - # Match from the version anchor to the next version anchor (or end of file) - pattern = ( - r"(?P\{#v" - + re.escape(header_version) - + r"\})(?P.*?)(?=\n\s*\{#v(?!0-0-0)\d+-\d+-\d+\}|\Z)" - ) - match = re.search(pattern, changelog_content, re.DOTALL) - if not match: - raise RuntimeError( - f"Could not find content for existing version {version} in CHANGELOG.md" - ) - - content_block = match.group("content") - - # Split content_block into header and categories - category_anchor_pattern = ( - r"\{#v" + re.escape(header_version) + r"-(?P[a-z]+)\}" - ) - match_cat = re.search(category_anchor_pattern, content_block) - if match_cat: - header_end_idx = match_cat.start() - header_str = content_block[:header_end_idx] - categories_str = content_block[header_end_idx:] - else: - header_str = content_block - categories_str = "" - - # Parse existing categories - existing_entries = {} - if categories_str: - cat_matches = list(re.finditer(category_anchor_pattern, categories_str)) - for i, m in enumerate(cat_matches): - cat = m.group("cat") - start_idx = m.end() - end_idx = ( - cat_matches[i + 1].start() - if i + 1 < len(cat_matches) - else len(categories_str) - ) - cat_content = categories_str[start_idx:end_idx].strip() - - lines = cat_content.splitlines() - cat_entries = [] - current_entry = [] - for line in lines: - if not line.strip() or line.strip().startswith("### "): - continue - if line.startswith("* ") or line.startswith("- "): - if current_entry: - cat_entries.append("\n".join(current_entry)) - current_entry = [line] - else: - if current_entry: - current_entry.append(line) - if current_entry: - cat_entries.append("\n".join(current_entry)) - existing_entries[cat] = cat_entries - - # Merge news entries - merged_entries = dict(existing_entries) - for cat, cat_entries in entries.items(): - if cat not in merged_entries: - merged_entries[cat] = [] - merged_entries[cat].extend(cat_entries) - - # Reconstruct categories - reconstructed_lines = [] - category_order = ["removed", "changed", "fixed", "added"] - for cat in merged_entries: - if cat not in category_order: - category_order.append(cat) - - for cat in category_order: - if cat in merged_entries and merged_entries[cat]: - reconstructed_lines.append(f"{{#v{header_version}-{cat}}}") - reconstructed_lines.append(f"### {cat.capitalize()}") - - sorted_entries = sorted( - merged_entries[cat], key=lambda e: (_get_sub_category(e), e) - ) - - for entry in sorted_entries: - reconstructed_lines.append(entry) - reconstructed_lines.append("") - - new_categories_str = "\n".join(reconstructed_lines) - new_release_block = ( - header_str.rstrip() + "\n\n" + new_categories_str.strip() + "\n" - ) - - # Replace in changelog - new_content = re.sub( - pattern, - r"\g\n" + new_release_block.strip() + "\n", - changelog_content, - flags=re.DOTALL, - ) - changelog_path_obj.write_text(new_content, encoding="utf-8") - - else: - if entries: - print( - f"Version {version} does not exist in changelog. Creating new" - " release section from news entries..." - ) - # Extract template - template_match = re.search( - r"BEGIN_UNRELEASED_TEMPLATE\s*\n(.*?)\n\s*END_UNRELEASED_TEMPLATE", - changelog_content, - re.DOTALL, - ) - if not template_match: - raise RuntimeError( - "Could not find BEGIN_UNRELEASED_TEMPLATE in CHANGELOG.md" - ) - - unreleased_template = template_match.group(1).strip() - new_release_block = generate_release_block(version, release_date, entries) - - replacement = f"{unreleased_template}\n\n{new_release_block}\n" - - # Replace the active Unreleased section - pattern = r"(END_UNRELEASED_TEMPLATE\s*\n-->\s*\n)(.*?)(\n\s*\{#v(?!0-0-0)\d+-\d+-\d+\})" - - if not re.search(pattern, changelog_content, re.DOTALL): - raise RuntimeError( - "Could not find active Unreleased section to replace in" - " CHANGELOG.md" - ) - - new_content = re.sub( - pattern, - r"\g<1>" + replacement + r"\g<3>", - changelog_content, - flags=re.DOTALL, - ) - changelog_path_obj.write_text(new_content, encoding="utf-8") - else: - # Fallback to old behavior - print( - f"No news entries found and version {version} does not exist." - " Falling back to manual changelog update..." - ) - header_version = version.replace(".", "-") - lines = changelog_content.splitlines() - - new_lines = [] - after_template = False - before_already_released = True - for line in lines: - if "END_UNRELEASED_TEMPLATE" in line: - after_template = True - if re.match("#v[1-9]-", line): - before_already_released = False - - if after_template and before_already_released: - line = line.replace( - "## Unreleased", f"## [{version}] - {release_date}" - ) - line = line.replace("v0-0-0", f"v{header_version}") - line = line.replace("0.0.0", version) - - new_lines.append(line) - - changelog_path_obj.write_text("\n".join(new_lines), encoding="utf-8") - - -def update_changelog( - version, release_date, changelog_path="CHANGELOG.md", news_dir="news" -): - """Performs the version replacements in CHANGELOG.md.""" - news_files = _get_news_files(news_dir) - entries = _parse_new_files(news_files) - - _add_news_to_changelog(changelog_path, version, entries, release_date) - - # Delete news files after successful update - for p in news_files: - p.unlink() - if news_files: - print(f"Removed {len(news_files)} processed news files.") - - def replace_version_next(version): """Replaces all VERSION_NEXT_* placeholders with the new version.""" for filepath in _iter_version_placeholder_files(): @@ -506,7 +219,7 @@ def main(): print("Updating changelog ...") release_date = datetime.date.today().strftime("%Y-%m-%d") - update_changelog(version, release_date) + changelog_news.update_changelog(version, release_date) print("Replacing VERSION_NEXT placeholders ...") replace_version_next(version)