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]: