build(release): add process-news command with PR and version marker support (#4062)
During release preparation, maintainers currently have to manually
locate news fragments from merged PRs, assemble them into
CHANGELOG.md, delete consumed news files, and update documentation
version markers ({versionadded}/{versionchanged}). Doing this manually
across many PRs is tedious and prone to human error.
Add the `process-news` subcommand to the release tool in
`process_news.py` to automate merging specific news files and PR
changes into an existing release section in CHANGELOG.md, removing
processed news files, and updating `VERSION_NEXT_*` markers to the
target release version.diff --git a/RELEASING.md b/RELEASING.md
index d160dcf..318434c 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -131,6 +131,37 @@
or `https://github.com/bazel-contrib/rules_python/pull/124/files`)
* Only URLs for the configured repository are accepted.
+### Processing News Files and PR Changes for Backports
+
+To process and merge news files into an existing release in `CHANGELOG.md`
+(e.g. after backporting a PR) and update any `VERSION_NEXT_*` markers added by
+the PR:
+
+```shell
+bazel run //tools/private/release -- \
+ process-news <VERSION> <TARGET> [<TARGET> ...]
+```
+
+The `<TARGET>` can be:
+* A news file path (e.g., `news/3997.added.md`).
+* A PR number (e.g., `3997` or `#3997`).
+* A PR URL (e.g., `https://github.com/bazel-contrib/rules_python/pull/3997`).
+
+When a PR reference is passed, `process-news` resolves the files touched by the
+PR, merges its news file(s) into `CHANGELOG.md`, deletes the processed news
+file(s), and updates any `VERSION_NEXT_FEATURE` / `VERSION_NEXT_PATCH`
+placeholders in the PR's files to `<VERSION>`.
+
+Examples:
+
+```shell
+# Process a single news file
+bazel run //tools/private/release -- process-news 2.3.0 news/3997.added.md
+
+# Process all news files and update version markers for a PR
+bazel run //tools/private/release -- process-news 2.3.0 3997
+```
+
### Failure Behavior
If a backport fails to process (e.g., due to cherry-pick conflicts):
* The failed backport checklist item will remain unchecked with
diff --git a/tests/tools/private/release/BUILD.bazel b/tests/tools/private/release/BUILD.bazel
index c930d19..e69e190 100644
--- a/tests/tools/private/release/BUILD.bazel
+++ b/tests/tools/private/release/BUILD.bazel
@@ -31,6 +31,7 @@
pytest_test(
name = "add_backports_test",
srcs = ["add_backports_test.py"],
+ python_version = "3.14",
target_compatible_with = NOT_WINDOWS,
deps = [
":conftest",
@@ -42,6 +43,7 @@
pytest_test(
name = "changelog_news_test",
srcs = ["changelog_news_test.py"],
+ python_version = "3.14",
target_compatible_with = NOT_WINDOWS,
deps = [
":conftest",
@@ -53,6 +55,7 @@
pytest_test(
name = "complete_prepare_test",
srcs = ["complete_prepare_test.py"],
+ python_version = "3.14",
target_compatible_with = NOT_WINDOWS,
deps = [
":conftest",
@@ -64,6 +67,7 @@
pytest_test(
name = "complete_sync_changelog_test",
srcs = ["complete_sync_changelog_test.py"],
+ python_version = "3.14",
target_compatible_with = NOT_WINDOWS,
deps = [
":conftest",
@@ -75,6 +79,7 @@
pytest_test(
name = "create_release_branch_test",
srcs = ["create_release_branch_test.py"],
+ python_version = "3.14",
target_compatible_with = NOT_WINDOWS,
deps = [
":conftest",
@@ -86,6 +91,7 @@
pytest_test(
name = "git_test",
srcs = ["git_test.py"],
+ python_version = "3.14",
target_compatible_with = NOT_WINDOWS,
deps = [
":conftest",
@@ -97,6 +103,7 @@
pytest_test(
name = "on_pr_merged_test",
srcs = ["on_pr_merged_test.py"],
+ python_version = "3.14",
target_compatible_with = NOT_WINDOWS,
deps = [
":conftest",
@@ -108,6 +115,7 @@
pytest_test(
name = "promote_test",
srcs = ["promote_test.py"],
+ python_version = "3.14",
target_compatible_with = NOT_WINDOWS,
deps = [
":conftest",
@@ -119,6 +127,7 @@
pytest_test(
name = "release_issue_test",
srcs = ["release_issue_test.py"],
+ python_version = "3.14",
target_compatible_with = NOT_WINDOWS,
deps = [
":conftest",
@@ -130,6 +139,7 @@
pytest_test(
name = "release_test",
srcs = ["release_test.py"],
+ python_version = "3.14",
target_compatible_with = NOT_WINDOWS,
deps = [
":conftest",
@@ -141,6 +151,7 @@
pytest_test(
name = "backport_create_releases_test",
srcs = ["backport_create_releases_test.py"],
+ python_version = "3.14",
target_compatible_with = NOT_WINDOWS,
deps = [
":conftest",
@@ -152,6 +163,7 @@
pytest_test(
name = "prepare_test",
srcs = ["prepare_test.py"],
+ python_version = "3.14",
target_compatible_with = NOT_WINDOWS,
deps = [
":conftest",
@@ -163,6 +175,7 @@
pytest_test(
name = "gh_test",
srcs = ["gh_test.py"],
+ python_version = "3.14",
target_compatible_with = NOT_WINDOWS,
deps = [
":conftest",
@@ -174,6 +187,7 @@
pytest_test(
name = "backport_prepare_test",
srcs = ["backport_prepare_test.py"],
+ python_version = "3.14",
target_compatible_with = NOT_WINDOWS,
deps = [
":conftest",
@@ -185,6 +199,7 @@
pytest_test(
name = "process_backports_test",
srcs = ["process_backports_test.py"],
+ python_version = "3.14",
target_compatible_with = NOT_WINDOWS,
deps = [
":conftest",
@@ -196,6 +211,19 @@
pytest_test(
name = "create_rc_test",
srcs = ["create_rc_test.py"],
+ python_version = "3.14",
+ target_compatible_with = NOT_WINDOWS,
+ deps = [
+ ":conftest",
+ ":release_test_helper",
+ "//tools/private/release:release_lib",
+ ],
+)
+
+pytest_test(
+ name = "process_news_test",
+ srcs = ["process_news_test.py"],
+ python_version = "3.14",
target_compatible_with = NOT_WINDOWS,
deps = [
":conftest",
@@ -207,6 +235,7 @@
pytest_test(
name = "utils_test",
srcs = ["utils_test.py"],
+ python_version = "3.14",
target_compatible_with = NOT_WINDOWS,
deps = [
":conftest",
diff --git a/tests/tools/private/release/gh_test.py b/tests/tools/private/release/gh_test.py
index 4027edd..19f8658 100644
--- a/tests/tools/private/release/gh_test.py
+++ b/tests/tools/private/release/gh_test.py
@@ -1,7 +1,14 @@
+import subprocess
+
import pytest
from tools.private.release import shell
-from tools.private.release.gh import CreatePrError, GitHub
+from tools.private.release.gh import (
+ CreatePrError,
+ GetPrError,
+ GitHub,
+ InvalidPrRefError,
+)
from tools.private.release.git import Git
pytest_plugins = ["tests.tools.private.release.release_test_helper"]
@@ -51,14 +58,16 @@
mock_run_cmd = mocker.patch("tools.private.release.gh.run_cmd")
# URL for a different repo should fail immediately without calling gh
url = "https://github.com/other-owner/other-repo/pull/126"
- with pytest.raises(ValueError, match="URL is not for the configured repository"):
+ with pytest.raises(
+ InvalidPrRefError, match="URL is not for the configured repository"
+ ):
gh.resolve_pr_number(url)
mock_run_cmd.assert_not_called()
def test_resolve_pr_number_invalid(mocker, gh):
mock_run_cmd = mocker.patch("tools.private.release.gh.run_cmd")
- with pytest.raises(ValueError, match="Could not resolve PR reference"):
+ with pytest.raises(InvalidPrRefError, match="Could not resolve PR reference"):
gh.resolve_pr_number("invalid-ref")
mock_run_cmd.assert_not_called()
@@ -96,6 +105,29 @@
assert captured_body["content"] == "new body content"
+def test_get_pr_files(gh, auto_patch_cmd_helpers):
+ auto_patch_cmd_helpers.run_gh.return_value = (
+ '{"files": [{"path": "news/123.added.md"}, {"path": "python/foo.py"}]}'
+ )
+ files = gh.get_pr_files(123)
+ assert files == ["news/123.added.md", "python/foo.py"]
+ auto_patch_cmd_helpers.run_gh.assert_called_with(
+ "pr",
+ "view",
+ "123",
+ "--json=files",
+ "--repo=my-owner/my-repo",
+ check=True,
+ capture_output=True,
+ )
+
+
+def test_get_pr_files_not_found(gh, auto_patch_cmd_helpers):
+ auto_patch_cmd_helpers.run_gh.side_effect = subprocess.CalledProcessError(1, ["gh"])
+ with pytest.raises(GetPrError, match="Failed to get PR #123 on my-owner/my-repo"):
+ gh.get_pr_files(123)
+
+
def test_create_pr_success(gh, auto_patch_cmd_helpers):
auto_patch_cmd_helpers.run_gh.return_value = (
"https://github.com/my-owner/my-repo/pull/123"
@@ -121,8 +153,6 @@
def test_create_pr_failure_raises_create_pr_error(gh, auto_patch_cmd_helpers):
- import subprocess
-
err = subprocess.CalledProcessError(
1,
["gh", "pr", "create"],
diff --git a/tests/tools/private/release/process_news_test.py b/tests/tools/private/release/process_news_test.py
new file mode 100644
index 0000000..db83d64
--- /dev/null
+++ b/tests/tools/private/release/process_news_test.py
@@ -0,0 +1,311 @@
+import argparse
+
+from tools.private.release.process_news import ProcessNews
+from tools.private.release.release import create_parser
+
+pytest_plugins = ["tests.tools.private.release.release_test_helper"]
+
+
+_CHANGELOG_TEMPLATE = """# rules_python 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-3-0}
+## [2.3.0] - 2026-08-07
+
+[2.3.0]: https://github.com/bazel-contrib/rules_python/releases/tag/2.3.0
+
+{#v2-3-0-fixed}
+### Fixed
+* (pypi) Fixed something.
+
+{#v2-3-0-added}
+### Added
+* (cc) Added experimental feature.
+"""
+
+
+def test_process_news_single_file(tmp_path, monkeypatch, mock_gh):
+ monkeypatch.chdir(tmp_path)
+ changelog = tmp_path / "CHANGELOG.md"
+ changelog.write_text(_CHANGELOG_TEMPLATE, encoding="utf-8")
+
+ news_dir = tmp_path / "news"
+ news_dir.mkdir()
+ news_file = news_dir / "3997.added.md"
+ news_file.write_text("(bzlmod) Added explicit_init_py tag class.", encoding="utf-8")
+
+ args = argparse.Namespace(
+ version="2.3.0",
+ targets=[str(news_file)],
+ )
+
+ result = ProcessNews(args, gh=mock_gh).run()
+
+ assert result == 0
+ assert not news_file.exists()
+
+ content = changelog.read_text(encoding="utf-8")
+ assert "* (bzlmod) Added explicit_init_py tag class." in content
+ assert "* (cc) Added experimental feature." in content
+ assert "## [2.3.0] - 2026-08-07" in content
+
+
+def test_process_news_pr_number(tmp_path, monkeypatch, mock_gh):
+ monkeypatch.chdir(tmp_path)
+ changelog = tmp_path / "CHANGELOG.md"
+ changelog.write_text(_CHANGELOG_TEMPLATE, encoding="utf-8")
+
+ news_dir = tmp_path / "news"
+ news_dir.mkdir()
+ news_file = news_dir / "3997.added.md"
+ news_file.write_text("(bzlmod) Added explicit_init_py tag class.", encoding="utf-8")
+
+ code_dir = tmp_path / "python" / "extensions"
+ code_dir.mkdir(parents=True)
+ code_file = code_dir / "config.bzl"
+ code_file.write_text(
+ """:::{versionadded} VERSION_NEXT_FEATURE
+:::
+""",
+ encoding="utf-8",
+ )
+
+ mock_gh.prs[3997] = {
+ "files": [
+ {"path": "news/3997.added.md"},
+ {"path": "python/extensions/config.bzl"},
+ ]
+ }
+
+ args = argparse.Namespace(
+ version="2.3.0",
+ targets=["3997"],
+ )
+
+ result = ProcessNews(args, gh=mock_gh).run()
+
+ assert result == 0
+ assert not news_file.exists()
+
+ content = changelog.read_text(encoding="utf-8")
+ assert "* (bzlmod) Added explicit_init_py tag class." in content
+
+ updated_code = code_file.read_text(encoding="utf-8")
+ assert ":::{versionadded} 2.3.0" in updated_code
+ assert "VERSION_NEXT_FEATURE" not in updated_code
+
+
+def test_process_news_pr_ref_variants(tmp_path, monkeypatch, mock_gh):
+ monkeypatch.chdir(tmp_path)
+ changelog = tmp_path / "CHANGELOG.md"
+ changelog.write_text(_CHANGELOG_TEMPLATE, encoding="utf-8")
+
+ news_dir = tmp_path / "news"
+ news_dir.mkdir()
+ news_file = news_dir / "3997.added.md"
+ news_file.write_text("(bzlmod) Added explicit_init_py tag class.", encoding="utf-8")
+
+ code_file = tmp_path / "feature.py"
+ code_file.write_text("FEATURE_VERSION = 'VERSION_NEXT_PATCH'\n", encoding="utf-8")
+
+ mock_gh.prs[3997] = {
+ "files": [
+ {"path": "news/3997.added.md"},
+ {"path": "feature.py"},
+ ]
+ }
+
+ args = argparse.Namespace(
+ version="2.3.0",
+ targets=["#3997"],
+ )
+
+ result = ProcessNews(args, gh=mock_gh).run()
+
+ assert result == 0
+ assert not news_file.exists()
+ assert "FEATURE_VERSION = '2.3.0'" in code_file.read_text(encoding="utf-8")
+
+
+def test_process_news_version_normalization(tmp_path, monkeypatch, mock_gh):
+ monkeypatch.chdir(tmp_path)
+ changelog = tmp_path / "CHANGELOG.md"
+ changelog.write_text(_CHANGELOG_TEMPLATE, encoding="utf-8")
+
+ news_dir = tmp_path / "news"
+ news_dir.mkdir()
+ news_file = news_dir / "3997.added.md"
+ news_file.write_text("(bzlmod) Added explicit_init_py tag class.", encoding="utf-8")
+
+ # Pass 2.3 instead of 2.3.0
+ args = argparse.Namespace(
+ version="2.3",
+ targets=[str(news_file)],
+ )
+
+ result = ProcessNews(args, gh=mock_gh).run()
+
+ assert result == 0
+ assert not news_file.exists()
+
+ content = changelog.read_text(encoding="utf-8")
+ assert "* (bzlmod) Added explicit_init_py tag class." in content
+
+
+def test_process_news_multiple_mixed_targets(tmp_path, monkeypatch, mock_gh):
+ monkeypatch.chdir(tmp_path)
+ changelog = tmp_path / "CHANGELOG.md"
+ changelog.write_text(_CHANGELOG_TEMPLATE, encoding="utf-8")
+
+ news_dir = tmp_path / "news"
+ news_dir.mkdir()
+ file1 = news_dir / "101.added.md"
+ file1.write_text("(bzlmod) New feature A.", encoding="utf-8")
+ file2 = news_dir / "102.fixed.md"
+ file2.write_text("(gazelle) New fix B.", encoding="utf-8")
+
+ code_file = tmp_path / "fix.py"
+ code_file.write_text("v = 'VERSION_NEXT_PATCH'", encoding="utf-8")
+
+ mock_gh.prs[102] = {"files": [{"path": "news/102.fixed.md"}, {"path": "fix.py"}]}
+
+ args = argparse.Namespace(
+ version="2.3.0",
+ targets=[str(file1), "102"],
+ )
+
+ result = ProcessNews(args, gh=mock_gh).run()
+
+ assert result == 0
+ assert not file1.exists()
+ assert not file2.exists()
+
+ content = changelog.read_text(encoding="utf-8")
+ assert "* (bzlmod) New feature A." in content
+ assert "* (gazelle) New fix B." in content
+ assert "v = '2.3.0'" in code_file.read_text(encoding="utf-8")
+
+
+def test_process_news_preserves_target_order(tmp_path, monkeypatch, mock_gh, mocker):
+ monkeypatch.chdir(tmp_path)
+ changelog = tmp_path / "CHANGELOG.md"
+ changelog.write_text(_CHANGELOG_TEMPLATE, encoding="utf-8")
+
+ news_dir = tmp_path / "news"
+ news_dir.mkdir()
+ file1 = news_dir / "101.added.md"
+ file1.write_text("(bzlmod) First feature.", encoding="utf-8")
+ file2 = news_dir / "102.added.md"
+ file2.write_text("(gazelle) Second feature.", encoding="utf-8")
+
+ mock_gh.prs[102] = {"files": [{"path": "news/102.added.md"}]}
+
+ processed_order = []
+ mocker.patch(
+ "tools.private.release.process_news.process_pr_target",
+ side_effect=lambda target, ver, p: processed_order.append(
+ f"PR:{target.pr_num}"
+ ),
+ )
+ mocker.patch(
+ "tools.private.release.process_news.process_news_file_target",
+ side_effect=lambda target, ver, p: processed_order.append(
+ f"FILE:{target.path.name}"
+ ),
+ )
+
+ # Pass 102 first, then file1
+ args = argparse.Namespace(
+ version="2.3.0",
+ targets=["102", str(file1)],
+ )
+
+ result = ProcessNews(args, gh=mock_gh).run()
+
+ assert result == 0
+ assert processed_order == ["PR:102", "FILE:101.added.md"]
+
+
+def test_process_news_missing_news_file(tmp_path, monkeypatch, mock_gh):
+ monkeypatch.chdir(tmp_path)
+ changelog = tmp_path / "CHANGELOG.md"
+ changelog.write_text(_CHANGELOG_TEMPLATE, encoding="utf-8")
+
+ args = argparse.Namespace(
+ version="2.3.0",
+ targets=["news/nonexistent.added.md"],
+ )
+
+ result = ProcessNews(args, gh=mock_gh).run()
+
+ assert result == 1
+
+
+def test_process_news_invalid_target(tmp_path, monkeypatch, mock_gh):
+ monkeypatch.chdir(tmp_path)
+ changelog = tmp_path / "CHANGELOG.md"
+ changelog.write_text(_CHANGELOG_TEMPLATE, encoding="utf-8")
+
+ invalid_file = tmp_path / "invalid.txt"
+ invalid_file.write_text("Not a news file", encoding="utf-8")
+
+ args = argparse.Namespace(
+ version="2.3.0",
+ targets=[str(invalid_file)],
+ )
+
+ result = ProcessNews(args, gh=mock_gh).run()
+
+ assert result == 1
+
+
+def test_process_news_pr_no_files_found(tmp_path, monkeypatch, mock_gh):
+ monkeypatch.chdir(tmp_path)
+ changelog = tmp_path / "CHANGELOG.md"
+ changelog.write_text(_CHANGELOG_TEMPLATE, encoding="utf-8")
+
+ args = argparse.Namespace(
+ version="2.3.0",
+ targets=["9999"],
+ )
+
+ result = ProcessNews(args, gh=mock_gh).run()
+
+ assert result == 1
+
+
+def test_process_news_version_not_in_changelog(tmp_path, monkeypatch, mock_gh):
+ monkeypatch.chdir(tmp_path)
+ changelog = tmp_path / "CHANGELOG.md"
+ changelog.write_text(_CHANGELOG_TEMPLATE, encoding="utf-8")
+
+ news_dir = tmp_path / "news"
+ news_dir.mkdir()
+ news_file = news_dir / "3997.added.md"
+ news_file.write_text("Some feature", encoding="utf-8")
+
+ args = argparse.Namespace(
+ version="3.9.0",
+ targets=[str(news_file)],
+ )
+
+ result = ProcessNews(args, gh=mock_gh).run()
+
+ assert result == 1
+ assert news_file.exists()
+
+
+def test_process_news_cli_parser():
+ parser = create_parser()
+ args = parser.parse_args(["process-news", "2.3.0", "news/3997.added.md", "3998"])
+ assert args.version == "2.3.0"
+ assert args.targets == ["news/3997.added.md", "3998"]
+ assert args.command == ProcessNews.run_from_args
diff --git a/tests/tools/private/release/utils_test.py b/tests/tools/private/release/utils_test.py
index 83faa7c..5eeac65 100644
--- a/tests/tools/private/release/utils_test.py
+++ b/tests/tools/private/release/utils_test.py
@@ -313,6 +313,24 @@
assert next_version == "1.2.4"
+def test_replace_version_next_in_files(release_tool_env):
+ file1 = release_tool_env.git_root / "file1.py"
+ file1.write_text("v = 'VERSION_NEXT_FEATURE'\n", encoding="utf-8")
+
+ file2 = release_tool_env.git_root / "file2.py"
+ file2.write_text("v = 'VERSION_NEXT_PATCH'\n", encoding="utf-8")
+
+ file3 = release_tool_env.git_root / "file3.py"
+ file3.write_text("v = '1.0.0'\n", encoding="utf-8")
+
+ modified = utils.replace_version_next_in_files([file1, file2, file3], "2.3.0")
+
+ assert modified == [file1, file2]
+ assert file1.read_text(encoding="utf-8") == "v = '2.3.0'\n"
+ assert file2.read_text(encoding="utf-8") == "v = '2.3.0'\n"
+ assert file3.read_text(encoding="utf-8") == "v = '1.0.0'\n"
+
+
def test_determine_next_version_on_main_with_is_patch(mocker, release_tool_env):
mocker.patch(
"tools.private.release.git.Git.get_current_branch", return_value="main"
diff --git a/tools/private/release/BUILD.bazel b/tools/private/release/BUILD.bazel
index 5f80d11..d484c01 100644
--- a/tools/private/release/BUILD.bazel
+++ b/tools/private/release/BUILD.bazel
@@ -34,6 +34,7 @@
name = "release",
srcs = ["release.py"],
main = "release.py",
+ python_version = "3.14",
deps = [
":release_lib",
],
diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py
index dbe644c..bde9690 100644
--- a/tools/private/release/gh.py
+++ b/tools/private/release/gh.py
@@ -1,12 +1,16 @@
"""GitHub CLI helper functions for the release tool."""
+import abc
import enum
import json
import os
import re
import subprocess
import tempfile
-from typing import TypedDict
+from typing import (
+ TypedDict,
+ override, # pyrefly: ignore[missing-module-attribute] -- override available in Python 3.12+
+)
from tools.private.release.release_issue import BackportTask
from tools.private.release.shell import run_cmd
@@ -73,6 +77,19 @@
merge_method: str
+class PrFileDict(TypedDict, total=False):
+ """In-memory representation of a file in a GitHub Pull Request object.
+
+ See GitHub API docs:
+ https://docs.github.com/en/rest/pulls/pulls#list-pull-requests-files
+ """
+
+ path: str
+ additions: int
+ deletions: int
+ changeType: str
+
+
class PrDict(TypedDict, total=False):
"""In-memory representation of a GitHub Pull Request object.
@@ -91,6 +108,7 @@
isDraft: bool
mergeCommit: dict[str, str]
auto_merge: AutoMergeDict | None
+ files: list[PrFileDict]
class MultipleTrackingIssuesError(ValueError):
@@ -111,7 +129,236 @@
pass
-class GitHub:
+class GetPrError(ValueError):
+ """Raised when querying a pull request fails."""
+
+ pass
+
+
+class InvalidPrRefError(ValueError):
+ """Raised when a PR reference cannot be resolved."""
+
+ pass
+
+
+class GitHubInterface(abc.ABC):
+ """Abstract interface for GitHub operations."""
+
+ @abc.abstractmethod
+ def post_issue_comment(self, issue_num: int, comment_body: str) -> None:
+ """Posts a comment on an issue or PR.
+
+ Args:
+ issue_num: The issue or PR number.
+ comment_body: The body content of the comment.
+ """
+
+ @abc.abstractmethod
+ def add_comment_reaction(self, comment_id: int, reaction: str) -> None:
+ """Adds a reaction to an issue or PR comment.
+
+ Args:
+ comment_id: The comment ID.
+ reaction: The reaction type (e.g., "+1", "-1", "rocket").
+ """
+
+ @abc.abstractmethod
+ def enable_auto_merge(self, pr_num: int, method: str = "squash") -> None:
+ """Enables auto-merge for a PR.
+
+ Args:
+ pr_num: The PR number.
+ method: The merge method ('squash', 'rebase', or 'merge').
+ """
+
+ @abc.abstractmethod
+ def create_issue(
+ self, title: str, body: str, labels: list[str] | None = None
+ ) -> int:
+ """Creates an issue.
+
+ Args:
+ title: Title of the issue.
+ body: Body text of the issue.
+ labels: Optional list of labels to add.
+
+ Returns:
+ The created issue number.
+ """
+
+ @abc.abstractmethod
+ def create_release_tracking_issue(self, version: str, template_content: str) -> int:
+ """Creates a release tracking issue from a template.
+
+ Args:
+ version: Release version string (e.g., "1.0.0").
+ template_content: Content of the issue template markdown file.
+
+ Returns:
+ The created issue number.
+ """
+
+ @abc.abstractmethod
+ def get_issue_body(self, issue_num: int) -> str:
+ """Gets the body content of an issue.
+
+ Args:
+ issue_num: The issue number.
+
+ Returns:
+ The body string of the issue.
+ """
+
+ @abc.abstractmethod
+ def get_issue_title(self, issue_num: int) -> str:
+ """Gets the title of an issue.
+
+ Args:
+ issue_num: The issue number.
+
+ Returns:
+ The title string of the issue.
+ """
+
+ @abc.abstractmethod
+ def update_issue_body(self, issue_num: int, body: str) -> None:
+ """Updates the body of an issue.
+
+ Args:
+ issue_num: The issue number.
+ body: The new body content.
+ """
+
+ @abc.abstractmethod
+ def resolve_pr_number(self, pr_ref: str) -> int:
+ """Resolves a PR reference (number, #number, or GitHub URL) to a PR number.
+
+ Args:
+ pr_ref: PR number string (e.g., "123", "#123") or URL.
+
+ Returns:
+ The integer PR number.
+
+ Raises:
+ InvalidPrRefError: If the PR reference cannot be resolved or is for
+ another repository.
+ """
+
+ @abc.abstractmethod
+ def get_release_tracking_issue(self, version: str) -> int:
+ """Finds the single open tracking issue for a given version.
+
+ Args:
+ version: Version string (e.g., "1.0.0").
+
+ Returns:
+ The issue number.
+
+ Raises:
+ NoTrackingIssueError: If no open tracking issue is found.
+ MultipleTrackingIssuesError: If multiple open tracking issues are
+ found.
+ """
+
+ @abc.abstractmethod
+ def create_pr(
+ self,
+ title: str,
+ body: str,
+ base: str = "main",
+ labels: list[str] | None = None,
+ ) -> str:
+ """Creates a pull request.
+
+ Args:
+ title: Title of the PR.
+ body: Body of the PR.
+ base: Base branch to merge into (default: "main").
+ labels: Optional list of labels to add.
+
+ Returns:
+ The URL of the created PR.
+ """
+
+ @abc.abstractmethod
+ def get_open_pr(self, branch_name: str) -> PrDict | None:
+ """Finds an open PR for the given branch.
+
+ Args:
+ branch_name: The head branch name to search for.
+
+ Returns:
+ Dictionary containing PR details if open, else None.
+ """
+
+ @abc.abstractmethod
+ def get_open_tracking_issues(self, version: str | None = None) -> list[IssueDict]:
+ """Finds open tracking issues for release.
+
+ Args:
+ version: Optional specific version to match (e.g., "1.0.0").
+
+ Returns:
+ List of matching open release tracking issue dictionaries.
+ """
+
+ @abc.abstractmethod
+ def get_pr_info(self, pr_num: int) -> PrDict:
+ """Gets info about a PR.
+
+ Args:
+ pr_num: The PR number.
+
+ Returns:
+ Dictionary containing PR fields (state, isDraft, mergeCommit, etc.).
+
+ Raises:
+ GetPrError: If querying the PR fails.
+ """
+
+ @abc.abstractmethod
+ def get_pr_files(self, pr_num: int) -> list[str]:
+ """Gets the list of file paths touched by a PR.
+
+ Args:
+ pr_num: The PR number.
+
+ Returns:
+ A list of file paths.
+
+ Raises:
+ GetPrError: If querying the PR fails.
+ """
+
+ @abc.abstractmethod
+ def get_pr_comments(self, pr_num: int) -> list[dict]:
+ """Gets all comments for a PR.
+
+ Args:
+ pr_num: The PR number.
+
+ Returns:
+ List of comment objects.
+
+ Raises:
+ GetPrError: If querying the PR fails.
+ """
+
+ @abc.abstractmethod
+ def get_merge_commits_for_prs(
+ self, pending_items: list[BackportTask]
+ ) -> list[BackportTask]:
+ """Resolves PR references in pending backports to their merge commit SHAs.
+
+ Args:
+ pending_items: A list of BackportTask items to resolve.
+
+ Returns:
+ The list of resolved BackportTask items.
+ """
+
+
+class GitHub(GitHubInterface):
"""GitHub CLI helper class for the release tool."""
def __init__(self, repo: str = "bazel-contrib/rules_python"):
@@ -191,6 +438,7 @@
output = self._gh_issue(*cmd)
return json.loads(output) if output else []
+ @override
def get_open_tracking_issues(self, version: str | None = None) -> list[IssueDict]:
"""Finds open tracking issues for release.
@@ -208,6 +456,7 @@
search=search,
)
+ @override
def get_release_tracking_issue(self, version: str) -> int:
"""Finds the single open tracking issue for a given version.
@@ -234,6 +483,7 @@
)
return matching[0]["number"]
+ @override
def create_issue(
self, title: str, body: str, labels: list[str] | None = None
) -> int:
@@ -258,6 +508,7 @@
# output is URL: https://github.com/owner/repo/issues/123
return int(output.rstrip("/").split("/")[-1])
+ @override
def create_release_tracking_issue(self, version: str, template_content: str) -> int:
"""Creates a release tracking issue from a template.
@@ -278,6 +529,7 @@
return self.create_issue(title=title, body=issue_body, labels=[RELEASE_LABEL])
+ @override
def get_issue_body(self, issue_num: int) -> str:
"""Gets the body content of an issue.
@@ -293,6 +545,7 @@
data = json.loads(output)
return data.get("body", "")
+ @override
def get_issue_title(self, issue_num: int) -> str:
"""Gets the title of an issue.
@@ -308,6 +561,7 @@
data = json.loads(output)
return data.get("title", "")
+ @override
def update_issue_body(self, issue_num: int, body: str) -> None:
"""Updates the body of an issue.
@@ -328,6 +582,7 @@
if os.path.exists(temp_path):
os.remove(temp_path)
+ @override
def resolve_pr_number(self, pr_ref: str) -> int:
"""Resolves a PR reference (number, #number, or GitHub URL) to a PR number.
@@ -338,7 +593,7 @@
The integer PR number.
Raises:
- ValueError: If the PR reference cannot be resolved or is for another repo.
+ InvalidPrRefError: If the PR reference cannot be resolved or is for another repo.
"""
clean_ref = pr_ref.lstrip("#")
if clean_ref.isdigit():
@@ -349,12 +604,35 @@
match = re.search(pattern, pr_ref, re.IGNORECASE)
if match:
return int(match.group(1))
- raise ValueError(
+ raise InvalidPrRefError(
f"URL is not for the configured repository ({self.repo}): {pr_ref}"
)
- raise ValueError(f"Could not resolve PR reference: {pr_ref}")
+ raise InvalidPrRefError(f"Could not resolve PR reference: {pr_ref}")
+ def _gh_pr_view(self, pr_num: int, *fields: str) -> str:
+ """Helper to run `gh pr view` with specified JSON fields.
+
+ Args:
+ pr_num: The PR number.
+ *fields: JSON fields to request (e.g., "state", "files").
+
+ Returns:
+ The raw JSON output string from gh.
+
+ Raises:
+ GetPrError: If querying the PR fails.
+ """
+ args = ["view", str(pr_num)]
+ if fields:
+ args.append(f"--json={','.join(fields)}")
+ try:
+ output = self._gh_pr(*args)
+ return output or ""
+ except subprocess.CalledProcessError as e:
+ raise GetPrError(f"Failed to get PR #{pr_num} on {self.repo}: {e}") from e
+
+ @override
def get_pr_info(self, pr_num: int) -> PrDict:
"""Gets info about a PR using gh CLI.
@@ -364,9 +642,30 @@
Returns:
Dictionary containing PR fields (state, isDraft, mergeCommit, etc.).
"""
- output = self._gh_pr("view", str(pr_num), "--json=state,isDraft,mergeCommit")
+ output = self._gh_pr_view(pr_num, "state", "isDraft", "mergeCommit")
return json.loads(output) if output else {}
+ @override
+ def get_pr_files(self, pr_num: int) -> list[str]:
+ """Gets the list of file paths touched by a PR using gh CLI.
+
+ Args:
+ pr_num: The PR number.
+
+ Returns:
+ A list of file paths.
+
+ Raises:
+ GetPrError: If querying the PR fails.
+ """
+ output = self._gh_pr_view(pr_num, "files")
+ if not output:
+ return []
+ data: PrDict = json.loads(output)
+ files = data.get("files", [])
+ return [f["path"] for f in files]
+
+ @override
def get_pr_comments(self, pr_num: int) -> list[dict]:
"""Gets all comments for a PR using gh CLI.
@@ -376,12 +675,13 @@
Returns:
List of comment objects (with body, author, etc.).
"""
- output = self._gh_pr("view", str(pr_num), "--json=comments")
+ output = self._gh_pr_view(pr_num, "comments")
if not output:
return []
data = json.loads(output)
return data.get("comments", [])
+ @override
def create_pr(
self,
title: str,
@@ -437,6 +737,7 @@
)
return output
+ @override
def enable_auto_merge(self, pr_num: int, method: str = "squash") -> None:
"""Enables auto-merge for a PR.
@@ -453,6 +754,7 @@
cmd.append("--merge")
self._gh_pr(*cmd, capture_output=False)
+ @override
def get_open_pr(self, branch_name: str) -> PrDict | None:
"""Finds an open PR for the given branch.
@@ -472,6 +774,7 @@
prs = json.loads(output) if output else []
return prs[0] if prs else None
+ @override
def post_issue_comment(self, issue_num: int, comment_body: str) -> None:
"""Posts a comment on an issue or PR.
@@ -486,6 +789,7 @@
capture_output=False,
)
+ @override
def add_comment_reaction(self, comment_id: int, reaction: str) -> None:
"""Adds a reaction to an issue or PR comment.
@@ -501,6 +805,7 @@
capture_output=False,
)
+ @override
def get_merge_commits_for_prs(
self, pending_items: list[BackportTask]
) -> list[BackportTask]:
@@ -518,7 +823,7 @@
def resolve_merge_commits_for_prs(
- gh_client: GitHub, pending_items: list[BackportTask]
+ gh_client: GitHubInterface, pending_items: list[BackportTask]
) -> list[BackportTask]:
"""Resolves PR references in pending backports to their merge commit SHAs.
diff --git a/tools/private/release/mock_gh.py b/tools/private/release/mock_gh.py
index 0b5b672..f35d0db 100644
--- a/tools/private/release/mock_gh.py
+++ b/tools/private/release/mock_gh.py
@@ -1,18 +1,25 @@
"""In-memory fake for GitHub API."""
import re
+from typing import (
+ override, # pyrefly: ignore[missing-module-attribute] -- override available in Python 3.12+
+)
from tools.private.release.gh import (
RELEASE_LABEL,
+ GetPrError,
+ GitHubInterface,
+ InvalidPrRefError,
IssueDict,
MultipleTrackingIssuesError,
NoTrackingIssueError,
PrDict,
resolve_merge_commits_for_prs,
)
+from tools.private.release.release_issue import BackportTask
-class MockGitHub:
+class MockGitHub(GitHubInterface):
def __init__(self, repo: str = "bazel-contrib/rules_python"):
self.repo = repo
self.issues: dict[int, IssueDict] = {}
@@ -22,17 +29,21 @@
self.reactions: dict[int, list[str]] = {}
self.pr_comments: dict[int, list[dict]] = {}
+ @override
def post_issue_comment(self, issue_num: int, comment_body: str) -> None:
self.issue_comments.setdefault(issue_num, []).append(comment_body)
+ @override
def add_comment_reaction(self, comment_id: int, reaction: str) -> None:
self.reactions.setdefault(comment_id, []).append(reaction)
+ @override
def enable_auto_merge(self, pr_num: int, method: str = "squash") -> None:
if pr_num not in self.prs:
self.create_pr(title="", body="")
self.prs[pr_num]["auto_merge"] = {"merge_method": method}
+ @override
def create_issue(
self, title: str, body: str, labels: list[str] | None = None
) -> int:
@@ -47,6 +58,7 @@
}
return issue_num
+ @override
def create_release_tracking_issue(self, version: str, template_content: str) -> int:
# Strip YAML frontmatter if present (simplified copy from gh.py)
issue_body = template_content
@@ -59,21 +71,25 @@
title=f"Release {version}", body=issue_body, labels=[RELEASE_LABEL]
)
+ @override
def get_issue_body(self, issue_num: int) -> str:
if issue_num not in self.issues:
raise ValueError(f"Issue #{issue_num} not found in MockGitHub")
return self.issues[issue_num]["body"]
+ @override
def get_issue_title(self, issue_num: int) -> str:
if issue_num not in self.issues:
raise ValueError(f"Issue #{issue_num} not found in MockGitHub")
return self.issues[issue_num]["title"]
+ @override
def update_issue_body(self, issue_num: int, body: str):
if issue_num not in self.issues:
raise ValueError(f"Issue #{issue_num} not found in MockGitHub")
self.issues[issue_num]["body"] = body
+ @override
def resolve_pr_number(self, pr_ref: str) -> int:
# Real algorithm copy (doesn't require RPCs)
clean_ref = pr_ref.lstrip("#")
@@ -85,11 +101,12 @@
match = re.search(pattern, pr_ref, re.IGNORECASE)
if match:
return int(match.group(1))
- raise ValueError(
+ raise InvalidPrRefError(
f"URL is not for the configured repository ({self.repo}): {pr_ref}"
)
- raise ValueError(f"Could not resolve PR ref: {pr_ref}")
+ raise InvalidPrRefError(f"Could not resolve PR ref: {pr_ref}")
+ @override
def get_release_tracking_issue(self, version: str) -> int:
search_title = f"Release {version}"
matching = [
@@ -108,6 +125,7 @@
)
return matching[0]
+ @override
def create_pr(
self,
title: str,
@@ -129,12 +147,14 @@
}
return url
+ @override
def get_open_pr(self, branch_name: str) -> PrDict | None:
for pr in self.prs.values():
if pr.get("head") == branch_name and pr.get("state") == "OPEN":
return pr
return None
+ @override
def get_open_tracking_issues(self, version: str | None = None) -> list[IssueDict]:
results = []
for issue in self.issues.values():
@@ -146,6 +166,7 @@
results.append(issue)
return results
+ @override
def get_pr_info(self, pr_num: int) -> PrDict:
if pr_num in self.prs:
return self.prs[pr_num]
@@ -154,9 +175,19 @@
"mergeCommit": {"oid": f"mock_merge_sha_{pr_num}"},
}
+ @override
+ def get_pr_files(self, pr_num: int) -> list[str]:
+ if pr_num not in self.prs:
+ raise GetPrError(f"Pull Request #{pr_num} not found in MockGitHub")
+ files = self.prs[pr_num].get("files", [])
+ return [f["path"] for f in files]
+
+ @override
def get_pr_comments(self, pr_num: int) -> list[dict]:
return self.pr_comments.get(pr_num, [])
- def get_merge_commits_for_prs(self, pending_items: list) -> list:
- # pyrefly: ignore[bad-argument-type]
+ @override
+ def get_merge_commits_for_prs(
+ self, pending_items: list[BackportTask]
+ ) -> list[BackportTask]:
return resolve_merge_commits_for_prs(self, pending_items)
diff --git a/tools/private/release/process_news.py b/tools/private/release/process_news.py
new file mode 100644
index 0000000..80ca394
--- /dev/null
+++ b/tools/private/release/process_news.py
@@ -0,0 +1,274 @@
+"""Subcommand to process news files and version markers for an existing release."""
+
+import argparse
+import dataclasses
+import logging
+import pathlib
+import re
+import sys
+
+from tools.private.release import changelog_news
+from tools.private.release.gh import (
+ GetPrError,
+ GitHub,
+ GitHubInterface,
+ InvalidPrRefError,
+)
+from tools.private.release.utils import replace_version_next_in_files
+
+logger = logging.getLogger(__name__)
+
+
+def _release_version_type(value: str) -> str:
+ """Argparse type validator for release versions (X.Y or X.Y.Z)."""
+ if not re.match(r"^\d+\.\d+(\.\d+)?(rc\d+)?$", value):
+ raise argparse.ArgumentTypeError(
+ f"'{value}' is not a valid release version (X.Y or X.Y.Z)"
+ )
+ return value
+
+
+@dataclasses.dataclass(frozen=True)
+class NewsFileTarget:
+ """Represents a direct news file target."""
+
+ path: pathlib.Path
+
+
+@dataclasses.dataclass(frozen=True)
+class PrTarget:
+ """Represents a PR target with its news files and code files."""
+
+ pr_num: int
+ news_files: tuple[pathlib.Path, ...]
+ code_files: tuple[pathlib.Path, ...]
+
+
+ResolvedTarget = NewsFileTarget | PrTarget
+
+
+def resolve_news_file_target(target: str) -> NewsFileTarget | None:
+ """Attempts to resolve a target as a direct news file path on disk."""
+ path = pathlib.Path(target)
+ if path.exists() and changelog_news.is_news_file(path):
+ return NewsFileTarget(path=path)
+ return None
+
+
+def resolve_pr_target(target: str, gh: GitHubInterface) -> PrTarget | None:
+ """Attempts to resolve a target as a PR reference and discover its files."""
+ try:
+ pr_num = gh.resolve_pr_number(target)
+ except InvalidPrRefError:
+ return None
+
+ logger.info("Resolving files for PR #%d via GitHub CLI...", pr_num)
+ try:
+ pr_file_paths = gh.get_pr_files(pr_num)
+ except GetPrError as e:
+ logger.error("Failed to get PR files for #%d: %s", pr_num, e)
+ print(f"::error::Failed to get files for PR #{pr_num}: {e}")
+ raise
+
+ news_files: list[pathlib.Path] = []
+ code_files: list[pathlib.Path] = []
+
+ # Check local news directory for any news/<pr_num>.*.md
+ news_dir = pathlib.Path("news")
+ if news_dir.is_dir():
+ for p in news_dir.iterdir():
+ if p.name.startswith(f"{pr_num}.") and changelog_news.is_news_file(p):
+ if p not in news_files:
+ news_files.append(p)
+
+ for f in pr_file_paths:
+ p = pathlib.Path(f)
+ if changelog_news.is_news_file(p):
+ if p.exists() and p not in news_files:
+ news_files.append(p)
+ else:
+ if p.exists() and p not in code_files:
+ code_files.append(p)
+
+ if not pr_file_paths and not news_files:
+ msg = f"No news files or PR files found for PR #{pr_num}."
+ logger.error(msg)
+ print(f"::error::{msg}")
+ raise GetPrError(msg)
+
+ return PrTarget(
+ pr_num=pr_num,
+ news_files=tuple(news_files),
+ code_files=tuple(code_files),
+ )
+
+
+def resolve_target(target: str, gh: GitHubInterface) -> ResolvedTarget:
+ """Resolves a target string into a ResolvedTarget.
+
+ Raises:
+ ValueError: If target cannot be resolved.
+ """
+ if news_target := resolve_news_file_target(target):
+ return news_target
+
+ try:
+ if pr_target := resolve_pr_target(target, gh):
+ return pr_target
+ except (InvalidPrRefError, GetPrError):
+ raise
+
+ path = pathlib.Path(target)
+ if path.exists():
+ msg = f"File is not a valid news file: {path} (expected <id>.<category>.md)"
+ else:
+ msg = (
+ f"Target '{target}' is neither an existing news file nor a valid PR"
+ " reference."
+ )
+ print(f"::error::{msg}")
+ raise ValueError(msg)
+
+
+def process_news_file_target(
+ target: NewsFileTarget, version: str, changelog_path: pathlib.Path
+) -> None:
+ """Processes a direct news file target."""
+ logger.info("Processing news file: %s", target.path)
+ changelog_news.update_changelog(
+ version=version,
+ release_date="0000-00-00",
+ changelog_path=changelog_path,
+ news_files=[target.path],
+ delete_news=True,
+ )
+ print(f"::notice::Processed news file {target.path} into {changelog_path}.")
+
+
+def process_pr_target(
+ target: PrTarget, version: str, changelog_path: pathlib.Path
+) -> None:
+ """Processes a PR target: merges news files and updates version markers."""
+ logger.info("Processing PR #%d...", target.pr_num)
+ if target.news_files:
+ changelog_news.update_changelog(
+ version=version,
+ release_date="0000-00-00",
+ changelog_path=changelog_path,
+ news_files=list(target.news_files),
+ delete_news=True,
+ )
+ news_list_str = ", ".join(str(f) for f in target.news_files)
+ print(
+ f"::notice::Processed news file(s) for PR #{target.pr_num} into"
+ f" {changelog_path}: {news_list_str}"
+ )
+
+ if target.code_files:
+ modified_files = replace_version_next_in_files(target.code_files, version)
+ if modified_files:
+ mod_str = ", ".join(str(f) for f in modified_files)
+ print(
+ f"::notice::Updated version-next markers for PR #{target.pr_num} in:"
+ f" {mod_str}"
+ )
+ else:
+ logger.info(
+ "No version-next markers found to update for PR #%d.",
+ target.pr_num,
+ )
+
+
+class ProcessNews:
+ """Class to process news files into CHANGELOG.md for an existing version."""
+
+ def __init__(self, args, gh: GitHubInterface):
+ self.args = args
+ self.gh = gh
+
+ def run(self) -> int:
+ """Executes the process-news subcommand."""
+ args = self.args
+ version = args.version
+ if len(version.split(".")) == 2:
+ version = f"{version}.0"
+
+ # Validate that target version exists in CHANGELOG.md
+ changelog_path = pathlib.Path("CHANGELOG.md")
+ if not changelog_path.exists():
+ print(
+ f"::error::Changelog file not found at {changelog_path}",
+ file=sys.stderr,
+ )
+ return 1
+
+ changelog_content = changelog_path.read_text(encoding="utf-8")
+ header_version = version.replace(".", "-")
+ version_anchor = f"{{#v{header_version}}}"
+ if version_anchor not in changelog_content:
+ print(
+ f"::error::Version {version} (anchor {version_anchor}) does not"
+ f" exist in {changelog_path}.",
+ file=sys.stderr,
+ )
+ return 1
+
+ # Phase 1: Resolve all targets in order
+ resolved_targets: list[ResolvedTarget] = []
+ for target in args.targets:
+ try:
+ resolved = resolve_target(target, self.gh)
+ resolved_targets.append(resolved)
+ except Exception as e:
+ logger.error("Failed to resolve target '%s': %s", target, e)
+ print(
+ f"::error::Failed to resolve target '{target}': {e}",
+ file=sys.stderr,
+ )
+ return 1
+
+ # Phase 2: Process all resolved targets in the given order
+ for target in resolved_targets:
+ if isinstance(target, NewsFileTarget):
+ process_news_file_target(target, version, changelog_path)
+ elif isinstance(target, PrTarget):
+ process_pr_target(target, version, changelog_path)
+ else:
+ logger.warning(
+ "Unexpected target type encountered: %s (%r)",
+ type(target),
+ target,
+ )
+
+ return 0
+
+ @classmethod
+ def add_parser(cls, subparsers):
+ """Adds parser for process-news subcommand."""
+ parser = subparsers.add_parser(
+ "process-news",
+ help=(
+ "Process news files and update version-next markers into"
+ " CHANGELOG.md for an existing version."
+ ),
+ )
+ parser.add_argument(
+ "version",
+ type=_release_version_type,
+ help="The target existing release version (e.g., 2.3.0 or 2.3).",
+ )
+ parser.add_argument(
+ "targets",
+ nargs="+",
+ metavar="TARGET",
+ help=(
+ "One or more news file paths (e.g., news/3997.added.md) or PR"
+ " references (e.g., 3997, #3997, or PR URL) to process."
+ ),
+ )
+ parser.set_defaults(command=cls.run_from_args)
+
+ @classmethod
+ def run_from_args(cls, args):
+ """Instantiates and runs the command from parsed args."""
+ return cls(args, gh=GitHub()).run()
diff --git a/tools/private/release/release.py b/tools/private/release/release.py
index 416d9b5..9df5ba8 100644
--- a/tools/private/release/release.py
+++ b/tools/private/release/release.py
@@ -17,6 +17,7 @@
from tools.private.release.on_pr_merged import OnPrMerged
from tools.private.release.prepare import Prepare
from tools.private.release.process_backports import ProcessBackports
+from tools.private.release.process_news import ProcessNews
from tools.private.release.promote import Promote
from tools.private.release.utils import format_exception
@@ -29,6 +30,7 @@
CreateReleaseBranch,
AddBackports,
ProcessBackports,
+ ProcessNews,
OnPrMerged,
CreateRc,
Promote,
diff --git a/tools/private/release/utils.py b/tools/private/release/utils.py
index b0573d2..f6e266f 100644
--- a/tools/private/release/utils.py
+++ b/tools/private/release/utils.py
@@ -1,8 +1,10 @@
"""Utility functions for the release tool."""
import argparse
+import collections.abc
import fnmatch
import os
+import pathlib
import re
from packaging.version import parse as parse_version
@@ -35,21 +37,26 @@
]
-def _iter_version_placeholder_files():
+def is_excluded_version_placeholder_path(path: pathlib.Path | str) -> bool:
+ """Checks if a path matches any version placeholder exclusion patterns."""
+ path_str = str(path)
+ if not path_str.startswith("./") and not path_str.startswith("/"):
+ path_str = f"./{path_str}"
+ return any(fnmatch.fnmatch(path_str, pattern) for pattern in _EXCLUDE_PATTERNS)
+
+
+def _iter_version_placeholder_files() -> collections.abc.Iterator[pathlib.Path]:
for root, dirs, files in os.walk(".", topdown=True):
- # Filter directories
+ # Filter directories in-place
dirs[:] = [
d
for d in dirs
- if not any(
- fnmatch.fnmatch(os.path.join(root, d), pattern)
- for pattern in _EXCLUDE_PATTERNS
- )
+ if not is_excluded_version_placeholder_path(os.path.join(root, d))
]
for filename in files:
- filepath = os.path.join(root, filename)
- if any(fnmatch.fnmatch(filepath, pattern) for pattern in _EXCLUDE_PATTERNS):
+ filepath = pathlib.Path(root) / filename
+ if is_excluded_version_placeholder_path(filepath):
continue
yield filepath
@@ -162,20 +169,45 @@
return f"{major}.{minor}.{patch + 1}"
-def replace_version_next(version):
- """Replaces all VERSION_NEXT_* placeholders with the new version."""
- for filepath in _iter_version_placeholder_files():
+def replace_version_next_in_files(
+ filepaths: collections.abc.Iterable[pathlib.Path], version: str
+) -> list[pathlib.Path]:
+ """Replaces VERSION_NEXT_* placeholders with version in the specified files.
+
+ Args:
+ filepaths: An iterable of pathlib.Path objects to process.
+ version: The release version string to replace placeholders with.
+
+ Returns:
+ List of pathlib.Path objects for files that were modified.
+ """
+ modified: list[pathlib.Path] = []
+ for path in filepaths:
+ if is_excluded_version_placeholder_path(path):
+ continue
try:
- with open(filepath, "r") as f:
- content = f.read()
+ content = path.read_text(encoding="utf-8")
except (IOError, UnicodeDecodeError):
continue
if "VERSION_NEXT_FEATURE" in content or "VERSION_NEXT_PATCH" in content:
new_content = content.replace("VERSION_NEXT_FEATURE", version)
new_content = new_content.replace("VERSION_NEXT_PATCH", version)
- with open(filepath, "w") as f:
- f.write(new_content)
+ path.write_text(new_content, encoding="utf-8")
+ modified.append(path)
+ return modified
+
+
+def replace_version_next(version: str) -> list[pathlib.Path]:
+ """Replaces all VERSION_NEXT_* placeholders with the new version.
+
+ Args:
+ version: The release version string to replace placeholders with.
+
+ Returns:
+ List of pathlib.Path objects for files that were modified.
+ """
+ return replace_version_next_in_files(_iter_version_placeholder_files(), version)
def parse_pr_list(value: str) -> list[str]: