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