From 5861c0afe5ef29562671be075afdd52becac87a5 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Mon, 29 Jun 2026 06:46:19 +0000 Subject: [PATCH 1/4] Add a --dry-run flag to the release tool's prepare command to allow safe testing of the release pipeline, and refactor the tool's codebase into modular components to improve maintainability. This structure is verified by an updated test suite using temporary directories and package-level mocking. --- .github/workflows/prepare_release.yml | 2 +- tests/tools/private/release/release_test.py | 244 +++++++++------ tools/private/release/BUILD.bazel | 3 + tools/private/release/gh.py | 18 +- tools/private/release/git.py | 2 +- tools/private/release/prepare.py | 146 +++++++++ tools/private/release/release.py | 311 +------------------- tools/private/release/release_issue.py | 62 ++++ tools/private/release/shell.py | 29 ++ tools/private/release/utils.py | 182 ++++++++++-- 10 files changed, 581 insertions(+), 418 deletions(-) create mode 100644 tools/private/release/prepare.py create mode 100644 tools/private/release/release_issue.py create mode 100644 tools/private/release/shell.py diff --git a/.github/workflows/prepare_release.yml b/.github/workflows/prepare_release.yml index 7d5e93aa6b..b080f121eb 100644 --- a/.github/workflows/prepare_release.yml +++ b/.github/workflows/prepare_release.yml @@ -31,6 +31,6 @@ jobs: - name: Run Release Preparation Pipeline run: | # Manual trigger: run full preparation - bazel run //tools/private/release -- prepare + bazel run //tools/private/release -- prepare --no-dry-run env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index 4ec7de53ba..63da0bc084 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -5,19 +5,48 @@ import unittest from unittest.mock import MagicMock, patch -from tools.private.release import changelog_news, release as releaser +from tools.private.release import changelog_news, release as releaser, utils +from tools.private.release.gh import MultipleTrackingIssuesError, NoTrackingIssueError -class ReleaserTest(unittest.TestCase): +def _mock_git_and_gh(test_case): + mock_git = MagicMock() + mock_gh = MagicMock() + test_case.mock_git = mock_git + test_case.mock_gh = mock_gh + + # Patch bindings in modules that import them at module level + patch("tools.private.release.release.git", new=mock_git).start() + patch("tools.private.release.prepare.git", new=mock_git).start() + patch("tools.private.release.utils.git", new=mock_git).start() + + patch("tools.private.release.release.gh", new=mock_gh).start() + patch("tools.private.release.prepare.gh", new=mock_gh).start() + mock_gh.MultipleTrackingIssuesError = MultipleTrackingIssuesError + mock_gh.NoTrackingIssueError = NoTrackingIssueError + + test_case.addCleanup(patch.stopall) + + # Apply safe defaults + mock_git.get_current_branch.return_value = None + mock_git.get_tags.return_value = [] + mock_git.get_tags_at_head.return_value = [] + mock_git.status.return_value = "" + mock_git.branch_exists.return_value = False + mock_git.tag_exists.return_value = False + mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError("Not found") + + +class TempDirTestCase(unittest.TestCase): def setUp(self): self.tmpdir = pathlib.Path(tempfile.mkdtemp()) self.original_cwd = os.getcwd() self.addCleanup(shutil.rmtree, self.tmpdir) - os.chdir(self.tmpdir) - # NOTE: On windows, this must be done before files are deleted. self.addCleanup(os.chdir, self.original_cwd) + +class ReleaserTest(TempDirTestCase): def test_update_changelog_with_news(self): # Arrange changelog = """# Changelog @@ -431,7 +460,7 @@ def test_replace_version_next(self): """ (self.tmpdir / "mock_file.bzl").write_text(mock_file_content) - releaser.replace_version_next("0.28.0") + utils.replace_version_next("0.28.0") new_content = (self.tmpdir / "mock_file.bzl").read_text() @@ -462,7 +491,7 @@ def test_replace_version_next_excludes_bazel_dirs(self): version = "0.28.0" # Act - releaser.replace_version_next(version) + utils.replace_version_next(version) # Assert new_content = (bazel_dir / "mock_file.bzl").read_text() @@ -490,56 +519,56 @@ def test_invalid_version(self): class GetLatestVersionTest(unittest.TestCase): - @patch("tools.private.release.release.git.get_tags") + @patch("tools.private.release.git.get_tags") def test_get_latest_version_success(self, mock_get_tags): mock_get_tags.return_value = ["0.1.0", "1.0.0", "0.2.0"] - self.assertEqual(releaser.get_latest_version(), "1.0.0") + self.assertEqual(utils.get_latest_version(), "1.0.0") - @patch("tools.private.release.release.git.get_tags") + @patch("tools.private.release.git.get_tags") def test_get_latest_version_rc_is_latest(self, mock_get_tags): mock_get_tags.return_value = ["0.1.0", "1.0.0", "1.1.0rc0"] with self.assertRaisesRegex( ValueError, "The latest version is a pre-release version: 1.1.0rc0" ): - releaser.get_latest_version() + utils.get_latest_version() - @patch("tools.private.release.release.git.get_tags") + @patch("tools.private.release.git.get_tags") def test_get_latest_version_no_tags(self, mock_get_tags): mock_get_tags.return_value = [] with self.assertRaisesRegex( RuntimeError, "No git tags found matching X.Y.Z or X.Y.ZrcN format." ): - releaser.get_latest_version() + utils.get_latest_version() - @patch("tools.private.release.release.git.get_tags") + @patch("tools.private.release.git.get_tags") def test_get_latest_version_no_matching_tags(self, mock_get_tags): mock_get_tags.return_value = ["v1.0", "latest"] with self.assertRaisesRegex( RuntimeError, "No git tags found matching X.Y.Z or X.Y.ZrcN format." ): - releaser.get_latest_version() + utils.get_latest_version() - @patch("tools.private.release.release.git.get_tags") + @patch("tools.private.release.git.get_tags") def test_get_latest_version_only_rc_tags(self, mock_get_tags): mock_get_tags.return_value = ["1.0.0rc0", "1.1.0rc0"] with self.assertRaisesRegex( ValueError, "The latest version is a pre-release version: 1.1.0rc0" ): - releaser.get_latest_version() + utils.get_latest_version() class GetLatestRcTagTest(unittest.TestCase): - @patch("tools.private.release.release.git.get_tags") + @patch("tools.private.release.git.get_tags") def test_get_latest_rc_tag_no_tags(self, mock_get_tags): mock_get_tags.return_value = [] - self.assertIsNone(releaser.get_latest_rc_tag("2.0.0")) + self.assertIsNone(utils.get_latest_rc_tag("2.0.0")) - @patch("tools.private.release.release.git.get_tags") + @patch("tools.private.release.git.get_tags") def test_get_latest_rc_tag_no_matching_tags(self, mock_get_tags): mock_get_tags.return_value = ["1.0.0", "2.0.0", "v2.0.0-rc0", "2.1.0-rc0"] - self.assertIsNone(releaser.get_latest_rc_tag("2.0.0")) + self.assertIsNone(utils.get_latest_rc_tag("2.0.0")) - @patch("tools.private.release.release.git.get_tags") + @patch("tools.private.release.git.get_tags") def test_get_latest_rc_tag_success(self, mock_get_tags): mock_get_tags.return_value = [ "2.0.0-rc0", @@ -547,26 +576,19 @@ def test_get_latest_rc_tag_success(self, mock_get_tags): "2.0.0-rc1", "2.1.0-rc0", ] - self.assertEqual(releaser.get_latest_rc_tag("2.0.0"), "2.0.0-rc2") + self.assertEqual(utils.get_latest_rc_tag("2.0.0"), "2.0.0-rc2") - @patch("tools.private.release.release.git.get_tags") + @patch("tools.private.release.git.get_tags") def test_get_latest_rc_tag_ignores_v_prefix(self, mock_get_tags): mock_get_tags.return_value = ["v2.0.0-rc0", "2.0.0-rc1"] - self.assertEqual(releaser.get_latest_rc_tag("2.0.0"), "2.0.0-rc1") + self.assertEqual(utils.get_latest_rc_tag("2.0.0"), "2.0.0-rc1") -class DetermineNextVersionTest(unittest.TestCase): +class DetermineNextVersionTest(TempDirTestCase): def setUp(self): - self.tmpdir = pathlib.Path(tempfile.mkdtemp()) - self.original_cwd = os.getcwd() - self.addCleanup(shutil.rmtree, self.tmpdir) - - os.chdir(self.tmpdir) - # NOTE: On windows, this must be done before files are deleted. - self.addCleanup(os.chdir, self.original_cwd) - + super().setUp() self.mock_get_latest_version = patch( - "tools.private.release.release.get_latest_version" + "tools.private.release.utils.get_latest_version" ).start() self.addCleanup(patch.stopall) @@ -574,7 +596,7 @@ def test_no_markers(self): (self.tmpdir / "mock_file.bzl").write_text("no markers here") self.mock_get_latest_version.return_value = "1.2.3" - next_version = releaser.determine_next_version() + next_version = utils.determine_next_version() self.assertEqual(next_version, "1.2.4") @@ -584,7 +606,7 @@ def test_only_patch(self): ) self.mock_get_latest_version.return_value = "1.2.3" - next_version = releaser.determine_next_version() + next_version = utils.determine_next_version() self.assertEqual(next_version, "1.2.4") @@ -594,7 +616,7 @@ def test_only_feature(self): ) self.mock_get_latest_version.return_value = "1.2.3" - next_version = releaser.determine_next_version() + next_version = utils.determine_next_version() self.assertEqual(next_version, "1.3.0") @@ -607,36 +629,36 @@ def test_both_markers(self): ) self.mock_get_latest_version.return_value = "1.2.3" - next_version = releaser.determine_next_version() + next_version = utils.determine_next_version() self.assertEqual(next_version, "1.3.0") - @patch("tools.private.release.release.git.get_current_branch") - @patch("tools.private.release.release.git.get_tags") + @patch("tools.private.release.git.get_current_branch") + @patch("tools.private.release.git.get_tags") def test_determine_next_version_on_release_branch_with_existing_tags( self, mock_get_tags, mock_get_branch ): mock_get_branch.return_value = "release/0.37" mock_get_tags.return_value = ["0.37.0", "0.37.1", "0.36.0"] - next_version = releaser.determine_next_version() + next_version = utils.determine_next_version() self.assertEqual(next_version, "0.37.2") - @patch("tools.private.release.release.git.get_current_branch") - @patch("tools.private.release.release.git.get_tags") + @patch("tools.private.release.git.get_current_branch") + @patch("tools.private.release.git.get_tags") def test_determine_next_version_on_release_branch_no_tags( self, mock_get_tags, mock_get_branch ): mock_get_branch.return_value = "release/0.38" mock_get_tags.return_value = ["0.37.0"] # No 0.38.x tags - next_version = releaser.determine_next_version() + next_version = utils.determine_next_version() self.assertEqual(next_version, "0.38.0") - @patch("tools.private.release.release.git.get_current_branch") - @patch("tools.private.release.release.git.get_tags") + @patch("tools.private.release.git.get_current_branch") + @patch("tools.private.release.git.get_tags") def test_determine_next_version_on_release_branch_with_active_rc( self, mock_get_tags, mock_get_branch ): @@ -644,13 +666,13 @@ def test_determine_next_version_on_release_branch_with_active_rc( # 0.37.0-rc0 and rc1 exist, but no stable 0.37.0 yet mock_get_tags.return_value = ["0.37.0-rc0", "0.37.0-rc1", "0.36.0"] - next_version = releaser.determine_next_version() + next_version = utils.determine_next_version() # Should target 0.37.0, not 0.37.1 self.assertEqual(next_version, "0.37.0") - @patch("tools.private.release.release.git.get_current_branch") - @patch("tools.private.release.release.git.get_tags") + @patch("tools.private.release.git.get_current_branch") + @patch("tools.private.release.git.get_tags") def test_determine_next_version_on_release_branch_with_stable_and_active_patch_rc( self, mock_get_tags, mock_get_branch ): @@ -658,39 +680,36 @@ def test_determine_next_version_on_release_branch_with_stable_and_active_patch_r # 0.37.0 stable exists, and 0.37.1-rc0 exists (but no stable 0.37.1 yet) mock_get_tags.return_value = ["0.37.0", "0.37.1-rc0", "0.36.0"] - next_version = releaser.determine_next_version() + next_version = utils.determine_next_version() # Should target 0.37.1, not 0.37.2 self.assertEqual(next_version, "0.37.1") - @patch("tools.private.release.release.git.get_current_branch") + @patch("tools.private.release.git.get_current_branch") def test_determine_next_version_on_main_branch_fallback(self, mock_get_branch): mock_get_branch.return_value = "main" # Should fallback to default behavior (which uses mock_get_latest_version from setUp) self.mock_get_latest_version.return_value = "1.2.3" (self.tmpdir / "mock_file.bzl").write_text("no markers here") - next_version = releaser.determine_next_version() + next_version = utils.determine_next_version() self.assertEqual(next_version, "1.2.4") -class CmdPrepareTest(unittest.TestCase): +class CmdPrepareTest(TempDirTestCase): def setUp(self): - self.mock_git = patch("tools.private.release.release.git").start() - self.mock_gh = patch("tools.private.release.release.gh").start() - self.addCleanup(patch.stopall) + super().setUp() + _mock_git_and_gh(self) - @patch("tools.private.release.release.pathlib.Path") - @patch("tools.private.release.release.changelog_news") - @patch("tools.private.release.release.replace_version_next") - def test_prepare_success_existing_issue( - self, mock_replace, mock_changelog, mock_path - ): + @patch("tools.private.release.prepare.changelog_news") + @patch("tools.private.release.prepare.replace_version_next") + def test_prepare_success_existing_issue(self, mock_replace, mock_changelog): # Arrange - args = MagicMock(version="2.0.0", issue=None) + args = MagicMock(version="2.0.0", issue=None, dry_run=False) self.mock_git.status.side_effect = ["", "M foo"] self.mock_git.branch_exists.return_value = False + self.mock_gh.get_release_tracking_issue.side_effect = None self.mock_gh.get_release_tracking_issue.return_value = 123 self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/456" self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" @@ -704,26 +723,25 @@ def test_prepare_success_existing_issue( self.mock_gh.create_tracking_issue.assert_not_called() self.mock_gh.create_pr.assert_called_once_with("2.0.0", "prepare-2.0.0", 123) - @patch("tools.private.release.release.pathlib.Path") - @patch("tools.private.release.release.changelog_news") - @patch("tools.private.release.release.replace_version_next") - def test_prepare_success_create_issue( - self, mock_replace, mock_changelog, mock_path - ): + @patch("tools.private.release.prepare.changelog_news") + @patch("tools.private.release.prepare.replace_version_next") + def test_prepare_success_create_issue(self, mock_replace, mock_changelog): # Arrange - args = MagicMock(version="2.0.0", issue=None) + template_dir = self.tmpdir / ".github" / "ISSUE_TEMPLATE" + template_dir.mkdir(parents=True, exist_ok=True) + template_file = template_dir / "release_tracking_template.md" + template_file.write_text("dummy template content") + + args = MagicMock(version="2.0.0", issue=None, dry_run=False) self.mock_git.status.side_effect = ["", "M foo"] self.mock_git.branch_exists.return_value = False - self.mock_gh.get_release_tracking_issue.side_effect = ValueError("Not found") + self.mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError( + "Not found" + ) self.mock_gh.create_tracking_issue.return_value = 123 self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/456" self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" - mock_template = MagicMock() - mock_template.exists.return_value = True - mock_template.read_text.return_value = "template content" - mock_path.return_value = mock_template - # Act result = releaser.cmd_prepare(args) @@ -731,20 +749,19 @@ def test_prepare_success_create_issue( self.assertEqual(result, 0) self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") self.mock_gh.create_tracking_issue.assert_called_once_with( - "2.0.0", "template content" + "2.0.0", "dummy template content" ) self.mock_gh.create_pr.assert_called_once_with("2.0.0", "prepare-2.0.0", 123) - @patch("tools.private.release.release.pathlib.Path") - @patch("tools.private.release.release.changelog_news") - @patch("tools.private.release.release.replace_version_next") - def test_prepare_ambiguous_issue(self, mock_replace, mock_changelog, mock_path): + @patch("tools.private.release.prepare.changelog_news") + @patch("tools.private.release.prepare.replace_version_next") + def test_prepare_ambiguous_issue(self, mock_replace, mock_changelog): # Arrange - args = MagicMock(version="2.0.0", issue=None) + args = MagicMock(version="2.0.0", issue=None, dry_run=False) self.mock_git.status.side_effect = ["", "M foo"] self.mock_git.branch_exists.return_value = False - self.mock_gh.get_release_tracking_issue.side_effect = ValueError( - "Multiple open tracking issues" + self.mock_gh.get_release_tracking_issue.side_effect = ( + MultipleTrackingIssuesError("Multiple open tracking issues") ) # Act @@ -756,12 +773,56 @@ def test_prepare_ambiguous_issue(self, mock_replace, mock_changelog, mock_path): self.mock_gh.create_tracking_issue.assert_not_called() self.mock_gh.create_pr.assert_not_called() + @patch("tools.private.release.prepare.changelog_news") + @patch("tools.private.release.prepare.replace_version_next") + def test_prepare_dry_run(self, mock_replace, mock_changelog): + # Arrange + args = MagicMock(version="2.0.0", issue=None, dry_run=True) + self.mock_git.status.side_effect = [""] + self.mock_gh.get_release_tracking_issue.side_effect = None + self.mock_gh.get_release_tracking_issue.return_value = 123 + + # Act + result = releaser.cmd_prepare(args) + + # Assert + self.assertEqual(result, 0) + self.mock_git.checkout.assert_not_called() + self.mock_git.commit.assert_not_called() + self.mock_git.push.assert_not_called() + self.mock_gh.create_pr.assert_not_called() + self.mock_gh.update_issue_body.assert_not_called() + self.mock_git.fetch.assert_called_once() + self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") + + @patch("tools.private.release.prepare.changelog_news") + @patch("tools.private.release.prepare.replace_version_next") + def test_prepare_dry_run_no_issue(self, mock_replace, mock_changelog): + # Arrange + template_dir = self.tmpdir / ".github" / "ISSUE_TEMPLATE" + template_dir.mkdir(parents=True, exist_ok=True) + template_file = template_dir / "release_tracking_template.md" + template_file.write_text("dummy template content") + + args = MagicMock(version="2.0.0", issue=None, dry_run=True) + self.mock_git.status.side_effect = [""] + self.mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError( + "Not found" + ) + + # Act + result = releaser.cmd_prepare(args) + + # Assert + self.assertEqual(result, 0) + self.mock_git.checkout.assert_not_called() + self.mock_gh.create_tracking_issue.assert_not_called() + self.mock_gh.create_pr.assert_not_called() + class CmdCreateRcTest(unittest.TestCase): def setUp(self): - self.mock_git = patch("tools.private.release.release.git").start() - self.mock_gh = patch("tools.private.release.release.gh").start() - self.addCleanup(patch.stopall) + _mock_git_and_gh(self) def test_create_rc_success_first_rc(self): # Arrange @@ -848,9 +909,7 @@ def test_create_rc_already_tagged(self): class CmdPromoteRcTest(unittest.TestCase): def setUp(self): - self.mock_git = patch("tools.private.release.release.git").start() - self.mock_gh = patch("tools.private.release.release.gh").start() - self.addCleanup(patch.stopall) + _mock_git_and_gh(self) def test_promote_rc_success(self): # Arrange @@ -893,6 +952,7 @@ def test_promote_rc_resolve_issue_success(self): args = MagicMock(version="2.0.0", issue=None, dry_run=False) self.mock_git.get_tags.return_value = ["2.0.0-rc1"] self.mock_git.tag_exists.return_value = False + self.mock_gh.get_release_tracking_issue.side_effect = None self.mock_gh.get_release_tracking_issue.return_value = 123 self.mock_git.get_commit_sha.return_value = "abcdef123456" initial_body = "- [ ] Tag Final" @@ -1004,7 +1064,9 @@ def test_promote_rc_issue_not_found(self): args = MagicMock(version="2.0.0", issue=None) self.mock_git.get_tags.return_value = ["2.0.0-rc1"] self.mock_git.tag_exists.return_value = False - self.mock_gh.get_release_tracking_issue.side_effect = ValueError("Not found") + self.mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError( + "Not found" + ) # Act result = releaser.cmd_promote_rc(args) diff --git a/tools/private/release/BUILD.bazel b/tools/private/release/BUILD.bazel index 747cb74e10..96f864141d 100644 --- a/tools/private/release/BUILD.bazel +++ b/tools/private/release/BUILD.bazel @@ -12,7 +12,10 @@ py_binary( srcs = [ "gh.py", "git.py", + "prepare.py", "release.py", + "release_issue.py", + "shell.py", "utils.py", ], main = "release.py", diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py index 09a0d3ae47..4ceb75cad9 100644 --- a/tools/private/release/gh.py +++ b/tools/private/release/gh.py @@ -4,12 +4,24 @@ import os import tempfile -from tools.private.release.utils import run_cmd +from tools.private.release.shell import run_cmd _REPO = "bazel-contrib/rules_python" _LABEL = "type: release" +class MultipleTrackingIssuesError(ValueError): + """Raised when multiple open tracking issues are found for a version.""" + + pass + + +class NoTrackingIssueError(ValueError): + """Raised when no open tracking issue is found for a version.""" + + pass + + def list_issues(*, fields, label=None, state=None, search=None): """Helper to list issues using gh CLI.""" cmd = ["gh", "issue", "list", f"--repo={_REPO}"] @@ -50,13 +62,13 @@ def get_release_tracking_issue(version): exact_matches.append(issue) if not exact_matches: - raise ValueError( + raise NoTrackingIssueError( f"No open tracking issue found matching 'Release {version}' " f"in repo {_REPO} with label '{_LABEL}'" ) if len(exact_matches) > 1: urls = [issue["url"] for issue in exact_matches] - raise ValueError( + raise MultipleTrackingIssuesError( f"Multiple open tracking issues found for version {version} " f"in repo {_REPO} with label '{_LABEL}':\n" + "\n".join(urls) ) diff --git a/tools/private/release/git.py b/tools/private/release/git.py index 4e623e2f7a..f446b5b62a 100644 --- a/tools/private/release/git.py +++ b/tools/private/release/git.py @@ -2,7 +2,7 @@ import subprocess -from tools.private.release.utils import run_cmd +from tools.private.release.shell import run_cmd def get_tags(): diff --git a/tools/private/release/prepare.py b/tools/private/release/prepare.py new file mode 100644 index 0000000000..9542d6d9c9 --- /dev/null +++ b/tools/private/release/prepare.py @@ -0,0 +1,146 @@ +import datetime +import pathlib + +from tools.private.release import changelog_news, gh, git +from tools.private.release.release_issue import update_task_in_body +from tools.private.release.utils import ( + determine_next_version, + replace_version_next, +) + + +def cmd_prepare(args): + """Executes the prepare subcommand.""" + print("Fetching upstream to verify fresh release history...") + git.fetch(tags=True, force=True) + + # Run pre-check: verify there are no local edits + status = git.status() + if status: + print( + "Error: Local edits detected. Workspace must be completely clean" + " before running release preparation." + ) + for line in status.splitlines(): + print(f" {line}") + return 1 + print("Pre-check passed: Workspace is clean.") + + version = args.version + if version is None: + version = determine_next_version() + + print(f"Running preparation pipeline for {version}...") + + # 1. Find or create tracking issue (EARLY) + # We do this before any write operations (branch creation, commit, push) + issue_num = args.issue + + if not issue_num: + try: + issue_num = gh.get_release_tracking_issue(version) + print(f"Tracking issue: #{issue_num}") + except gh.MultipleTrackingIssuesError as e: + print(f"Error: {e}") + return 1 + except gh.NoTrackingIssueError: + # Not found, we need the template + template_path = pathlib.Path( + ".github/ISSUE_TEMPLATE/release_tracking_template.md" + ) + if not template_path.exists(): + raise FileNotFoundError(f"Template file not found at {template_path}") + template_content = template_path.read_text(encoding="utf-8") + + if args.dry_run: + print( + f"[DRY RUN] No active tracking issue found for {version}. Would create a new one." + ) + print(f"[DRY RUN] Title: Release {version}\n{template_content}") + issue_num = None # Keep it None for dry-run prints later + else: + print( + f"No active tracking issue found for {version}. Creating a new one..." + ) + issue_num = gh.create_tracking_issue(version, template_content) + print(f"Tracking issue: #{issue_num}") + else: + print(f"Tracking issue: #{issue_num}") + + branch_name = f"prepare-{version}" + + # 2. Interleaved git and write operations + + # --- Branch selection/creation --- + if git.branch_exists(branch_name): + if args.dry_run: + print( + f"[DRY RUN] Branch {branch_name} already exists. Would checkout existing branch." + ) + else: + print(f"Branch {branch_name} already exists. Checking it out...") + git.checkout(branch_name) + else: + if args.dry_run: + print(f"[DRY RUN] Would create and checkout branch {branch_name}") + else: + git.checkout(branch_name, create_branch=True) + + # --- Update files --- + if args.dry_run: + print( + f"[DRY RUN] Would update CHANGELOG.md and version placeholders for {version}" + ) + else: + print("Updating changelog and placeholders...") + release_date = datetime.date.today().strftime("%Y-%m-%d") + changelog_news.update_changelog(version, release_date) + replace_version_next(version) + + # --- Commit and Push --- + if args.dry_run: + print(f"[DRY RUN] Would push branch {branch_name} to origin") + else: + modified_files = git.status() + if not modified_files: + print("No files modified by the release tool. Nothing to commit.") + return 0 + + # Stage only modified files + for line in modified_files.splitlines(): + file_path = line.strip().split()[-1] + git.add(file_path) + + git.commit(f"Prepare release {version}") + git.push("origin", branch_name) + + # --- Create PR --- + if args.dry_run: + target_issue = f"#{issue_num}" if issue_num else "" + print( + f"[DRY RUN] Would create Pull Request for branch {branch_name} targeting issue {target_issue}" + ) + else: + pr_url = gh.create_pr(version, branch_name, issue_num) + pr_num = pr_url.split("/")[-1] + print(f"Created Pull Request: {pr_url} (PR #{pr_num})") + + # --- Update checklist --- + if args.dry_run: + target_issue = f"#{issue_num}" if issue_num else "" + print( + f"[DRY RUN] Would update tracking issue {target_issue} checklist 'Prepare Release' task status to PENDING" + ) + else: + print( + f"Updating tracking issue #{issue_num} checklist 'Prepare Release' task status to PENDING..." + ) + body = gh.get_issue_body(issue_num) + metadata = {"status": "pending", "pr": f"#{pr_num}"} + updated_body = update_task_in_body( + body, "Prepare Release", checked=False, metadata=metadata + ) + gh.update_issue_body(issue_num, updated_body) + print("Preparation pipeline completed successfully!") + + return 0 diff --git a/tools/private/release/release.py b/tools/private/release/release.py index 3c2eed82ff..64ea953635 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -2,167 +2,26 @@ import argparse import datetime -import fnmatch import os import pathlib import re import sys -from packaging.version import parse as parse_version - from tools.private.release import changelog_news, gh, git - -_REPO_URL = "https://github.com/bazel-contrib/rules_python" - -_EXCLUDE_PATTERNS = [ - "./.git/*", - "./.github/*", - "./.bazelci/*", - "./.bcr/*", - "./bazel-*/*", - "./CONTRIBUTING.md", - "./RELEASING.md", - "./tools/private/release/*", - "./tests/tools/private/release/*", -] +from tools.private.release.prepare import cmd_prepare +from tools.private.release.release_issue import ( + parse_metadata_line, + update_task_in_body, +) +from tools.private.release.utils import ( + _REPO_URL, + determine_next_version, + get_latest_rc_tag, +) _RELEASE_TITLE_RE = re.compile(r"Release (\d+\.\d+\.\d+)", re.IGNORECASE) -def _iter_version_placeholder_files(): - for root, dirs, files in os.walk(".", topdown=True): - # Filter directories - dirs[:] = [ - d - for d in dirs - if not any( - fnmatch.fnmatch(os.path.join(root, d), pattern) - for pattern in _EXCLUDE_PATTERNS - ) - ] - - for filename in files: - filepath = os.path.join(root, filename) - if any(fnmatch.fnmatch(filepath, pattern) for pattern in _EXCLUDE_PATTERNS): - continue - - yield filepath - - -def get_latest_version(): - """Gets the latest version from git tags.""" - tags = git.get_tags() - versions = [ - (tag, parse_version(tag)) - for tag in tags - if re.match(r"^\d+\.\d+\.\d+(rc\d+)?$", tag.strip()) - ] - if not versions: - raise RuntimeError("No git tags found matching X.Y.Z or X.Y.ZrcN format.") - - versions.sort(key=lambda v: v[1]) - latest_tag, latest_version = versions[-1] - - if latest_version.is_prerelease: - raise ValueError(f"The latest version is a pre-release version: {latest_tag}") - - stable_versions = [tag for tag, version in versions if not version.is_prerelease] - if not stable_versions: - raise ValueError("No stable git tags found matching X.Y.Z format.") - - return stable_versions[-1] - - -def get_latest_rc_tag(version): - """Queries git tags and returns the highest RC tag for the version.""" - tags = git.get_tags() - pattern = rf"^{re.escape(version)}-rc\d+$" - rc_tags = [tag.strip() for tag in tags if re.match(pattern, tag.strip())] - if not rc_tags: - return None - rc_tags.sort(key=parse_version) - return rc_tags[-1] - - -def should_increment_minor(): - """Checks if the minor version should be incremented.""" - for filepath in _iter_version_placeholder_files(): - try: - with open(filepath, "r") as f: - content = f.read() - except (IOError, UnicodeDecodeError): - continue - - if "VERSION_NEXT_FEATURE" in content: - return True - return False - - -def determine_next_version(branch_name=None): - """Determines the next version based on git tags and the current branch.""" - if branch_name is None: - branch_name = git.get_current_branch() - - if branch_name: - release_match = re.match(r"^release/(\d+)\.(\d+)$", branch_name) - if release_match: - branch_major = int(release_match.group(1)) - branch_minor = int(release_match.group(2)) - print( - f"Detected release branch: {branch_name} (targeting" - f" {branch_major}.{branch_minor}.x)" - ) - - tags = git.get_tags() - matching_patches = [] - for tag in tags: - tag = tag.strip() - m = re.match(rf"^{branch_major}\.{branch_minor}\.(\d+)$", tag) - if m: - matching_patches.append(int(m.group(1))) - - if matching_patches: - latest_patch = max(matching_patches) - next_version = f"{branch_major}.{branch_minor}.{latest_patch + 1}" - print( - f"Latest tag on this branch is" - f" {branch_major}.{branch_minor}.{latest_patch}. Next" - f" version: {next_version}" - ) - return next_version - else: - next_version = f"{branch_major}.{branch_minor}.0" - print( - f"No stable tags found for {branch_major}.{branch_minor}.x." - f" Next version: {next_version}" - ) - return next_version - - latest_version = get_latest_version() - major, minor, patch = [int(n) for n in latest_version.split(".")] - - if should_increment_minor(): - return f"{major}.{minor + 1}.0" - else: - 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(): - try: - with open(filepath, "r") as f: - content = f.read() - 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) - - def _semver_type(value): if not re.match(r"^\d+\.\d+\.\d+(rc\d+)?$", value): raise argparse.ArgumentTypeError( @@ -176,65 +35,6 @@ def _semver_type(value): # ============================================================================== -def parse_metadata_line(line): - """Parses a checklist line with optional | key=value metadata.""" - match = re.match(r"^\s*-\s*\[([ xX])\]\s+([^|]+)(?:\s*\|\s*(.*))?$", line) - if not match: - return None - - checked = match.group(1).lower() == "x" - name = match.group(2).strip() - metadata_str = match.group(3) - - metadata = {} - if metadata_str: - pairs = metadata_str.strip().split() - for pair in pairs: - if "=" in pair: - k, v = pair.split("=", 1) - metadata[k] = v - - return { - "checked": checked, - "name": name, - "metadata": metadata, - "original_line": line, - } - - -def format_metadata_line(checked, name, metadata): - """Formats a checklist line with space-separated key=value metadata.""" - check_str = "x" if checked else " " - if not metadata: - return f"- [{check_str}] {name}" - - metadata_str = " ".join(f"{k}={v}" for k, v in metadata.items()) - return f"- [{check_str}] {name} | {metadata_str}" - - -def update_task_in_body(body, task_name, checked, metadata): - """Updates a specific task's checked state and metadata in the issue body.""" - lines = body.splitlines() - updated_lines = [] - found = False - - for line in lines: - parsed = parse_metadata_line(line) - if parsed and parsed["name"].lower() == task_name.lower(): - updated_lines.append(format_metadata_line(checked, task_name, metadata)) - found = True - else: - updated_lines.append(line) - - if not found: - raise ValueError( - f"Task '{task_name}' not found in issue body. " - f"Expected format: '- [ ] {task_name}' or '- [x] {task_name}' (optionally followed by '| key=value')" - ) - - return "\n".join(updated_lines) - - def parse_checklist_state(body): """Parses the main checklist tasks and their metadata.""" state = { @@ -366,91 +166,6 @@ def cmd_create_release_issue(args): return 0 -def cmd_prepare(args): - """Executes the prepare subcommand.""" - print("Fetching upstream to verify fresh release history...") - git.fetch(tags=True, force=True) - - # Run pre-check: verify there are no local edits - status = git.status() - if status: - print( - "Error: Local edits detected. Workspace must be completely clean" - " before running release preparation." - ) - for line in status.splitlines(): - print(f" {line}") - return 1 - print("Pre-check passed: Workspace is clean.") - - version = args.version - if version is None: - version = determine_next_version() - - print(f"Running preparation pipeline for v{version}...") - - branch_name = f"prepare-{version}" - if git.branch_exists(branch_name): - print(f"Branch {branch_name} already exists. Checking it out...") - git.checkout(branch_name) - else: - git.checkout(branch_name, create_branch=True) - - print("Updating changelog and placeholders...") - release_date = datetime.date.today().strftime("%Y-%m-%d") - changelog_news.update_changelog(version, release_date) - replace_version_next(version) - - modified_files = git.status() - if not modified_files: - print("No files modified by the release tool. Nothing to commit.") - return 0 - - # Stage only modified files - for line in modified_files.splitlines(): - file_path = line.strip().split()[-1] - git.add(file_path) - - git.commit(f"Prepare release {version}") - git.push("origin", branch_name) - - issue_num = args.issue - if not issue_num: - try: - issue_num = gh.get_release_tracking_issue(version) - print(f"Found active tracking issue #{issue_num} for v{version}") - except ValueError as e: - if "Multiple open tracking issues" in str(e): - print(f"Error: {e}") - return 1 - print( - f"No active tracking issue found for v{version}. Creating a new one..." - ) - template_path = pathlib.Path( - ".github/ISSUE_TEMPLATE/release_tracking_template.md" - ) - if not template_path.exists(): - raise FileNotFoundError(f"Template file not found at {template_path}") - template_content = template_path.read_text(encoding="utf-8") - issue_num = gh.create_tracking_issue(version, template_content) - - print(f"Using tracking issue #{issue_num}") - - pr_url = gh.create_pr(version, branch_name, issue_num) - pr_num = pr_url.split("/")[-1] - print(f"Created Pull Request: {pr_url} (PR #{pr_num})") - - print(f"Updating tracking issue #{issue_num} checklist status to PENDING...") - body = gh.get_issue_body(issue_num) - metadata = {"status": "pending", "pr": f"#{pr_num}"} - updated_body = update_task_in_body( - body, "Prepare Release", checked=False, metadata=metadata - ) - gh.update_issue_body(issue_num, updated_body) - print("Preparation pipeline completed successfully!") - return 0 - - def cmd_complete_prepare(args): """Executes the complete-prepare subcommand (Phase 2 PR merged).""" print(f"Completing preparation for PR #{args.pr}...") @@ -896,6 +611,12 @@ def create_parser(): type=int, help="The tracking issue number (optional, triggers automated branch/PR pipeline).", ) + prepare_parser.add_argument( + "--dry-run", + action=argparse.BooleanOptionalAction, + default=True, + help="Perform a dry run (default: True). Use --no-dry-run to actually execute.", + ) # Subcommand: complete-prepare complete_prep_parser = subparsers.add_parser( diff --git a/tools/private/release/release_issue.py b/tools/private/release/release_issue.py new file mode 100644 index 0000000000..27bcf3d774 --- /dev/null +++ b/tools/private/release/release_issue.py @@ -0,0 +1,62 @@ +"""Helper functions for managing release tracking issues and checklists.""" + +import re + + +def parse_metadata_line(line): + """Parses a checklist line with optional | key=value metadata.""" + match = re.match(r"^\s*-\s*\[([ xX])\]\s+([^|]+)(?:\s*\|\s*(.*))?$", line) + if not match: + return None + + checked = match.group(1).lower() == "x" + name = match.group(2).strip() + metadata_str = match.group(3) + + metadata = {} + if metadata_str: + pairs = metadata_str.strip().split() + for pair in pairs: + if "=" in pair: + k, v = pair.split("=", 1) + metadata[k] = v + + return { + "checked": checked, + "name": name, + "metadata": metadata, + "original_line": line, + } + + +def format_metadata_line(checked, name, metadata): + """Formats a checklist line with space-separated key=value metadata.""" + check_str = "x" if checked else " " + if not metadata: + return f"- [{check_str}] {name}" + + metadata_str = " ".join(f"{k}={v}" for k, v in metadata.items()) + return f"- [{check_str}] {name} | {metadata_str}" + + +def update_task_in_body(body, task_name, checked, metadata): + """Updates a specific task's checked state and metadata in the issue body.""" + lines = body.splitlines() + updated_lines = [] + found = False + + for line in lines: + parsed = parse_metadata_line(line) + if parsed and parsed["name"].lower() == task_name.lower(): + updated_lines.append(format_metadata_line(checked, task_name, metadata)) + found = True + else: + updated_lines.append(line) + + if not found: + raise ValueError( + f"Task '{task_name}' not found in issue body. " + f"Expected format: '- [ ] {task_name}' or '- [x] {task_name}' (optionally followed by '| key=value')" + ) + + return "\n".join(updated_lines) diff --git a/tools/private/release/shell.py b/tools/private/release/shell.py new file mode 100644 index 0000000000..cfff53f4e4 --- /dev/null +++ b/tools/private/release/shell.py @@ -0,0 +1,29 @@ +"""Shell utility functions for the release tool.""" + +import shlex +import subprocess + + +def run_cmd(*args, check=True, capture_output=True): + """Runs a command as a subprocess with separate arguments (prints command). + + If the command fails, it raises the CalledProcessError after attaching + a detailed note explaining the failure to preserve the stack trace. + """ + cmd = [str(arg) for arg in args] + print(f"Running: {shlex.join(cmd)}") + try: + result = subprocess.run( + cmd, + check=check, + stdout=subprocess.PIPE if capture_output else None, + stderr=subprocess.PIPE if capture_output else None, + universal_newlines=True, + ) + return result.stdout.strip() if capture_output else None + except subprocess.CalledProcessError as e: + note = f"Error running command: {shlex.join(cmd)}" + if capture_output: + note += f"\nStdout: {e.stdout}\nStderr: {e.stderr}" + e.add_note(note) + raise diff --git a/tools/private/release/utils.py b/tools/private/release/utils.py index 5eb7c43a71..83ecbb9c7a 100644 --- a/tools/private/release/utils.py +++ b/tools/private/release/utils.py @@ -1,29 +1,157 @@ """Utility functions for the release tool.""" -import shlex -import subprocess - - -def run_cmd(*args, check=True, capture_output=True): - """Runs a command as a subprocess with separate arguments (prints command). - - If the command fails, it raises the CalledProcessError after attaching - a detailed note explaining the failure to preserve the stack trace. - """ - cmd = [str(arg) for arg in args] - print(f"Running: {shlex.join(cmd)}") - try: - result = subprocess.run( - cmd, - check=check, - stdout=subprocess.PIPE if capture_output else None, - stderr=subprocess.PIPE if capture_output else None, - universal_newlines=True, - ) - return result.stdout.strip() if capture_output else None - except subprocess.CalledProcessError as e: - note = f"Error running command: {shlex.join(cmd)}" - if capture_output: - note += f"\nStdout: {e.stdout}\nStderr: {e.stderr}" - e.add_note(note) - raise +import fnmatch +import os +import re + +from packaging.version import parse as parse_version + +from tools.private.release import git + +_REPO_URL = "https://github.com/bazel-contrib/rules_python" + +_EXCLUDE_PATTERNS = [ + "./.git/*", + "./.github/*", + "./.bazelci/*", + "./.bcr/*", + "./bazel-*/*", + "./CONTRIBUTING.md", + "./RELEASING.md", + "./tools/private/release/*", + "./tests/tools/private/release/*", +] + + +def _iter_version_placeholder_files(): + for root, dirs, files in os.walk(".", topdown=True): + # Filter directories + dirs[:] = [ + d + for d in dirs + if not any( + fnmatch.fnmatch(os.path.join(root, d), pattern) + for pattern in _EXCLUDE_PATTERNS + ) + ] + + for filename in files: + filepath = os.path.join(root, filename) + if any(fnmatch.fnmatch(filepath, pattern) for pattern in _EXCLUDE_PATTERNS): + continue + + yield filepath + + +def get_latest_version(): + """Gets the latest version from git tags.""" + tags = git.get_tags() + versions = [ + (tag, parse_version(tag)) + for tag in tags + if re.match(r"^\d+\.\d+\.\d+(rc\d+)?$", tag.strip()) + ] + if not versions: + raise RuntimeError("No git tags found matching X.Y.Z or X.Y.ZrcN format.") + + versions.sort(key=lambda v: v[1]) + latest_tag, latest_version = versions[-1] + + if latest_version.is_prerelease: + raise ValueError(f"The latest version is a pre-release version: {latest_tag}") + + stable_versions = [tag for tag, version in versions if not version.is_prerelease] + if not stable_versions: + raise ValueError("No stable git tags found matching X.Y.Z format.") + + return stable_versions[-1] + + +def get_latest_rc_tag(version): + """Queries git tags and returns the highest RC tag for the version.""" + tags = git.get_tags() + pattern = rf"^{re.escape(version)}-rc\d+$" + rc_tags = [tag.strip() for tag in tags if re.match(pattern, tag.strip())] + if not rc_tags: + return None + rc_tags.sort(key=parse_version) + return rc_tags[-1] + + +def should_increment_minor(): + """Checks if the minor version should be incremented.""" + for filepath in _iter_version_placeholder_files(): + try: + with open(filepath, "r") as f: + content = f.read() + except (IOError, UnicodeDecodeError): + continue + + if "VERSION_NEXT_FEATURE" in content: + return True + return False + + +def determine_next_version(branch_name=None): + """Determines the next version based on git tags and the current branch.""" + if branch_name is None: + branch_name = git.get_current_branch() + + if branch_name: + release_match = re.match(r"^release/(\d+)\.(\d+)$", branch_name) + if release_match: + branch_major = int(release_match.group(1)) + branch_minor = int(release_match.group(2)) + print( + f"Detected release branch: {branch_name} (targeting" + f" {branch_major}.{branch_minor}.x)" + ) + + tags = git.get_tags() + matching_patches = [] + for tag in tags: + tag = tag.strip() + m = re.match(rf"^{branch_major}\.{branch_minor}\.(\d+)$", tag) + if m: + matching_patches.append(int(m.group(1))) + + if matching_patches: + latest_patch = max(matching_patches) + next_version = f"{branch_major}.{branch_minor}.{latest_patch + 1}" + print( + f"Latest tag on this branch is" + f" {branch_major}.{branch_minor}.{latest_patch}. Next" + f" version: {next_version}" + ) + return next_version + else: + next_version = f"{branch_major}.{branch_minor}.0" + print( + f"No stable tags found for {branch_major}.{branch_minor}.x." + f" Next version: {next_version}" + ) + return next_version + + latest_version = get_latest_version() + major, minor, patch = [int(n) for n in latest_version.split(".")] + + if should_increment_minor(): + return f"{major}.{minor + 1}.0" + else: + 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(): + try: + with open(filepath, "r") as f: + content = f.read() + 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) From 483330704b87e0e5ad9234522f7b5b49525443cc Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Mon, 29 Jun 2026 07:41:47 +0000 Subject: [PATCH 2/4] Fix PR creation by removing non-existent label and improve error reporting --- tools/private/release/gh.py | 1 - tools/private/release/release.py | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py index 4ceb75cad9..b55893c0da 100644 --- a/tools/private/release/gh.py +++ b/tools/private/release/gh.py @@ -160,7 +160,6 @@ def create_pr(version, branch, issue_num): f"--body=Work towards #{issue_num}", f"--head={branch}", "--base=main", - "--label=release-prepared", ) diff --git a/tools/private/release/release.py b/tools/private/release/release.py index 64ea953635..d9a225d5cf 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -719,6 +719,9 @@ def main(): exit_code = cmd_promote_rc(args) except Exception as e: print(f"Fatal error executing {args.command}: {e}", file=sys.stderr) + if hasattr(e, "__notes__"): + for note in e.__notes__: + print(note, file=sys.stderr) sys.exit(1) sys.exit(exit_code if exit_code is not None else 0) From 11b6abe49c09a350675585ae4121517d763a0eea Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Mon, 29 Jun 2026 07:44:45 +0000 Subject: [PATCH 3/4] Fix PR creation: set upstream tracking and use gh auto-detection --- tests/tools/private/release/release_test.py | 4 ++-- tools/private/release/gh.py | 3 +-- tools/private/release/git.py | 8 ++++++-- tools/private/release/prepare.py | 4 ++-- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index 63da0bc084..c5e4979591 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -721,7 +721,7 @@ def test_prepare_success_existing_issue(self, mock_replace, mock_changelog): self.assertEqual(result, 0) self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") self.mock_gh.create_tracking_issue.assert_not_called() - self.mock_gh.create_pr.assert_called_once_with("2.0.0", "prepare-2.0.0", 123) + self.mock_gh.create_pr.assert_called_once_with("2.0.0", 123) @patch("tools.private.release.prepare.changelog_news") @patch("tools.private.release.prepare.replace_version_next") @@ -751,7 +751,7 @@ def test_prepare_success_create_issue(self, mock_replace, mock_changelog): self.mock_gh.create_tracking_issue.assert_called_once_with( "2.0.0", "dummy template content" ) - self.mock_gh.create_pr.assert_called_once_with("2.0.0", "prepare-2.0.0", 123) + self.mock_gh.create_pr.assert_called_once_with("2.0.0", 123) @patch("tools.private.release.prepare.changelog_news") @patch("tools.private.release.prepare.replace_version_next") diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py index b55893c0da..f37d87f396 100644 --- a/tools/private/release/gh.py +++ b/tools/private/release/gh.py @@ -150,7 +150,7 @@ def update_issue_body(issue_num, body): os.unlink(temp_path) -def create_pr(version, branch, issue_num): +def create_pr(version, issue_num): """Creates a pull request for release preparation.""" return run_cmd( "gh", @@ -158,7 +158,6 @@ def create_pr(version, branch, issue_num): "create", f"--title=Prepare release v{version}", f"--body=Work towards #{issue_num}", - f"--head={branch}", "--base=main", ) diff --git a/tools/private/release/git.py b/tools/private/release/git.py index f446b5b62a..a25ec67d20 100644 --- a/tools/private/release/git.py +++ b/tools/private/release/git.py @@ -36,9 +36,13 @@ def commit(message, amend=False, no_edit=False): run_cmd(*cmd, capture_output=False) -def push(remote, ref): +def push(remote, ref, set_upstream=False): """Pushes a reference to a remote repository.""" - run_cmd("git", "push", remote, ref, capture_output=False) + cmd = ["git", "push"] + if set_upstream: + cmd.append("-u") + cmd.extend([remote, ref]) + run_cmd(*cmd, capture_output=False) def fetch(remote="origin", tags=False, force=False): diff --git a/tools/private/release/prepare.py b/tools/private/release/prepare.py index 9542d6d9c9..726e5fc550 100644 --- a/tools/private/release/prepare.py +++ b/tools/private/release/prepare.py @@ -112,7 +112,7 @@ def cmd_prepare(args): git.add(file_path) git.commit(f"Prepare release {version}") - git.push("origin", branch_name) + git.push("origin", branch_name, set_upstream=True) # --- Create PR --- if args.dry_run: @@ -121,7 +121,7 @@ def cmd_prepare(args): f"[DRY RUN] Would create Pull Request for branch {branch_name} targeting issue {target_issue}" ) else: - pr_url = gh.create_pr(version, branch_name, issue_num) + pr_url = gh.create_pr(version, issue_num) pr_num = pr_url.split("/")[-1] print(f"Created Pull Request: {pr_url} (PR #{pr_num})") From 36d023c15820b70b12c940ecb9d8de4607f90c7e Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Mon, 29 Jun 2026 15:36:16 +0000 Subject: [PATCH 4/4] chore(release): use git add --update via helper to stage files --- tests/tools/private/release/release_test.py | 5 +++++ tools/private/release/git.py | 5 +++++ tools/private/release/prepare.py | 6 ++---- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index c5e4979591..534aa2a5fd 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -722,6 +722,7 @@ def test_prepare_success_existing_issue(self, mock_replace, mock_changelog): self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") self.mock_gh.create_tracking_issue.assert_not_called() self.mock_gh.create_pr.assert_called_once_with("2.0.0", 123) + self.mock_git.add_modified_and_deleted.assert_called_once() @patch("tools.private.release.prepare.changelog_news") @patch("tools.private.release.prepare.replace_version_next") @@ -752,6 +753,7 @@ def test_prepare_success_create_issue(self, mock_replace, mock_changelog): "2.0.0", "dummy template content" ) self.mock_gh.create_pr.assert_called_once_with("2.0.0", 123) + self.mock_git.add_modified_and_deleted.assert_called_once() @patch("tools.private.release.prepare.changelog_news") @patch("tools.private.release.prepare.replace_version_next") @@ -772,6 +774,7 @@ def test_prepare_ambiguous_issue(self, mock_replace, mock_changelog): self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") self.mock_gh.create_tracking_issue.assert_not_called() self.mock_gh.create_pr.assert_not_called() + self.mock_git.add_modified_and_deleted.assert_not_called() @patch("tools.private.release.prepare.changelog_news") @patch("tools.private.release.prepare.replace_version_next") @@ -794,6 +797,7 @@ def test_prepare_dry_run(self, mock_replace, mock_changelog): self.mock_gh.update_issue_body.assert_not_called() self.mock_git.fetch.assert_called_once() self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0") + self.mock_git.add_modified_and_deleted.assert_not_called() @patch("tools.private.release.prepare.changelog_news") @patch("tools.private.release.prepare.replace_version_next") @@ -818,6 +822,7 @@ def test_prepare_dry_run_no_issue(self, mock_replace, mock_changelog): self.mock_git.checkout.assert_not_called() self.mock_gh.create_tracking_issue.assert_not_called() self.mock_gh.create_pr.assert_not_called() + self.mock_git.add_modified_and_deleted.assert_not_called() class CmdCreateRcTest(unittest.TestCase): diff --git a/tools/private/release/git.py b/tools/private/release/git.py index a25ec67d20..9c2662d4e6 100644 --- a/tools/private/release/git.py +++ b/tools/private/release/git.py @@ -24,6 +24,11 @@ def add(*files): run_cmd("git", "add", *files, capture_output=False) +def add_modified_and_deleted(): + """Stages all modified and deleted tracked files.""" + run_cmd("git", "add", "--update", capture_output=False) + + def commit(message, amend=False, no_edit=False): """Commits staged changes, optionally amending the previous commit.""" cmd = ["git", "commit"] diff --git a/tools/private/release/prepare.py b/tools/private/release/prepare.py index 726e5fc550..1d946bda6c 100644 --- a/tools/private/release/prepare.py +++ b/tools/private/release/prepare.py @@ -106,10 +106,8 @@ def cmd_prepare(args): print("No files modified by the release tool. Nothing to commit.") return 0 - # Stage only modified files - for line in modified_files.splitlines(): - file_path = line.strip().split()[-1] - git.add(file_path) + # Stage all modified and deleted tracked files + git.add_modified_and_deleted() git.commit(f"Prepare release {version}") git.push("origin", branch_name, set_upstream=True)