build(release): allow process-news to create missing release sections (#4145)

During patch release backporting, sync-changelog failed because
process-news rejected release versions that did not already exist in
CHANGELOG.md.

Remove the pre-check requiring the version anchor to pre-exist, and
allow process-news to automatically create a new release section with
the appropriate release date. Forward --release-date from sync-changelog
to process-news.
diff --git a/tests/tools/private/release/process_news_test.py b/tests/tools/private/release/process_news_test.py
index db83d64..2aa6131 100644
--- a/tests/tools/private/release/process_news_test.py
+++ b/tests/tools/private/release/process_news_test.py
@@ -45,6 +45,7 @@
     args = argparse.Namespace(
         version="2.3.0",
         targets=[str(news_file)],
+        release_date=None,
     )
 
     result = ProcessNews(args, gh=mock_gh).run()
@@ -88,6 +89,7 @@
     args = argparse.Namespace(
         version="2.3.0",
         targets=["3997"],
+        release_date=None,
     )
 
     result = ProcessNews(args, gh=mock_gh).run()
@@ -126,6 +128,7 @@
     args = argparse.Namespace(
         version="2.3.0",
         targets=["#3997"],
+        release_date=None,
     )
 
     result = ProcessNews(args, gh=mock_gh).run()
@@ -149,6 +152,7 @@
     args = argparse.Namespace(
         version="2.3",
         targets=[str(news_file)],
+        release_date=None,
     )
 
     result = ProcessNews(args, gh=mock_gh).run()
@@ -180,6 +184,7 @@
     args = argparse.Namespace(
         version="2.3.0",
         targets=[str(file1), "102"],
+        release_date=None,
     )
 
     result = ProcessNews(args, gh=mock_gh).run()
@@ -211,13 +216,13 @@
     processed_order = []
     mocker.patch(
         "tools.private.release.process_news.process_pr_target",
-        side_effect=lambda target, ver, p: processed_order.append(
+        side_effect=lambda target, ver, p, **kwargs: 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(
+        side_effect=lambda target, ver, p, **kwargs: processed_order.append(
             f"FILE:{target.path.name}"
         ),
     )
@@ -226,6 +231,7 @@
     args = argparse.Namespace(
         version="2.3.0",
         targets=["102", str(file1)],
+        release_date=None,
     )
 
     result = ProcessNews(args, gh=mock_gh).run()
@@ -242,6 +248,7 @@
     args = argparse.Namespace(
         version="2.3.0",
         targets=["news/nonexistent.added.md"],
+        release_date=None,
     )
 
     result = ProcessNews(args, gh=mock_gh).run()
@@ -260,6 +267,7 @@
     args = argparse.Namespace(
         version="2.3.0",
         targets=[str(invalid_file)],
+        release_date=None,
     )
 
     result = ProcessNews(args, gh=mock_gh).run()
@@ -275,6 +283,7 @@
     args = argparse.Namespace(
         version="2.3.0",
         targets=["9999"],
+        release_date=None,
     )
 
     result = ProcessNews(args, gh=mock_gh).run()
@@ -295,12 +304,70 @@
     args = argparse.Namespace(
         version="3.9.0",
         targets=[str(news_file)],
+        release_date=None,
     )
 
     result = ProcessNews(args, gh=mock_gh).run()
 
-    assert result == 1
-    assert news_file.exists()
+    assert result == 0
+    assert not news_file.exists()
+    content = changelog.read_text(encoding="utf-8")
+    assert "{#v3-9-0}" in content
+    assert "## [3.9.0] -" in content
+    assert "* Some feature" in content
+
+
+def test_process_news_creates_version_when_missing_with_custom_release_date(
+    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="2.3.1",
+        targets=[str(news_file)],
+        release_date="2026-08-15",
+    )
+
+    result = ProcessNews(args, gh=mock_gh).run()
+
+    assert result == 0
+    assert not news_file.exists()
+    content = changelog.read_text(encoding="utf-8")
+    assert "{#v2-3-1}" in content
+    assert "## [2.3.1] - 2026-08-15" in content
+    assert "* Some feature" in content
+
+
+def test_process_news_creates_version_no_news_files(tmp_path, monkeypatch, mock_gh):
+    monkeypatch.chdir(tmp_path)
+    changelog = tmp_path / "CHANGELOG.md"
+    changelog.write_text(_CHANGELOG_TEMPLATE, encoding="utf-8")
+
+    code_file = tmp_path / "mod.py"
+    code_file.write_text("x = 'VERSION_NEXT_PATCH'\n", encoding="utf-8")
+    mock_gh.prs[5000] = {"files": [{"path": "mod.py"}]}
+
+    args = argparse.Namespace(
+        version="2.3.1",
+        targets=["5000"],
+        release_date="2026-08-15",
+    )
+
+    result = ProcessNews(args, gh=mock_gh).run()
+
+    assert result == 0
+    assert "x = '2.3.1'\n" == code_file.read_text(encoding="utf-8")
+    content = changelog.read_text(encoding="utf-8")
+    assert "{#v2-3-1}" in content
+    assert "## [2.3.1] - 2026-08-15" in content
+    assert "No notable changes." in content
 
 
 def test_process_news_cli_parser():
@@ -309,3 +376,15 @@
     assert args.version == "2.3.0"
     assert args.targets == ["news/3997.added.md", "3998"]
     assert args.command == ProcessNews.run_from_args
+    assert args.release_date is None
+
+    args_with_flags = parser.parse_args(
+        [
+            "process-news",
+            "2.3.1",
+            "news/3997.added.md",
+            "--release-date",
+            "2026-09-02",
+        ]
+    )
+    assert args_with_flags.release_date == "2026-09-02"
diff --git a/tests/tools/private/release/sync_changelog_test.py b/tests/tools/private/release/sync_changelog_test.py
index 9e7bc4a..3db4dca 100644
--- a/tests/tools/private/release/sync_changelog_test.py
+++ b/tests/tools/private/release/sync_changelog_test.py
@@ -2,6 +2,7 @@
 from unittest.mock import MagicMock, call
 
 from tools.private.release.gh import CreatePrError
+from tools.private.release.release import create_parser
 from tools.private.release.sync_changelog import SyncChangelog
 
 pytest_plugins = ["tests.tools.private.release.release_test_helper"]
@@ -41,6 +42,7 @@
         issue=123,
         remote="origin",
         prs=None,
+        release_date=None,
     )
     mock_gh.issues[123] = {
         "title": "Release 2.0.0",
@@ -111,6 +113,7 @@
         issue=None,
         remote="origin",
         prs=None,
+        release_date=None,
     )
     mock_gh.issues[123] = {
         "title": "Release 2.0.0",
@@ -143,6 +146,7 @@
         issue=123,
         remote="origin",
         prs=None,
+        release_date=None,
     )
     mock_gh.issues[123] = {
         "title": "Release 2.0.0",
@@ -181,6 +185,7 @@
         issue=None,
         remote="origin",
         prs=None,
+        release_date=None,
     )
     mock_gh.issues[123] = {
         "number": 123,
@@ -208,6 +213,7 @@
         issue=None,
         remote="origin",
         prs=None,
+        release_date=None,
     )
     mock_gh.issues[123] = {
         "number": 123,
@@ -240,6 +246,7 @@
         issue=123,
         remote="origin",
         prs=["#124", "125"],
+        release_date=None,
     )
     mock_gh.issues[123] = {
         "title": "Release 2.0.0",
@@ -271,6 +278,7 @@
         issue=123,
         remote="origin",
         prs=None,
+        release_date=None,
     )
     mock_gh.issues[123] = {
         "title": "Release 2.0.0",
@@ -302,6 +310,7 @@
         issue=123,
         remote="origin",
         prs=None,
+        release_date=None,
     )
     mock_gh.issues[123] = {
         "title": "Release 2.0.0",
@@ -336,6 +345,7 @@
         issue=123,
         remote="origin",
         prs=None,
+        release_date=None,
     )
     mock_gh.issues[123] = {
         "title": "Release 2.0.0",
@@ -359,3 +369,82 @@
         in mock_gh.issue_comments[123][0]
     )
     assert "Traceback" not in mock_gh.issue_comments[123][0]
+
+
+def test_sync_changelog_cli_parser():
+    parser = create_parser()
+    args = parser.parse_args(
+        [
+            "sync-changelog",
+            "--remote",
+            "origin",
+            "--issue",
+            "123",
+            "--release-date",
+            "2026-09-02",
+        ]
+    )
+    assert args.remote == "origin"
+    assert args.issue == 123
+    assert args.release_date == "2026-09-02"
+    assert args.command == SyncChangelog.run_from_args
+
+
+def test_sync_changelog_creates_missing_version_with_real_process_news(
+    tmp_path, monkeypatch, mock_git, mock_gh
+):
+    monkeypatch.chdir(tmp_path)
+    changelog = tmp_path / "CHANGELOG.md"
+    changelog.write_text(
+        """# rules_python Changelog
+
+{#unreleased}
+## Unreleased
+
+[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased
+
+{#v2-3-2}
+## [2.3.2] - 2026-08-22
+
+[2.3.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.3.2
+""",
+        encoding="utf-8",
+    )
+
+    news_dir = tmp_path / "news"
+    news_dir.mkdir()
+    news_file = news_dir / "124.fixed.md"
+    news_file.write_text("* (pypi) Fixed something backported.", encoding="utf-8")
+
+    mock_gh.prs[124] = {"files": [{"path": "news/124.fixed.md"}]}
+    mock_gh.issues[123] = {
+        "title": "Release 2.3.3",
+        "body": """
+## Checklist
+- [ ] Sync Changelog #124
+""",
+        "labels": ["type: release"],
+    }
+    mock_git.branch_exists.return_value = False
+    mock_git.get_commit_sha.return_value = "main_sha"
+    mock_git.status.side_effect = ["", "M CHANGELOG.md\nD news/124.fixed.md"]
+
+    args = argparse.Namespace(
+        issue=123,
+        remote="origin",
+        prs=None,
+        release_date="2026-09-02",
+    )
+
+    result = SyncChangelog(args, mock_git, mock_gh).run()
+
+    assert result == 0
+    content = changelog.read_text(encoding="utf-8")
+    assert "{#v2-3-3}" in content
+    assert "## [2.3.3] - 2026-09-02" in content
+    assert "* (pypi) Fixed something backported." in content
+    assert not news_file.exists()
+    assert (
+        "- [ ] Sync Changelog #124 | status=pending pr=#1001"
+        in mock_gh.get_issue_body(123)
+    )
diff --git a/tools/private/release/process_news.py b/tools/private/release/process_news.py
index 80ca394..6190220 100644
--- a/tools/private/release/process_news.py
+++ b/tools/private/release/process_news.py
@@ -1,7 +1,8 @@
-"""Subcommand to process news files and version markers for an existing release."""
+"""Subcommand to process news files and version markers for a release."""
 
 import argparse
 import dataclasses
+import datetime
 import logging
 import pathlib
 import re
@@ -131,13 +132,18 @@
 
 
 def process_news_file_target(
-    target: NewsFileTarget, version: str, changelog_path: pathlib.Path
+    target: NewsFileTarget,
+    version: str,
+    changelog_path: pathlib.Path,
+    release_date: str | None = None,
 ) -> None:
     """Processes a direct news file target."""
+    if release_date is None:
+        release_date = datetime.date.today().strftime("%Y-%m-%d")
     logger.info("Processing news file: %s", target.path)
     changelog_news.update_changelog(
         version=version,
-        release_date="0000-00-00",
+        release_date=release_date,
         changelog_path=changelog_path,
         news_files=[target.path],
         delete_news=True,
@@ -146,14 +152,19 @@
 
 
 def process_pr_target(
-    target: PrTarget, version: str, changelog_path: pathlib.Path
+    target: PrTarget,
+    version: str,
+    changelog_path: pathlib.Path,
+    release_date: str | None = None,
 ) -> None:
     """Processes a PR target: merges news files and updates version markers."""
+    if release_date is None:
+        release_date = datetime.date.today().strftime("%Y-%m-%d")
     logger.info("Processing PR #%d...", target.pr_num)
     if target.news_files:
         changelog_news.update_changelog(
             version=version,
-            release_date="0000-00-00",
+            release_date=release_date,
             changelog_path=changelog_path,
             news_files=list(target.news_files),
             delete_news=True,
@@ -180,7 +191,7 @@
 
 
 class ProcessNews:
-    """Class to process news files into CHANGELOG.md for an existing version."""
+    """Class to process news files into CHANGELOG.md for a release."""
 
     def __init__(self, args, gh: GitHubInterface):
         self.args = args
@@ -202,16 +213,10 @@
             )
             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
+
+        release_date = args.release_date or datetime.date.today().strftime("%Y-%m-%d")
 
         # Phase 1: Resolve all targets in order
         resolved_targets: list[ResolvedTarget] = []
@@ -230,9 +235,13 @@
         # 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)
+                process_news_file_target(
+                    target, version, changelog_path, release_date=release_date
+                )
             elif isinstance(target, PrTarget):
-                process_pr_target(target, version, changelog_path)
+                process_pr_target(
+                    target, version, changelog_path, release_date=release_date
+                )
             else:
                 logger.warning(
                     "Unexpected target type encountered: %s (%r)",
@@ -240,6 +249,20 @@
                     target,
                 )
 
+        current_changelog = changelog_path.read_text(encoding="utf-8")
+        if version_anchor not in current_changelog:
+            logger.info(
+                "Version anchor %s not created by targets; creating empty release section.",
+                version_anchor,
+            )
+            changelog_news.update_changelog(
+                version=version,
+                release_date=release_date,
+                changelog_path=changelog_path,
+                news_files=[],
+                delete_news=False,
+            )
+
         return 0
 
     @classmethod
@@ -249,13 +272,13 @@
             "process-news",
             help=(
                 "Process news files and update version-next markers into"
-                " CHANGELOG.md for an existing version."
+                " CHANGELOG.md for a release version."
             ),
         )
         parser.add_argument(
             "version",
             type=_release_version_type,
-            help="The target existing release version (e.g., 2.3.0 or 2.3).",
+            help="The target release version (e.g., 2.3.0 or 2.3).",
         )
         parser.add_argument(
             "targets",
@@ -266,6 +289,15 @@
                 " references (e.g., 3997, #3997, or PR URL) to process."
             ),
         )
+        parser.add_argument(
+            "--release-date",
+            type=str,
+            default=None,
+            help=(
+                "Release date (YYYY-MM-DD) to use if creating a new version"
+                " section (defaults to today)."
+            ),
+        )
         parser.set_defaults(command=cls.run_from_args)
 
     @classmethod
diff --git a/tools/private/release/sync_changelog.py b/tools/private/release/sync_changelog.py
index 7e775d3..c7e3d38 100644
--- a/tools/private/release/sync_changelog.py
+++ b/tools/private/release/sync_changelog.py
@@ -182,6 +182,7 @@
             process_news_args = argparse.Namespace(
                 version=version,
                 targets=[str(pr) for pr in sorted_prs],
+                release_date=args.release_date,
             )
             process_news_runner = ProcessNews(process_news_args, gh=self.gh)
             ret = process_news_runner.run()
@@ -296,6 +297,15 @@
                 " separated) to sync (optional)."
             ),
         )
+        parser.add_argument(
+            "--release-date",
+            type=str,
+            default=None,
+            help=(
+                "Release date (YYYY-MM-DD) to use if creating a new version"
+                " section in CHANGELOG.md."
+            ),
+        )
         parser.set_defaults(command=cls.run_from_args)
 
     @classmethod