diff --git a/RELEASING.md b/RELEASING.md index d160dcf4d5..318434cc1a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -131,6 +131,37 @@ The `` can be: 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 [ ...] +``` + +The `` 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 ``. + +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 c930d194c8..e69e190506 100644 --- a/tests/tools/private/release/BUILD.bazel +++ b/tests/tools/private/release/BUILD.bazel @@ -31,6 +31,7 @@ py_library( 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( 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( 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( 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( 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( 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( 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( 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( 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( 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( 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( 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( 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( 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( 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( 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( 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 4027edd6bd..19f8658cff 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 @@ def test_resolve_pr_number_url_other_repo(mocker, gh): 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 @@ def mock_run(*args, **kwargs): 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_success(gh, auto_patch_cmd_helpers): 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 0000000000..db83d64e75 --- /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 83faa7c00b..5eeac65820 100644 --- a/tests/tools/private/release/utils_test.py +++ b/tests/tools/private/release/utils_test.py @@ -313,6 +313,24 @@ def test_determine_next_version_ignores_agents_markers(mocker, release_tool_env) 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 5f80d1130a..d484c015ae 100644 --- a/tools/private/release/BUILD.bazel +++ b/tools/private/release/BUILD.bazel @@ -34,6 +34,7 @@ py_binary( 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 dbe644c29f..bde969068b 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 @@ class AutoMergeDict(TypedDict, total=False): 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 @@ class PrDict(TypedDict, total=False): isDraft: bool mergeCommit: dict[str, str] auto_merge: AutoMergeDict | None + files: list[PrFileDict] class MultipleTrackingIssuesError(ValueError): @@ -111,7 +129,236 @@ class CreatePrError(Exception): 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 @@ def list_issues( 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 @@ def get_open_tracking_issues(self, version: str | None = None) -> list[IssueDict 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 @@ def get_release_tracking_issue(self, version: str) -> int: ) return matching[0]["number"] + @override def create_issue( self, title: str, body: str, labels: list[str] | None = None ) -> int: @@ -258,6 +508,7 @@ def create_issue( # 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 @@ def create_release_tracking_issue(self, version: str, template_content: str) -> 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 @@ def get_issue_body(self, issue_num: int) -> str: 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 @@ def get_issue_title(self, issue_num: int) -> str: 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 @@ def update_issue_body(self, issue_num: int, body: str) -> None: 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 @@ def resolve_pr_number(self, pr_ref: str) -> int: 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 @@ def resolve_pr_number(self, pr_ref: str) -> int: 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 @@ def get_pr_info(self, pr_num: int) -> PrDict: 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 @@ def get_pr_comments(self, pr_num: int) -> list[dict]: 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 @@ def create_pr( ) 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 @@ def enable_auto_merge(self, pr_num: int, method: str = "squash") -> None: 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 @@ def get_open_pr(self, branch_name: str) -> PrDict | None: 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 @@ def post_issue_comment(self, issue_num: int, comment_body: str) -> None: 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 @@ def add_comment_reaction(self, comment_id: int, reaction: str) -> None: capture_output=False, ) + @override def get_merge_commits_for_prs( self, pending_items: list[BackportTask] ) -> list[BackportTask]: @@ -518,7 +823,7 @@ def get_merge_commits_for_prs( 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 0b5b672517..f35d0db5e8 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 @@ def __init__(self, repo: str = "bazel-contrib/rules_python"): 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 @@ def create_issue( } 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 @@ def create_release_tracking_issue(self, version: str, template_content: str) -> 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 @@ def resolve_pr_number(self, pr_ref: str) -> int: 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 @@ def get_release_tracking_issue(self, version: str) -> int: ) return matching[0] + @override def create_pr( self, title: str, @@ -129,12 +147,14 @@ def create_pr( } 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 @@ def get_open_tracking_issues(self, version: str | None = None) -> list[IssueDict 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 @@ def get_pr_info(self, pr_num: int) -> PrDict: "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 0000000000..80ca394e8d --- /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/.*.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 ..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 416d9b54fd..9df5ba8606 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 b0573d2af7..f6e266fe01 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 semver_type(value): ] -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 @@ def determine_next_version(branch_name=None, git=None, is_patch=False): 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]: