From 2a5804997a20c77b18814633f7969419cd3d3caf Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Thu, 2 Jul 2026 07:11:44 +0000 Subject: [PATCH 1/3] Refactor release tool commands into classes. Factored out cmd_* functions from release.py into separate classes with explicit dependency injection of Git and GitHub helpers. This improves testability and code organization. Simplified mocks in tests to use injection instead of module-level patching. --- tests/tools/private/release/release_test.py | 172 +++--- tools/private/release/BUILD.bazel | 5 + tools/private/release/__init__.py | 1 + tools/private/release/complete_prepare.py | 81 +++ tools/private/release/create_rc.py | 216 ++++--- .../private/release/create_release_branch.py | 171 ++++-- tools/private/release/create_release_issue.py | 59 ++ .../private/release/determine_next_version.py | 30 + tools/private/release/gh.py | 547 +++++++++++------- tools/private/release/git.py | 543 ++++++++++------- tools/private/release/prepare.py | 321 +++++----- tools/private/release/process_backports.py | 470 ++++++++------- tools/private/release/promote_rc.py | 143 +++++ tools/private/release/release.py | 360 +----------- tools/private/release/release_issue.py | 124 ++-- tools/private/release/shell.py | 8 +- tools/private/release/utils.py | 16 +- 17 files changed, 1904 insertions(+), 1363 deletions(-) create mode 100644 tools/private/release/__init__.py create mode 100644 tools/private/release/complete_prepare.py create mode 100644 tools/private/release/create_release_issue.py create mode 100644 tools/private/release/determine_next_version.py create mode 100644 tools/private/release/promote_rc.py diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index 1307f5e78b..96bb65280f 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -6,8 +6,17 @@ import unittest from unittest.mock import MagicMock, call, patch -from tools.private.release import changelog_news, git, release as releaser, utils -from tools.private.release.gh import MultipleTrackingIssuesError, NoTrackingIssueError +from tools.private.release import changelog_news, release as releaser, utils +from tools.private.release.create_rc import CreateRc +from tools.private.release.create_release_branch import CreateReleaseBranch +from tools.private.release.gh import ( + MultipleTrackingIssuesError, + NoTrackingIssueError, +) +from tools.private.release.git import Git +from tools.private.release.prepare import Prepare +from tools.private.release.process_backports import ProcessBackports +from tools.private.release.promote_rc import PromoteRc def _mock_git_and_gh(test_case): @@ -16,19 +25,9 @@ def _mock_git_and_gh(test_case): 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.create_release_branch.git", new=mock_git).start() - patch("tools.private.release.create_rc.git", new=mock_git).start() - patch("tools.private.release.process_backports.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() - patch("tools.private.release.create_release_branch.gh", new=mock_gh).start() - patch("tools.private.release.create_rc.gh", new=mock_gh).start() - patch("tools.private.release.process_backports.gh", new=mock_gh).start() + # Mock Git inside utils.py since it instantiates it locally + patch("tools.private.release.utils.Git", return_value=mock_git).start() + mock_gh.MultipleTrackingIssuesError = MultipleTrackingIssuesError mock_gh.NoTrackingIssueError = NoTrackingIssueError @@ -528,12 +527,12 @@ def test_invalid_version(self): class GetLatestVersionTest(unittest.TestCase): - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.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(utils.get_latest_version(), "1.0.0") - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.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( @@ -541,7 +540,7 @@ def test_get_latest_version_rc_is_latest(self, mock_get_tags): ): utils.get_latest_version() - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_tags") def test_get_latest_version_no_tags(self, mock_get_tags): mock_get_tags.return_value = [] with self.assertRaisesRegex( @@ -549,7 +548,7 @@ def test_get_latest_version_no_tags(self, mock_get_tags): ): utils.get_latest_version() - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.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( @@ -557,7 +556,7 @@ def test_get_latest_version_no_matching_tags(self, mock_get_tags): ): utils.get_latest_version() - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.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( @@ -567,17 +566,17 @@ def test_get_latest_version_only_rc_tags(self, mock_get_tags): class GetLatestRcTagTest(unittest.TestCase): - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_tags") def test_get_latest_rc_tag_no_tags(self, mock_get_tags): mock_get_tags.return_value = [] self.assertIsNone(utils.get_latest_rc_tag("2.0.0")) - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.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(utils.get_latest_rc_tag("2.0.0")) - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_tags") def test_get_latest_rc_tag_success(self, mock_get_tags): mock_get_tags.return_value = [ "2.0.0-rc0", @@ -587,12 +586,12 @@ def test_get_latest_rc_tag_success(self, mock_get_tags): ] self.assertEqual(utils.get_latest_rc_tag("2.0.0"), "2.0.0-rc2") - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.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(utils.get_latest_rc_tag("2.0.0"), "2.0.0-rc1") - @patch("tools.private.release.git.get_remote_tags") + @patch("tools.private.release.git.Git.get_remote_tags") def test_get_latest_rc_tag_remote_success(self, mock_get_remote_tags): mock_get_remote_tags.return_value = [ "2.0.0-rc0", @@ -611,7 +610,7 @@ def setUp(self): "tools.private.release.utils.get_latest_version" ).start() self.mock_get_current_branch = patch( - "tools.private.release.git.get_current_branch" + "tools.private.release.git.Git.get_current_branch" ).start() self.mock_get_current_branch.return_value = "main" self.addCleanup(patch.stopall) @@ -657,8 +656,8 @@ def test_both_markers(self): self.assertEqual(next_version, "1.3.0") - @patch("tools.private.release.git.get_current_branch") - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_current_branch") + @patch("tools.private.release.git.Git.get_tags") def test_determine_next_version_on_release_branch_with_existing_tags( self, mock_get_tags, mock_get_branch ): @@ -669,8 +668,8 @@ def test_determine_next_version_on_release_branch_with_existing_tags( self.assertEqual(next_version, "0.37.2") - @patch("tools.private.release.git.get_current_branch") - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_current_branch") + @patch("tools.private.release.git.Git.get_tags") def test_determine_next_version_on_release_branch_no_tags( self, mock_get_tags, mock_get_branch ): @@ -681,8 +680,8 @@ def test_determine_next_version_on_release_branch_no_tags( self.assertEqual(next_version, "0.38.0") - @patch("tools.private.release.git.get_current_branch") - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_current_branch") + @patch("tools.private.release.git.Git.get_tags") def test_determine_next_version_on_release_branch_with_active_rc( self, mock_get_tags, mock_get_branch ): @@ -695,8 +694,8 @@ def test_determine_next_version_on_release_branch_with_active_rc( # Should target 0.37.0, not 0.37.1 self.assertEqual(next_version, "0.37.0") - @patch("tools.private.release.git.get_current_branch") - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_current_branch") + @patch("tools.private.release.git.Git.get_tags") def test_determine_next_version_on_release_branch_with_stable_and_active_patch_rc( self, mock_get_tags, mock_get_branch ): @@ -709,7 +708,7 @@ def test_determine_next_version_on_release_branch_with_stable_and_active_patch_r # Should target 0.37.1, not 0.37.2 self.assertEqual(next_version, "0.37.1") - @patch("tools.private.release.git.get_current_branch") + @patch("tools.private.release.git.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) @@ -739,7 +738,7 @@ def test_prepare_success_existing_issue(self, mock_replace, mock_changelog): self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" # Act - result = releaser.cmd_prepare(args) + result = Prepare(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -768,7 +767,7 @@ def test_prepare_success_create_issue(self, mock_replace, mock_changelog): self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" # Act - result = releaser.cmd_prepare(args) + result = Prepare(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -791,7 +790,7 @@ def test_prepare_ambiguous_issue(self, mock_replace, mock_changelog): ) # Act - result = releaser.cmd_prepare(args) + result = Prepare(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -810,7 +809,7 @@ def test_prepare_dry_run(self, mock_replace, mock_changelog): self.mock_gh.get_release_tracking_issue.return_value = 123 # Act - result = releaser.cmd_prepare(args) + result = Prepare(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -841,7 +840,7 @@ def test_prepare_use_associated_pr_from_tracking_issue( ) # Act - result = releaser.cmd_prepare(args) + result = Prepare(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -871,7 +870,7 @@ def test_prepare_create_pr_when_none_associated(self, mock_replace, mock_changel self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/789" # Act - result = releaser.cmd_prepare(args) + result = Prepare(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -902,7 +901,7 @@ def test_prepare_reuse_existing_pr(self, mock_replace, mock_changelog): self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" # Act - result = releaser.cmd_prepare(args) + result = Prepare(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -933,7 +932,7 @@ def test_prepare_dry_run_no_issue(self, mock_replace, mock_changelog): ) # Act - result = releaser.cmd_prepare(args) + result = Prepare(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -961,7 +960,7 @@ def test_create_rc_success_first_rc(self): self.mock_git.get_commit_sha.return_value = "1234567890" # Act - result = releaser.cmd_create_rc(args) + result = CreateRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1019,7 +1018,7 @@ def test_create_rc_success_next_rc(self): self.mock_git.get_commit_sha.return_value = "1234567890" # Act - result = releaser.cmd_create_rc(args) + result = CreateRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1075,7 +1074,7 @@ def test_create_rc_gating_on_backports(self): - [ ] #124 | status=pending """ # Act - result = releaser.cmd_create_rc(args) + result = CreateRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -1099,7 +1098,7 @@ def test_create_rc_with_finished_backports(self): self.mock_git.get_commit_sha.return_value = "1234567890" # Act - result = releaser.cmd_create_rc(args) + result = CreateRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1121,7 +1120,7 @@ def test_promote_rc_success(self): self.mock_gh.get_issue_body.return_value = initial_body # Act - result = releaser.cmd_promote_rc(args) + result = PromoteRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1159,7 +1158,7 @@ def test_promote_rc_resolve_issue_success(self): self.mock_gh.get_issue_body.return_value = initial_body # Act - result = releaser.cmd_promote_rc(args) + result = PromoteRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1194,7 +1193,7 @@ def test_promote_rc_defaults_to_determine_next_version(self): self.mock_gh.get_issue_body.return_value = initial_body # Act - result = releaser.cmd_promote_rc(args) + result = PromoteRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1230,7 +1229,7 @@ def test_promote_rc_dry_run_success(self): self.mock_gh.get_issue_body.return_value = initial_body # Act - result = releaser.cmd_promote_rc(args) + result = PromoteRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1251,7 +1250,7 @@ def test_promote_rc_tag_already_exists(self): self.mock_git.tag_exists.return_value = True # Act - result = releaser.cmd_promote_rc(args) + result = PromoteRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -1271,7 +1270,7 @@ def test_promote_rc_issue_not_found(self): ) # Act - result = releaser.cmd_promote_rc(args) + result = PromoteRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -1291,7 +1290,7 @@ def test_promote_rc_issue_malformed(self): self.mock_gh.get_issue_body.return_value = initial_body # Act - result = releaser.cmd_promote_rc(args) + result = PromoteRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -1307,7 +1306,7 @@ def test_promote_rc_no_rc_found(self): self.mock_git.get_remote_tags.return_value = [] # Act - result = releaser.cmd_promote_rc(args) + result = PromoteRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -1333,7 +1332,7 @@ def test_create_release_branch_success(self): self.mock_git.remote_branch_exists.return_value = False # Act - result = releaser.cmd_create_release_branch(args) + result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1362,7 +1361,7 @@ def test_create_release_branch_prepare_not_done(self): - [ ] Create Release branch | status=pending """ # Act - result = releaser.cmd_create_release_branch(args) + result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -1380,7 +1379,7 @@ def test_create_release_branch_already_checked(self): - [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 """ # Act - result = releaser.cmd_create_release_branch(args) + result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1401,7 +1400,7 @@ def test_create_release_branch_already_exists_same_commit(self): self.mock_git.get_commit_sha.return_value = "abcdef12" # Act - result = releaser.cmd_create_release_branch(args) + result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1423,7 +1422,7 @@ def test_create_release_branch_already_exists_fast_forward(self): self.mock_git.is_ancestor.return_value = True # Act - result = releaser.cmd_create_release_branch(args) + result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1447,7 +1446,7 @@ def test_create_release_branch_already_exists_non_ff(self): self.mock_git.is_ancestor.return_value = False # Act - result = releaser.cmd_create_release_branch(args) + result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -1468,7 +1467,7 @@ def test_process_backports_no_pending(self): args = MagicMock(issue=123, remote="origin", dry_run=False) self.mock_gh.get_issue_body.return_value = "No backports here" - result = releaser.cmd_process_backports(args) + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() self.assertEqual(result, 0) self.mock_gh.get_issue_body.assert_called_once_with(123) @@ -1502,7 +1501,7 @@ def mock_resolve(items): self.mock_git.get_commit_sha.return_value = "12345678" self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' - result = releaser.cmd_process_backports(args) + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() self.assertEqual(result, 0) self.mock_git.fetch.assert_has_calls( @@ -1554,7 +1553,7 @@ def mock_resolve(items): self.mock_git.get_commit_sha.return_value = "12345678" self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' - result = releaser.cmd_process_backports(args) + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() self.assertEqual(result, 0) self.mock_git.fetch.assert_has_calls( @@ -1601,7 +1600,7 @@ def mock_resolve(items): self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve - result = releaser.cmd_process_backports(args) + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() self.assertEqual(result, 1) self.mock_gh.update_issue_body.assert_called_once() @@ -1628,7 +1627,7 @@ def test_process_backports_ignored_error_status(self): self.mock_git.get_remote_tags.return_value = [] self.mock_gh.get_merge_commits_for_prs.return_value = [] - result = releaser.cmd_process_backports(args) + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() self.assertEqual(result, 0) self.mock_gh.get_merge_commits_for_prs.assert_not_called() @@ -1661,7 +1660,7 @@ def mock_resolve(items): self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] self.mock_git.cherry_pick.side_effect = Exception("Cherry-pick conflict") - result = releaser.cmd_process_backports(args) + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() self.assertEqual(result, 1) self.mock_git.checkout.assert_called_once_with( @@ -1680,38 +1679,41 @@ def mock_resolve(items): class GitCheckoutTest(unittest.TestCase): - @patch("tools.private.release.git.run_cmd") - def test_checkout_simple(self, mock_run_cmd): - git.checkout("my-branch") - mock_run_cmd.assert_called_once_with( - "git", "checkout", "my-branch", capture_output=False + def setUp(self): + self.git = Git(".") + self.patcher = patch.object(self.git, "_run_git") + self.mock_run_git = self.patcher.start() + self.addCleanup(self.patcher.stop) + + def test_checkout_simple(self): + self.git.checkout("my-branch") + self.mock_run_git.assert_called_once_with( + "checkout", "my-branch", capture_output=False ) - @patch("tools.private.release.git.branch_exists") - @patch("tools.private.release.git.run_cmd") - def test_checkout_track_remote_new_branch(self, mock_run_cmd, mock_branch_exists): + @patch("tools.private.release.git.Git.branch_exists") + def test_checkout_track_remote_new_branch(self, mock_branch_exists): mock_branch_exists.return_value = False - git.checkout("my-branch", track_remote="origin") + self.git.checkout("my-branch", track_remote="origin") mock_branch_exists.assert_called_once_with("my-branch") - mock_run_cmd.assert_called_once_with( - "git", "checkout", "--track", "origin/my-branch", capture_output=False + self.mock_run_git.assert_called_once_with( + "checkout", "--track", "origin/my-branch", capture_output=False ) - @patch("tools.private.release.git.reset_hard") - @patch("tools.private.release.git.branch_exists") - @patch("tools.private.release.git.run_cmd") + @patch("tools.private.release.git.Git.reset_hard") + @patch("tools.private.release.git.Git.branch_exists") def test_checkout_track_remote_existing_branch( - self, mock_run_cmd, mock_branch_exists, mock_reset_hard + self, mock_branch_exists, mock_reset_hard ): mock_branch_exists.return_value = True - git.checkout("my-branch", track_remote="origin") + self.git.checkout("my-branch", track_remote="origin") mock_branch_exists.assert_called_once_with("my-branch") - mock_run_cmd.assert_called_once_with( - "git", "checkout", "my-branch", capture_output=False + self.mock_run_git.assert_called_once_with( + "checkout", "my-branch", capture_output=False ) mock_reset_hard.assert_called_once_with("origin/my-branch") diff --git a/tools/private/release/BUILD.bazel b/tools/private/release/BUILD.bazel index 1ae52cf37a..7ea3d2e535 100644 --- a/tools/private/release/BUILD.bazel +++ b/tools/private/release/BUILD.bazel @@ -10,12 +10,17 @@ py_library( py_binary( name = "release", srcs = [ + "__init__.py", + "complete_prepare.py", "create_rc.py", "create_release_branch.py", + "create_release_issue.py", + "determine_next_version.py", "gh.py", "git.py", "prepare.py", "process_backports.py", + "promote_rc.py", "release.py", "release_issue.py", "shell.py", diff --git a/tools/private/release/__init__.py b/tools/private/release/__init__.py new file mode 100644 index 0000000000..e68f0391f8 --- /dev/null +++ b/tools/private/release/__init__.py @@ -0,0 +1 @@ +"""Release tools package.""" diff --git a/tools/private/release/complete_prepare.py b/tools/private/release/complete_prepare.py new file mode 100644 index 0000000000..7657738aad --- /dev/null +++ b/tools/private/release/complete_prepare.py @@ -0,0 +1,81 @@ +"""Subcommand to mark preparation task as complete.""" + +import re + +from tools.private.release.gh import GitHub +from tools.private.release.release_issue import update_task_in_body + + +class CompletePrepare: + """Class to mark preparation task as complete.""" + + def __init__(self, args, gh: GitHub): + self.args = args + self.gh = gh + + def run(self) -> int: + """Executes the complete-prepare subcommand (Phase 2 PR merged).""" + args = self.args + print(f"Completing preparation for PR #{args.pr}...") + + pr_info = self.gh.get_pr_info(args.pr) + if not pr_info or pr_info.get("state") != "MERGED": + state = pr_info.get("state", "UNKNOWN") + print(f"Error: PR #{args.pr} is not merged yet (state: {state}).") + return 1 + + # Resolve issue number from PR body + pr_body = pr_info.get("body", "") + match = re.search(r"Work towards #(\d+)", pr_body) + if not match: + match = re.search(r"#(\d+)", pr_body) + if not match: + print( + f"Error: Could not determine tracking issue number from PR" + f" #{args.pr} body: {pr_body}" + ) + return 1 + + issue_num = int(match.group(1)) + print(f"Resolved tracking issue #{issue_num} from PR #{args.pr} body.") + + commit_sha = pr_info["mergeCommit"]["oid"] + short_commit = commit_sha[:8] + print( + f"PR #{args.pr} merged at commit {commit_sha}. Updating tracking issue..." + ) + + # Update checklist: mark Prepare Release as done (checked) and set SUCCESS + body = self.gh.get_issue_body(issue_num) + metadata = { + "status": "done", + "pr": f"#{args.pr}", + "commit": short_commit, + } + updated_body = update_task_in_body( + body, "Prepare Release", checked=True, metadata=metadata + ) + self.gh.update_issue_body(issue_num, updated_body) + print("Prepare Release task marked complete successfully!") + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for complete-prepare subcommand.""" + parser = subparsers.add_parser( + "complete-prepare", + help="Mark the Prepare Release task as complete in the tracking issue.", + ) + parser.add_argument( + "--pr", + type=int, + required=True, + help="The merged preparation PR number.", + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + gh = GitHub() + return cls(args, gh).run() diff --git a/tools/private/release/create_rc.py b/tools/private/release/create_rc.py index a4593323aa..ea4d8d7450 100644 --- a/tools/private/release/create_rc.py +++ b/tools/private/release/create_rc.py @@ -1,6 +1,7 @@ """Subcommand to tag and push the next release candidate.""" -from tools.private.release import gh, git +from tools.private.release.gh import GitHub +from tools.private.release.git import Git from tools.private.release.release_issue import ( RELEASE_TITLE_RE, parse_backports, @@ -13,91 +14,104 @@ ) -def cmd_create_rc(args): - """Executes the create-rc subcommand.""" - body = gh.get_issue_body(args.issue) - state = parse_checklist_state(body) - - if ( - state["prepare_release"]["status"] != "done" - or state["create_branch"]["status"] != "done" - ): - print( - "Error: Preconditions not met (release must be prepared and branch created)." - ) - return 1 - - # Gating: RC tagging is blocked if any backport is unchecked OR does not have status=done - backports = parse_backports(body) - conflicting_or_pending = [ - b for b in backports if not b.checked or b.status != "done" - ] - if conflicting_or_pending: - print( - f"Gating RC tagging: {len(conflicting_or_pending)} backports are still" - " unfinished, failed, or in conflict." - ) - return 1 - - # Resolve version and branch - issue_title = gh.get_issue_title(args.issue) - version_match = RELEASE_TITLE_RE.search(issue_title) - if not version_match: - print(f"Error: Could not parse version from issue title: {issue_title}") - return 1 - - version = version_match.group(1) - branch_version = ".".join(version.split(".")[:2]) - branch_name = f"release/{branch_version}" - - # Determine next RC tag - git.fetch(args.remote) - git.fetch(args.remote, tags=True, force=True) - latest_rc = get_latest_rc_tag(version, remote=args.remote) - - if not latest_rc: - next_rc_num = 0 - next_rc = f"{version}-rc0" - else: - rc_num = int(latest_rc.split("-rc")[-1]) - next_rc_num = rc_num + 1 - next_rc = f"{version}-rc{next_rc_num}" - - # Precheck: next RC number must exist and be unchecked in the checklist - rc_tags = state.get("rc_tags", {}) - if next_rc_num not in rc_tags: - print( - f"Error: Checklist is missing required task 'Tag RC{next_rc_num}'" - f" to cut {version}-rc{next_rc_num}." +class CreateRc: + """Class to tag and push the next release candidate.""" + + def __init__(self, args, git: Git, gh: GitHub): + self.args = args + self.git = git + self.gh = gh + + def run(self) -> int: + """Executes the create-rc subcommand.""" + args = self.args + body = self.gh.get_issue_body(args.issue) + state = parse_checklist_state(body) + + if ( + state["prepare_release"].status != "done" + or state["create_branch"].status != "done" + ): + print( + "Error: Preconditions not met (release must be prepared and" + " branch created)." + ) + return 1 + + # Gating: RC tagging is blocked if any backport is unchecked OR does not have status=done + backports = parse_backports(body) + conflicting_or_pending = [ + b for b in backports if not b.checked or b.status != "done" + ] + if conflicting_or_pending: + print( + f"Gating RC tagging: {len(conflicting_or_pending)} backports" + " are still unfinished, failed, or in conflict." + ) + return 1 + + # Resolve version and branch + issue_title = self.gh.get_issue_title(args.issue) + version_match = RELEASE_TITLE_RE.search(issue_title) + if not version_match: + print(f"Error: Could not parse version from issue title: {issue_title}") + return 1 + + version = version_match.group(1) + branch_version = ".".join(version.split(".")[:2]) + branch_name = f"release/{branch_version}" + + # Determine next RC tag + self.git.fetch(args.remote) + self.git.fetch(args.remote, tags=True, force=True) + latest_rc = get_latest_rc_tag(version, remote=args.remote) + + if not latest_rc: + next_rc_num = 0 + next_rc = f"{version}-rc0" + else: + rc_num = int(latest_rc.split("-rc")[-1]) + next_rc_num = rc_num + 1 + next_rc = f"{version}-rc{next_rc_num}" + + # Precheck: next RC number must exist and be unchecked in the checklist + rc_tags = state.get("rc_tags", {}) + if next_rc_num not in rc_tags: + print( + f"Error: Checklist is missing required task 'Tag RC{next_rc_num}'" + f" to cut {version}-rc{next_rc_num}." + ) + return 1 + + target_rc_task = rc_tags[next_rc_num] + if target_rc_task.checked or target_rc_task.status == "done": + print( + f"Error: Task 'Tag RC{next_rc_num}' is already marked done in" + " the checklist." + ) + return 1 + + target_ref = f"{args.remote}/{branch_name}" + commit_sha = self.git.get_commit_sha(target_ref) + + print(f"Tagging and pushing next RC: {next_rc}...") + self.git.tag(next_rc, target_ref) + self.git.push(args.remote, next_rc) + + # Check off the appropriate "Tag RC{N}" task in the checklist + print(f"Checking off Tag RC{next_rc_num} task...") + metadata = {"status": "done", "tag": next_rc, "commit": commit_sha[:8]} + task_name = f"Tag RC{next_rc_num}" + updated_body = update_task_in_body( + body, task_name, checked=True, metadata=metadata ) - return 1 - - target_rc_task = rc_tags[next_rc_num] - if target_rc_task["checked"] or target_rc_task["status"] == "done": - print( - f"Error: Task 'Tag RC{next_rc_num}' is already marked done in the checklist." - ) - return 1 - - target_ref = f"{args.remote}/{branch_name}" - commit_sha = git.get_commit_sha(target_ref) + self.gh.update_issue_body(args.issue, updated_body) - print(f"Tagging and pushing next RC: {next_rc}...") - git.tag(next_rc, target_ref) - git.push(args.remote, next_rc) - - # Check off the appropriate "Tag RC{N}" task in the checklist - print(f"Checking off Tag RC{next_rc_num} task...") - metadata = {"status": "done", "tag": next_rc, "commit": commit_sha[:8]} - task_name = f"Tag RC{next_rc_num}" - updated_body = update_task_in_body(body, task_name, checked=True, metadata=metadata) - gh.update_issue_body(args.issue, updated_body) - - tag_url = f"{REPO_URL}/releases/tag/{next_rc}" - bcr_entry_url = f"https://registry.bazel.build/modules/rules_python/{version}" - bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+{version}" - release_workflow_url = f"{REPO_URL}/actions/workflows/release.yml" - comment_body = f"""**New Release Candidate Tagged!** 🐍🌿 + tag_url = f"{REPO_URL}/releases/tag/{next_rc}" + bcr_entry_url = f"https://registry.bazel.build/modules/rules_python/{version}" + bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+{version}" + release_workflow_url = f"{REPO_URL}/actions/workflows/release.yml" + comment_body = f"""**New Release Candidate Tagged!** 🐍🌿 Release Candidate **{next_rc}** has been successfully generated and tagged on branch `{branch_name}`. @@ -105,6 +119,34 @@ def cmd_create_rc(args): - BCR Entry: [rules_python@{version}]({bcr_entry_url}) - [BCR PRs]({bcr_search_url}) - [Release workflow status]({release_workflow_url})""" - gh.post_issue_comment(args.issue, comment_body) - print("RC creation completed successfully!") - return 0 + self.gh.post_issue_comment(args.issue, comment_body) + print("RC creation completed successfully!") + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for create-rc subcommand.""" + parser = subparsers.add_parser( + "create-rc", + help="Tags the next RC on the release branch if no backports remain.", + ) + parser.add_argument( + "--issue", + type=int, + required=True, + help="The tracking issue number (required).", + ) + parser.add_argument( + "--remote", + type=str, + required=True, + help="The git remote to push the RC tag to (required).", + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + git = Git(".") + gh = GitHub() + return cls(args, git, gh).run() diff --git a/tools/private/release/create_release_branch.py b/tools/private/release/create_release_branch.py index 7b12465f4c..7a9d7eec37 100644 --- a/tools/private/release/create_release_branch.py +++ b/tools/private/release/create_release_branch.py @@ -1,6 +1,7 @@ """Subcommand to create a release branch from a merged PR commit.""" -from tools.private.release import gh, git +from tools.private.release.gh import GitHub +from tools.private.release.git import Git from tools.private.release.release_issue import ( RELEASE_TITLE_RE, parse_checklist_state, @@ -9,76 +10,122 @@ from tools.private.release.utils import REPO_URL -def cmd_create_release_branch(args): - """Executes the create-release-branch subcommand.""" - print(f"Evaluating branch creation for tracking issue #{args.issue}...") - body = gh.get_issue_body(args.issue) - state = parse_checklist_state(body) +class CreateReleaseBranch: + """Class to create a release branch from a merged PR commit.""" - if ( - state["prepare_release"]["status"] != "done" - or not state["prepare_release"]["commit"] - ): - print( - "Error: Prepare Release task is not marked 'done' with a valid commit SHA." - ) - return 1 + def __init__(self, args, git: Git, gh: GitHub): + self.args = args + self.git = git + self.gh = gh - if state["create_branch"]["checked"]: - print("Release branch has already been created and checked. Skipping.") - return 0 + def run(self) -> int: + """Executes the create-release-branch subcommand.""" + args = self.args + print(f"Evaluating branch creation for tracking issue #{args.issue}...") + body = self.gh.get_issue_body(args.issue) + state = parse_checklist_state(body) - # Extract version from issue title - issue_title = gh.get_issue_title(args.issue) - version_match = RELEASE_TITLE_RE.search(issue_title) - if not version_match: - print(f"Error: Could not parse version from issue title: {issue_title}") - return 1 + if ( + state["prepare_release"].status != "done" + or not state["prepare_release"].commit + ): + print( + "Error: Prepare Release task is not marked 'done' with a valid" + " commit SHA." + ) + return 1 - version = version_match.group(1) - branch_version = ".".join(version.split(".")[:2]) - branch_name = f"release/{branch_version}" + if state["create_branch"].checked: + print("Release branch has already been created and checked. Skipping.") + return 0 - commit_sha = state["prepare_release"]["commit"] - print(f"Cutting branch {branch_name} from commit {commit_sha}...") + # Extract version from issue title + issue_title = self.gh.get_issue_title(args.issue) + version_match = RELEASE_TITLE_RE.search(issue_title) + if not version_match: + print(f"Error: Could not parse version from issue title: {issue_title}") + return 1 - # Create and push branch without affecting local checkout - git.fetch(args.remote) + version = version_match.group(1) + branch_version = ".".join(version.split(".")[:2]) + branch_name = f"release/{branch_version}" - if git.remote_branch_exists(args.remote, branch_name): - remote_ref = f"{args.remote}/{branch_name}" - remote_sha = git.get_commit_sha(remote_ref) - if remote_sha == commit_sha: - print( - f"Branch {branch_name} already exists on {args.remote} and points to {commit_sha}. Skipping push." - ) - elif git.is_ancestor(remote_ref, commit_sha): - print( - f"Branch {branch_name} exists on {args.remote} but can be fast-forwarded to {commit_sha}. Pushing..." - ) - ref_spec = f"{commit_sha}:refs/heads/{branch_name}" - git.push(args.remote, ref_spec) + commit_sha = state["prepare_release"].commit + print(f"Cutting branch {branch_name} from commit {commit_sha}...") + + # Create and push branch without affecting local checkout + self.git.fetch(args.remote) + + if self.git.remote_branch_exists(args.remote, branch_name): + remote_ref = f"{args.remote}/{branch_name}" + remote_sha = self.git.get_commit_sha(remote_ref) + if remote_sha == commit_sha: + print( + f"Branch {branch_name} already exists on {args.remote} and" + f" points to {commit_sha}. Skipping push." + ) + elif self.git.is_ancestor(remote_ref, commit_sha): + print( + f"Branch {branch_name} exists on {args.remote} but can be" + f" fast-forwarded to {commit_sha}. Pushing..." + ) + ref_spec = f"{commit_sha}:refs/heads/{branch_name}" + self.git.push(args.remote, ref_spec) + else: + print( + f"Error: Branch {branch_name} already exists on" + f" {args.remote} at {remote_sha[:8]}, which is not an" + f" ancestor of {commit_sha[:8]}. Cannot fast-forward." + ) + return 1 else: + print(f"Branch {branch_name} does not exist on {args.remote}. Pushing...") + ref_spec = f"{commit_sha}:refs/heads/{branch_name}" + self.git.push(args.remote, ref_spec) print( - f"Error: Branch {branch_name} already exists on {args.remote} at {remote_sha[:8]}, " - f"which is not an ancestor of {commit_sha[:8]}. Cannot fast-forward." + f"Successfully pushed branch {branch_name} pointing to" + f" {commit_sha} to {args.remote}" ) - return 1 - else: - print(f"Branch {branch_name} does not exist on {args.remote}. Pushing...") - ref_spec = f"{commit_sha}:refs/heads/{branch_name}" - git.push(args.remote, ref_spec) - print( - f"Successfully pushed branch {branch_name} pointing to {commit_sha} to {args.remote}" + + # Update tracking issue checklist + print("Updating tracking issue checklist...") + branch_url = f"{REPO_URL}/tree/{branch_name}" + metadata = { + "status": "done", + "branch_url": branch_url, + "commit": commit_sha[:8], + } + updated_body = update_task_in_body( + body, "Create Release branch", checked=True, metadata=metadata + ) + self.gh.update_issue_body(args.issue, updated_body) + print("Create Release branch task marked complete successfully!") + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for create-release-branch subcommand.""" + parser = subparsers.add_parser( + "create-release-branch", + help="Create the release branch pointing to the merged PR commit.", + ) + parser.add_argument( + "--issue", + type=int, + required=True, + help="The tracking issue number (required).", + ) + parser.add_argument( + "--remote", + type=str, + required=True, + help="The git remote to create the branch on (required).", ) + parser.set_defaults(command=cls.run_from_args) - # Update tracking issue checklist - print("Updating tracking issue checklist...") - branch_url = f"{REPO_URL}/tree/{branch_name}" - metadata = {"status": "done", "branch_url": branch_url, "commit": commit_sha[:8]} - updated_body = update_task_in_body( - body, "Create Release branch", checked=True, metadata=metadata - ) - gh.update_issue_body(args.issue, updated_body) - print("Create Release branch task marked complete successfully!") - return 0 + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + git = Git(".") + gh = GitHub() + return cls(args, git, gh).run() diff --git a/tools/private/release/create_release_issue.py b/tools/private/release/create_release_issue.py new file mode 100644 index 0000000000..2d00d7369a --- /dev/null +++ b/tools/private/release/create_release_issue.py @@ -0,0 +1,59 @@ +"""Subcommand to create a release tracking issue.""" + +import pathlib + +from tools.private.release.gh import GitHub +from tools.private.release.utils import determine_next_version, semver_type + + +class CreateReleaseIssue: + """Class to create a release tracking issue.""" + + def __init__(self, args, gh: GitHub): + self.args = args + self.gh = gh + + def run(self) -> int: + """Executes the create-release-issue subcommand.""" + version = self.args.version + if version is None: + version = determine_next_version() + + # Concurrency check + open_issues = self.gh.get_open_tracking_issues() + if open_issues: + print("Error: A release is already in progress. Active tracking issues:") + for issue in open_issues: + print(f"- {issue['title']}: {issue['url']}") + return 1 + + 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 = self.gh.create_tracking_issue(version, template_content) + print(f"Created tracking issue #{issue_num} for v{version}") + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for create-release-issue subcommand.""" + parser = subparsers.add_parser( + "create-release-issue", + help="Search for open releases and create a new tracking issue.", + ) + parser.add_argument( + "--version", + type=semver_type, + help="The release version (e.g., 0.38.0). If not provided, determined automatically.", + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + gh = GitHub() + return cls(args, gh).run() diff --git a/tools/private/release/determine_next_version.py b/tools/private/release/determine_next_version.py new file mode 100644 index 0000000000..541daddfe7 --- /dev/null +++ b/tools/private/release/determine_next_version.py @@ -0,0 +1,30 @@ +"""Subcommand to determine the next version.""" + +from tools.private.release.utils import determine_next_version + + +class DetermineNextVersion: + """Class to determine the next version.""" + + def __init__(self, args, git=None, gh=None): + self.args = args + + def run(self) -> int: + """Executes the determine-next-version subcommand.""" + version = determine_next_version() + print(version) + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for determine-next-version subcommand.""" + parser = subparsers.add_parser( + "determine-next-version", + help="Determine the next version and print it, without making any changes.", + ) + 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).run() diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py index cec20ca2d6..8b938aecf6 100644 --- a/tools/private/release/gh.py +++ b/tools/private/release/gh.py @@ -7,9 +7,6 @@ from tools.private.release.release_issue import BackportTask 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.""" @@ -23,224 +20,352 @@ class NoTrackingIssueError(ValueError): 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}"] - if label: - cmd.append(f"--label={label}") - if state: - cmd.append(f"--state={state}") - if search: - cmd.append(f"--search={search}") - cmd.append(f"--json={fields}") - - output = run_cmd(*cmd) - return json.loads(output) if output else [] - - -def get_open_tracking_issues(version=None): - """Returns a list of open tracking issues with the 'type: release' label.""" - search = f'"Release {version}" in:title' if version else None - return list_issues( - label=_LABEL, - state="open", - search=search, - fields="number,title,url", - ) - - -def get_release_tracking_issue(version): - """Resolves the tracking issue number for a given version. - - Searches for an open issue with label 'type: release' and 'Release ' in the title. - Raises ValueError if 0 or multiple issues are found. - """ - matching_issues = get_open_tracking_issues(version) - - exact_matches = [] - for issue in matching_issues: - if issue["title"] == f"Release {version}": - exact_matches.append(issue) - - if not exact_matches: - raise NoTrackingIssueError( - f"No open tracking issue found matching 'Release {version}' " - f"in repo {_REPO} with label '{_LABEL}'" +class GitHub: + """GitHub CLI helper class for the release tool.""" + + def __init__(self, repo: str = "bazel-contrib/rules_python"): + """Initializes the GitHub helper. + + Args: + repo: The GitHub repository to operate on. + """ + self.repo = repo + self.label = "type: release" + + def _run_gh( + self, *args: str, check: bool = True, capture_output: bool = True + ) -> str | None: + """Runs a 'gh' command. + + Args: + *args: Arguments for 'gh' (excluding 'gh'). + check: If True, raises CalledProcessError on failure. + capture_output: If True, captures and returns stdout. + + Returns: + The stdout of the command, stripped, or None. + """ + return run_cmd("gh", *args, check=check, capture_output=capture_output) + + def _gh_issue( + self, *args: str, check: bool = True, capture_output: bool = True + ) -> str | None: + """Runs a 'gh issue' command.""" + return self._run_gh( + "issue", + *args, + f"--repo={self.repo}", + check=check, + capture_output=capture_output, + ) + + def _gh_pr( + self, *args: str, check: bool = True, capture_output: bool = True + ) -> str | None: + """Runs a 'gh pr' command.""" + return self._run_gh( + "pr", + *args, + f"--repo={self.repo}", + check=check, + capture_output=capture_output, ) - if len(exact_matches) > 1: - urls = [issue["url"] for issue in exact_matches] - raise MultipleTrackingIssuesError( - f"Multiple open tracking issues found for version {version} " - f"in repo {_REPO} with label '{_LABEL}':\n" + "\n".join(urls) + + def list_issues( + self, + *, + fields: str, + label: str | None = None, + state: str | None = None, + search: str | None = None, + ) -> list[dict]: + """Helper to list issues using gh CLI. + + Args: + fields: Comma-separated list of fields to return. + label: Filter by label. + state: Filter by state (open, closed, all). + search: Search query. + + Returns: + A list of dictionaries representing the issues. + """ + cmd = ["list"] + if label: + cmd.append(f"--label={label}") + if state: + cmd.append(f"--state={state}") + if search: + cmd.append(f"--search={search}") + cmd.append(f"--json={fields}") + + output = self._gh_issue(*cmd) + return json.loads(output) if output else [] + + def get_open_tracking_issues(self, version: str | None = None) -> list[dict]: + """Returns a list of open tracking issues with the 'type: release' label. + + Args: + version: Optional version to filter by. + + Returns: + A list of open tracking issues. + """ + search = f'"Release {version}" in:title' if version else None + return self.list_issues( + label=self.label, + state="open", + search=search, + fields="number,title,url", ) - return exact_matches[0]["number"] + def get_release_tracking_issue(self, version: str) -> int: + """Resolves the tracking issue number for a given version. + + Searches for an open issue with label 'type: release' and 'Release + ' in the title. + + Args: + version: The version to find the tracking issue for. + + Returns: + The tracking issue number. + + Raises: + NoTrackingIssueError: If no open tracking issue is found. + MultipleTrackingIssuesError: If multiple open tracking issues are + found. + """ + matching_issues = self.get_open_tracking_issues(version) + + exact_matches = [] + for issue in matching_issues: + if issue["title"] == f"Release {version}": + exact_matches.append(issue) + + if not exact_matches: + raise NoTrackingIssueError( + f"No open tracking issue found matching 'Release {version}' " + f"in repo {self.repo} with label '{self.label}'" + ) + if len(exact_matches) > 1: + urls = [issue["url"] for issue in exact_matches] + raise MultipleTrackingIssuesError( + f"Multiple open tracking issues found for version {version} " + f"in repo {self.repo} with label '{self.label}':\n" + "\n".join(urls) + ) + + return exact_matches[0]["number"] + + def create_tracking_issue(self, version: str, template_content: str) -> int: + """Creates a new release tracking issue from template content. + + Strips YAML frontmatter if present. + + Args: + version: The version to create the tracking issue for. + template_content: The markdown template content for the issue body. + + Returns: + The created issue number. + """ + # Strip YAML frontmatter if present + issue_body = template_content + if template_content.startswith("---"): + parts = template_content.split("---", 2) + if len(parts) >= 3: + issue_body = parts[2].strip() + + # Write body to a secure temporary file to pass to the CLI + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + f.write(issue_body) + temp_path = f.name + try: + output = self._gh_issue( + "create", + f"--title=Release {version}", + f"--label={self.label}", + f"--body-file={temp_path}", + ) + if not output: + raise RuntimeError("Failed to get issue URL from gh issue create") + issue_url = output.strip() + issue_num = int(issue_url.split("/")[-1]) + return issue_num + finally: + if os.path.exists(temp_path): + os.unlink(temp_path) + + def get_issue_body(self, issue_num: int) -> str: + """Fetches the body of a specific issue. + + Args: + issue_num: The issue number. + + Returns: + The issue body markdown. + """ + output = self._gh_issue( + "view", + str(issue_num), + "--json=body", + "--jq=.body", + ) + return output if output else "" -def create_tracking_issue(version, template_content): - """Creates a new release tracking issue from template content (strips YAML frontmatter).""" - # Strip YAML frontmatter if present - issue_body = template_content - if template_content.startswith("---"): - parts = template_content.split("---", 2) - if len(parts) >= 3: - issue_body = parts[2].strip() + def get_issue_title(self, issue_num: int) -> str: + """Fetches the title of a specific issue. - # Write body to a secure temporary file to pass to the CLI - with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: - f.write(issue_body) - temp_path = f.name + Args: + issue_num: The issue number. - try: - output = run_cmd( - "gh", - "issue", + Returns: + The issue title. + """ + output = self._gh_issue( + "view", + str(issue_num), + "--json=title", + ) + return json.loads(output)["title"] if output else "" + + def update_issue_body(self, issue_num: int, body: str) -> None: + """Updates the body of a specific issue. + + Args: + issue_num: The issue number. + body: The new issue body markdown. + """ + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + f.write(body) + temp_path = f.name + try: + self._gh_issue( + "edit", + str(issue_num), + f"--body-file={temp_path}", + capture_output=False, + ) + finally: + if os.path.exists(temp_path): + os.unlink(temp_path) + + def create_pr(self, version: str, issue_num: int) -> str: + """Creates a pull request for release preparation. + + Args: + version: The version being prepared. + issue_num: The associated tracking issue number. + + Returns: + The URL of the created PR. + """ + output = self._gh_pr( "create", - f"--title=Release {version}", - f"--label={_LABEL}", - f"--body-file={temp_path}", + f"--title=Prepare release v{version}", + f"--body=Work towards #{issue_num}", + "--base=main", ) - issue_url = output.strip() - issue_num = int(issue_url.split("/")[-1]) - return issue_num - finally: - if os.path.exists(temp_path): - os.unlink(temp_path) - - -def get_issue_body(issue_num): - """Fetches the body of a specific issue.""" - return run_cmd( - "gh", - "issue", - "view", - str(issue_num), - "--json=body", - "--jq=.body", - ) - - -def get_issue_title(issue_num): - """Fetches the title of a specific issue.""" - output = run_cmd( - "gh", - "issue", - "view", - str(issue_num), - "--json=title", - ) - return json.loads(output)["title"] if output else "" - - -def update_issue_body(issue_num, body): - """Updates the body of a specific issue.""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: - f.write(body) - temp_path = f.name - try: - run_cmd( - "gh", - "issue", - "edit", + return output if output else "" + + def get_open_pr(self, branch_name: str) -> dict | None: + """Returns PR info if an open PR exists for the given branch. + + Args: + branch_name: The head branch name of the PR. + + Returns: + A dictionary with 'number' and 'url' of the PR, or None. + """ + output = self._gh_pr( + "list", + f"--head={branch_name}", + "--state=open", + "--json=number,url", + ) + prs = json.loads(output) if output else [] + return prs[0] if prs else None + + def get_pr_info(self, pr_num: int) -> dict: + """Gets information about a PR. + + Includes state, merge commit, body, and draft status. + + Args: + pr_num: The PR number. + + Returns: + A dictionary containing the PR info. + """ + output = self._gh_pr( + "view", + str(pr_num), + "--json=state,mergeCommit,body,isDraft", + ) + return json.loads(output) if output else {} + + def post_issue_comment(self, issue_num: int, comment_body: str) -> None: + """Posts a comment to a specific issue. + + Args: + issue_num: The issue number. + comment_body: The comment body markdown. + """ + self._gh_issue( + "comment", str(issue_num), - f"--body-file={temp_path}", + f"--body={comment_body}", capture_output=False, ) - finally: - if os.path.exists(temp_path): - os.unlink(temp_path) - - -def create_pr(version, issue_num): - """Creates a pull request for release preparation.""" - return run_cmd( - "gh", - "pr", - "create", - f"--title=Prepare release v{version}", - f"--body=Work towards #{issue_num}", - "--base=main", - ) - - -def get_open_pr(branch_name): - """Returns PR info if an open PR exists for the given branch, else None.""" - cmd = [ - "gh", - "pr", - "list", - f"--repo={_REPO}", - f"--head={branch_name}", - "--state=open", - "--json=number,url", - ] - output = run_cmd(*cmd) - prs = json.loads(output) if output else [] - return prs[0] if prs else None - - -def get_pr_info(pr_num): - """Gets information about a PR, including state, merge commit, body, and draft status.""" - output = run_cmd( - "gh", - "pr", - "view", - str(pr_num), - "--json=state,mergeCommit,body,isDraft", - ) - return json.loads(output) if output else {} - - -def post_issue_comment(issue_num, comment_body): - """Posts a comment to a specific issue.""" - run_cmd( - "gh", - "issue", - "comment", - str(issue_num), - f"--body={comment_body}", - capture_output=False, - ) - - -def get_merge_commits_for_prs(pending_items: list[BackportTask]) -> list[BackportTask]: - """Resolves PR references in pending backports to their merge commit SHAs. - - Updates item.status based on PR state if it cannot be resolved. - """ - resolved_items = [] - for item in pending_items: - pr_num = item.pr_ref.lstrip("#") - print(f"Resolving PR #{pr_num} to merge commit...") - try: - pr_info = get_pr_info(pr_num) - if not pr_info: - print(f"PR #{pr_num} not found. Gating.") - item.status = "error-not-found" - else: - state = pr_info.get("state") - is_draft = pr_info.get("isDraft", False) - if state == "OPEN" or is_draft: - print( - f"PR #{pr_num} is open or draft (state: {state}, draft: {is_draft}). Ignoring." - ) - item.status = "open-pr" if not is_draft else "draft-pr" - elif state == "CLOSED": - print(f"PR #{pr_num} is closed but not merged. Gating.") - item.status = "error-closed-pr" - elif state == "MERGED": - merge_commit = pr_info.get("mergeCommit") - if merge_commit and "oid" in merge_commit: - item.commit = merge_commit["oid"] - item.status = "resolved" - else: - print(f"PR #{pr_num} has no merge commit SHA. Gating.") - item.status = "error-no-merge-commit" + + def get_merge_commits_for_prs( + self, pending_items: list[BackportTask] + ) -> list[BackportTask]: + """Resolves PR references in pending backports to their merge commit SHAs. + + Updates item.status based on PR state if it cannot be resolved. + + Args: + pending_items: A list of BackportTask items to resolve. + + Returns: + The list of resolved BackportTask items. + """ + resolved_items = [] + for item in pending_items: + pr_num = int(item.pr_ref.lstrip("#")) + print(f"Resolving PR #{pr_num} to merge commit...") + try: + pr_info = self.get_pr_info(pr_num) + if not pr_info: + print(f"PR #{pr_num} not found. Gating.") + item.status = "error-not-found" else: - print(f"PR #{pr_num} has unknown state: {state}. Gating.") - item.status = "error-unknown" - except Exception as e: - print(f"Error resolving PR #{pr_num}: {e}. Gating.") - item.status = "error-resolution-failed" - resolved_items.append(item) - return resolved_items + state = pr_info.get("state") + is_draft = pr_info.get("isDraft", False) + if state == "OPEN" or is_draft: + print( + f"PR #{pr_num} is open or draft (state: {state}," + f" draft: {is_draft}). Ignoring." + ) + item.status = "open-pr" if not is_draft else "draft-pr" + elif state == "CLOSED": + print(f"PR #{pr_num} is closed but not merged. Gating.") + item.status = "error-closed-pr" + elif state == "MERGED": + merge_commit = pr_info.get("mergeCommit") + if merge_commit and "oid" in merge_commit: + item.commit = merge_commit["oid"] + item.status = "resolved" + else: + print(f"PR #{pr_num} has no merge commit SHA. Gating.") + item.status = "error-no-merge-commit" + else: + print(f"PR #{pr_num} has unknown state: {state}. Gating.") + item.status = "error-unknown" + except Exception as e: + print(f"Error resolving PR #{pr_num}: {e}. Gating.") + item.status = "error-resolution-failed" + resolved_items.append(item) + return resolved_items diff --git a/tools/private/release/git.py b/tools/private/release/git.py index 795710fd8c..69edba3565 100644 --- a/tools/private/release/git.py +++ b/tools/private/release/git.py @@ -5,213 +5,344 @@ from tools.private.release.shell import run_cmd -def get_tags(): - """Returns a list of all git tags in the repository.""" - output = run_cmd("git", "tag") - return output.splitlines() if output else [] - - -def checkout( - ref: str, create_branch: bool = False, track_remote: str | None = None -) -> None: - """Checks out a git reference (tag, branch, or commit). - - Args: - ref: The git reference (tag, branch, or commit) to checkout. - create_branch: If True, creates the branch before checking it out. - track_remote: If specified, checks out the branch tracking this remote's - corresponding branch. +class Git: + """Git helper class for the release tool. + + Operates on a specific git repository path. """ - cmd = ["git", "checkout"] - if create_branch: - cmd.append("-b") - should_reset_hard = False - if track_remote: - if branch_exists(ref): - cmd.append(ref) - should_reset_hard = True + def __init__(self, repo: str): + """Initializes the Git helper. + + Args: + repo: The path to the git repository. + """ + self._repo = repo + + def _run_git( + self, *args: str, check: bool = True, capture_output: bool = True + ) -> str | None: + """Runs a git command in the repository directory. + + Args: + *args: Arguments passed to the git command. + check: If True, raises CalledProcessError on failure. + capture_output: If True, captures and returns stdout. + + Returns: + The stdout of the command, stripped, or None if capture_output is + False. + """ + return run_cmd( + "git", + *args, + check=check, + capture_output=capture_output, + cwd=self._repo, + ) + + def get_tags(self) -> list[str]: + """Returns a list of all git tags in the repository. + + Returns: + A list of tag names (strings). + """ + output = self._run_git("tag") + return output.splitlines() if output else [] + + def checkout( + self, + ref: str, + create_branch: bool = False, + track_remote: str | None = None, + ) -> None: + """Checks out a git reference (tag, branch, or commit). + + Args: + ref: The git reference (tag, branch, or commit) to checkout. + create_branch: If True, creates the branch before checking it out. + track_remote: If specified, checks out the branch tracking this + remote's corresponding branch. + """ + cmd = ["checkout"] + if create_branch: + cmd.append("-b") + + should_reset_hard = False + if track_remote: + if self.branch_exists(ref): + cmd.append(ref) + should_reset_hard = True + else: + cmd.extend(["--track", f"{track_remote}/{ref}"]) else: - cmd.extend(["--track", f"{track_remote}/{ref}"]) - else: + cmd.append(ref) + self._run_git(*cmd, capture_output=False) + + if should_reset_hard: + self.reset_hard(f"{track_remote}/{ref}") + + def add(self, *files: str) -> None: + """Stages files for commit. + + Args: + *files: Paths to files to stage. + """ + self._run_git("add", *files, capture_output=False) + + def add_modified_and_deleted(self) -> None: + """Stages all modified and deleted tracked files.""" + self._run_git("add", "--update", capture_output=False) + + def commit(self, message: str, amend: bool = False, no_edit: bool = False) -> None: + """Commits staged changes, optionally amending the previous commit. + + Args: + message: The commit message. + amend: If True, amends the previous commit. + no_edit: If True, uses the existing commit message without editing. + """ + cmd = ["commit"] + if amend: + cmd.append("--amend") + if no_edit: + cmd.append("--no-edit") + if message: + cmd.extend(["-m", message]) + self._run_git(*cmd, capture_output=False) + + def push( + self, + remote: str, + ref: str, + set_upstream: bool = False, + force: bool = False, + ) -> None: + """Pushes a reference to a remote repository. + + Args: + remote: The remote repository name (e.g., 'origin'). + ref: The reference to push (e.g., a branch name). + set_upstream: If True, sets the upstream tracking branch. + force: If True, force pushes the changes. + """ + cmd = ["push"] + if set_upstream: + cmd.append("--set-upstream") + if force: + cmd.append("--force") + cmd.extend([remote, ref]) + self._run_git(*cmd, capture_output=False) + + def fetch( + self, remote: str = "origin", tags: bool = False, force: bool = False + ) -> None: + """Fetches updates from a remote repository. + + Args: + remote: The remote repository name. Defaults to 'origin'. + tags: If True, fetches all tags. + force: If True, force fetches updates. + """ + cmd = ["fetch", remote] + if tags: + cmd.append("--tags") + if force: + cmd.append("--force") + self._run_git(*cmd, capture_output=False) + + def merge(self, commit_ref: str, ff_only: bool = True) -> None: + """Merges a commit into the current branch. + + Args: + commit_ref: The commit reference to merge. + ff_only: If True, only allows fast-forward merges. + """ + cmd = ["merge", commit_ref] + if ff_only: + cmd.append("--ff-only") + self._run_git(*cmd, capture_output=False) + + def tag(self, tag_name: str, commit_ref: str) -> None: + """Creates a local tag pointing to a specific commit. + + Args: + tag_name: The name of the tag to create. + commit_ref: The commit reference the tag should point to. + """ + self._run_git("tag", tag_name, commit_ref, capture_output=False) + + def cherry_pick(self, sha: str) -> None: + """Cherry-picks a commit. + + Args: + sha: The commit SHA to cherry-pick. + """ + self._run_git("cherry-pick", "-x", sha, capture_output=False) + + def cherry_pick_abort(self) -> None: + """Aborts an in-progress cherry-pick operation.""" + self._run_git("cherry-pick", "--abort", capture_output=False) + + def reset_hard(self, ref: str = "HEAD") -> None: + """Resets the index and working tree to a specific reference. + + Args: + ref: The git reference to reset to. Defaults to 'HEAD'. + """ + self._run_git("reset", "--hard", ref, capture_output=False) + + def status(self) -> str: + """Returns the output of git status --porcelain. + + Returns: + The porcelain status output. + """ + output = self._run_git("status", "--porcelain") + return output if output else "" + + def get_commit_sha(self, ref: str = "HEAD", short: bool = False) -> str: + """Returns the commit SHA of a given reference. + + Args: + ref: The git reference. Defaults to 'HEAD'. + short: If True, returns a short SHA. + + Returns: + The commit SHA. + """ + cmd = ["rev-parse"] + if short: + cmd.append("--short") cmd.append(ref) - run_cmd(*cmd, capture_output=False) - - if should_reset_hard: - reset_hard(f"{track_remote}/{ref}") - - -def add(*files): - """Stages files for commit.""" - 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"] - if amend: - cmd.append("--amend") - if no_edit: - cmd.append("--no-edit") - if message: - cmd.extend(["-m", message]) - run_cmd(*cmd, capture_output=False) - - -def push(remote, ref, set_upstream=False, force=False): - """Pushes a reference to a remote repository.""" - cmd = ["git", "push"] - if set_upstream: - cmd.append("--set-upstream") - if force: - cmd.append("--force") - cmd.extend([remote, ref]) - run_cmd(*cmd, capture_output=False) - - -def fetch(remote="origin", tags=False, force=False): - """Fetches updates from a remote repository.""" - cmd = ["git", "fetch", remote] - if tags: - cmd.append("--tags") - if force: - cmd.append("--force") - run_cmd(*cmd, capture_output=False) - - -def merge(commit_ref, ff_only=True): - """Merges a commit into the current branch.""" - cmd = ["git", "merge", commit_ref] - if ff_only: - cmd.append("--ff-only") - run_cmd(*cmd, capture_output=False) - - -def tag(tag_name, commit_ref): - """Creates a local tag pointing to a specific commit.""" - run_cmd("git", "tag", tag_name, commit_ref, capture_output=False) - - -def cherry_pick(sha: str) -> None: - """Cherry-picks a commit. - - Args: - sha: The commit SHA to cherry-pick. - """ - run_cmd("git", "cherry-pick", "-x", sha, capture_output=False) - - -def cherry_pick_abort(): - """Aborts an in-progress cherry-pick operation.""" - run_cmd("git", "cherry-pick", "--abort", capture_output=False) - - -def reset_hard(ref: str = "HEAD") -> None: - """Resets the index and working tree to a specific reference. - - Args: - ref: The git reference to reset to. Defaults to 'HEAD'. - """ - run_cmd("git", "reset", "--hard", ref, capture_output=False) - - -def status(): - """Returns the output of git status --porcelain.""" - return run_cmd("git", "status", "--porcelain") - - -def get_commit_sha(ref="HEAD", short=False): - """Returns the commit SHA of a given reference.""" - cmd = ["git", "rev-parse"] - if short: - cmd.append("--short") - cmd.append(ref) - return run_cmd(*cmd) - - -def get_commit_message(ref: str = "HEAD") -> str: - """Returns the commit message of a given reference. - - Args: - ref: The git reference to get the message from. Defaults to 'HEAD'. - """ - return run_cmd("git", "log", "-1", "--format=%B", ref) - - -def branch_exists(branch_name): - """Returns True if a local branch exists.""" - try: - run_cmd("git", "show-ref", "--verify", f"refs/heads/{branch_name}") - return True - except subprocess.CalledProcessError: - return False - - -def tag_exists(tag_name): - """Returns True if a local tag exists.""" - try: - run_cmd("git", "show-ref", "--verify", f"refs/tags/{tag_name}") - return True - except subprocess.CalledProcessError: - return False - - -def sort_commits_chronologically(shas): - """Sorts a list of commit SHAs chronologically (oldest first).""" - output = run_cmd("git", "log", "--no-walk", "--reverse", "--format=%H", *shas) - return output.splitlines() if output else [] - - -def get_current_branch(): - """Returns the current git branch name.""" - return run_cmd("git", "rev-parse", "--abbrev-ref", "HEAD") - - -def remote_branch_exists(remote, branch_name): - """Returns True if a remote branch exists.""" - try: - run_cmd("git", "show-ref", "--verify", f"refs/remotes/{remote}/{branch_name}") - return True - except subprocess.CalledProcessError: - return False - - -def is_ancestor(ancestor, descendant): - """Returns True if ancestor is an ancestor of descendant (fast-forwardable).""" - try: - run_cmd("git", "merge-base", "--is-ancestor", ancestor, descendant) - return True - except subprocess.CalledProcessError: - return False - - -def get_remote_tags(remote: str) -> list[str]: - """Returns a list of tags present on the specified remote repository. - - Args: - remote: The name of the git remote to query (e.g., 'origin', 'upstream'). - - Returns: - A list of tag names (strings) found on the remote, excluding peeled tags. - """ - output = run_cmd("git", "ls-remote", "--tags", remote) - tags = [] - for line in output.splitlines(): - if not line: - continue - parts = line.split() - if len(parts) < 2: - continue - ref = parts[1] - if ref.startswith("refs/tags/"): - tag = ref[len("refs/tags/") :] - # Skip peeled tags (e.g. tag^{}) to avoid - # duplicate tag names in the output. - if not tag.endswith("^{}"): - tags.append(tag) - return tags + output = self._run_git(*cmd) + return output if output else "" + + def get_commit_message(self, ref: str = "HEAD") -> str: + """Returns the commit message of a given reference. + + Args: + ref: The git reference. Defaults to 'HEAD'. + + Returns: + The commit message. + """ + output = self._run_git("log", "-1", "--format=%B", ref) + return output if output else "" + + def branch_exists(self, branch_name: str) -> bool: + """Returns True if a local branch exists. + + Args: + branch_name: The name of the branch to check. + + Returns: + True if the branch exists, False otherwise. + """ + try: + self._run_git("show-ref", "--verify", f"refs/heads/{branch_name}") + return True + except subprocess.CalledProcessError: + return False + + def tag_exists(self, tag_name: str) -> bool: + """Returns True if a local tag exists. + + Args: + tag_name: The name of the tag to check. + + Returns: + True if the tag exists, False otherwise. + """ + try: + self._run_git("show-ref", "--verify", f"refs/tags/{tag_name}") + return True + except subprocess.CalledProcessError: + return False + + def sort_commits_chronologically(self, shas: list[str]) -> list[str]: + """Sorts a list of commit SHAs chronologically (oldest first). + + Args: + shas: A list of commit SHAs to sort. + + Returns: + The sorted list of commit SHAs. + """ + output = self._run_git("log", "--no-walk", "--reverse", "--format=%H", *shas) + return output.splitlines() if output else [] + + def get_current_branch(self) -> str: + """Returns the current git branch name. + + Returns: + The current branch name. + """ + output = self._run_git("rev-parse", "--abbrev-ref", "HEAD") + return output if output else "" + + def remote_branch_exists(self, remote: str, branch_name: str) -> bool: + """Returns True if a remote branch exists. + + Args: + remote: The name of the remote. + branch_name: The name of the branch. + + Returns: + True if the remote branch exists, False otherwise. + """ + try: + self._run_git( + "show-ref", + "--verify", + f"refs/remotes/{remote}/{branch_name}", + ) + return True + except subprocess.CalledProcessError: + return False + + def is_ancestor(self, ancestor: str, descendant: str) -> bool: + """Returns True if ancestor is an ancestor of descendant. + + Args: + ancestor: The commit reference that might be an ancestor. + descendant: The commit reference that might be a descendant. + + Returns: + True if ancestor is an ancestor of descendant, False otherwise. + """ + try: + self._run_git("merge-base", "--is-ancestor", ancestor, descendant) + return True + except subprocess.CalledProcessError: + return False + + def get_remote_tags(self, remote: str) -> list[str]: + """Returns a list of tags present on the specified remote repository. + + Args: + remote: The name of the git remote to query (e.g., 'origin', + 'upstream'). + + Returns: + A list of tag names (strings) found on the remote, excluding peeled + tags. + """ + output = self._run_git("ls-remote", "--tags", remote) + tags = [] + if not output: + return tags + for line in output.splitlines(): + if not line: + continue + parts = line.split() + if len(parts) < 2: + continue + ref = parts[1] + if ref.startswith("refs/tags/"): + tag = ref[len("refs/tags/") :] + # Skip peeled tags (e.g. tag^{}) to avoid + # duplicate tag names in the output. + if not tag.endswith("^{}"): + tags.append(tag) + return tags diff --git a/tools/private/release/prepare.py b/tools/private/release/prepare.py index 2d32ee4de0..610f2369e7 100644 --- a/tools/private/release/prepare.py +++ b/tools/private/release/prepare.py @@ -1,7 +1,16 @@ +"""Subcommand to prepare the release (updates changelog, placeholders).""" + +import argparse import datetime import pathlib -from tools.private.release import changelog_news, gh, git +from tools.private.release import changelog_news +from tools.private.release.gh import ( + GitHub, + MultipleTrackingIssuesError, + NoTrackingIssueError, +) +from tools.private.release.git import Git from tools.private.release.release_issue import ( parse_checklist_state, update_task_in_body, @@ -9,161 +18,215 @@ from tools.private.release.utils import ( determine_next_version, replace_version_next, + semver_type, ) -def cmd_prepare(args): - """Executes the prepare subcommand.""" - print("Fetching upstream to verify fresh release history...") - git.fetch(tags=True, force=True) +class Prepare: + """Class to prepare the release.""" - # 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.") + def __init__(self, args, git: Git, gh: GitHub): + self.args = args + self.git = git + self.gh = gh - version = args.version - if version is None: - version = determine_next_version() + def run(self) -> int: + """Executes the prepare subcommand.""" + args = self.args + print("Fetching upstream to verify fresh release history...") + self.git.fetch(tags=True, force=True) - print(f"Running preparation pipeline for {version}...") + # Run pre-check: verify there are no local edits + status = self.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.") - # 1. Find or create tracking issue (EARLY) - # We do this before any write operations (branch creation, commit, push) - issue_num = args.issue + version = args.version + if version is None: + version = determine_next_version() - if not issue_num: - try: - issue_num = gh.get_release_tracking_issue(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 = self.gh.get_release_tracking_issue(version) + print(f"Tracking issue: #{issue_num}") + except MultipleTrackingIssuesError as e: + print(f"Error: {e}") + return 1 + except 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" + f" {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 = self.gh.create_tracking_issue(version, template_content) + print(f"Tracking issue: #{issue_num}") + else: 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") + branch_name = f"prepare-{version}" + + # 2. Interleaved git and write operations + + # --- Branch selection/creation --- + if self.git.branch_exists(branch_name): if args.dry_run: print( - f"[DRY RUN] No active tracking issue found for {version}. Would create a new one." + f"[DRY RUN] Branch {branch_name} already exists. Would" + " checkout existing branch." ) - 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 + print(f"Branch {branch_name} already exists. Checking it out...") + self.git.checkout(branch_name) + else: + if args.dry_run: + print(f"[DRY RUN] Would create and checkout branch {branch_name}") + else: + self.git.checkout(branch_name, create_branch=True) - # --- Branch selection/creation --- - if git.branch_exists(branch_name): + # --- Update files --- if args.dry_run: print( - f"[DRY RUN] Branch {branch_name} already exists. Would checkout existing branch." + f"[DRY RUN] Would update CHANGELOG.md and version placeholders" + f" for {version}" ) else: - print(f"Branch {branch_name} already exists. Checking it out...") - git.checkout(branch_name) - 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 create and checkout branch {branch_name}") + print(f"[DRY RUN] Would push branch {branch_name} to origin") 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 modified_files: - # Stage all modified and deleted tracked files - git.add_modified_and_deleted() - git.commit(f"Prepare release {version}") + modified_files = self.git.status() + if modified_files: + # Stage all modified and deleted tracked files + self.git.add_modified_and_deleted() + self.git.commit(f"Prepare release {version}") + else: + print("No files modified by the release tool. Nothing to commit.") + + print(f"Pushing branch {branch_name} to origin...") + # Force push to overwrite the remote branch if it already exists (e.g. from a previous run) + self.git.push("origin", branch_name, set_upstream=True, force=True) + + # --- Create PR --- + # Determine if we need to create a PR or reuse an existing one + open_pr = self.gh.get_open_pr(branch_name) + associated_pr = None + + if not open_pr and issue_num: + body = self.gh.get_issue_body(issue_num) + state = parse_checklist_state(body) + associated_pr = state["prepare_release"].pr + + if open_pr: + pr_num = open_pr["number"] + pr_url = open_pr["url"] + print(f"Open Pull Request already exists: {pr_url} (PR #{pr_num})") + elif associated_pr: + pr_num = associated_pr.lstrip("#") + pr_url = f"https://github.com/bazel-contrib/rules_python/pull/{pr_num}" + print( + f"PR #{pr_num} is already associated in tracking issue" + f" #{issue_num}. Using it." + ) else: - print("No files modified by the release tool. Nothing to commit.") - - print(f"Pushing branch {branch_name} to origin...") - # Force push to overwrite the remote branch if it already exists (e.g. from a previous run) - git.push("origin", branch_name, set_upstream=True, force=True) - - # --- Create PR --- - # Determine if we need to create a PR or reuse an existing one - open_pr = gh.get_open_pr(branch_name) - associated_pr = None - - if not open_pr and issue_num: - body = gh.get_issue_body(issue_num) - state = parse_checklist_state(body) - associated_pr = state["prepare_release"]["pr"] - - if open_pr: - pr_num = open_pr["number"] - pr_url = open_pr["url"] - print(f"Open Pull Request already exists: {pr_url} (PR #{pr_num})") - elif associated_pr: - pr_num = associated_pr.lstrip("#") - pr_url = f"https://github.com/bazel-contrib/rules_python/pull/{pr_num}" - print( - f"PR #{pr_num} is already associated in tracking issue #{issue_num}. Using it." - ) - else: + if args.dry_run: + target_issue = f"#{issue_num}" if issue_num else "" + print( + f"[DRY RUN] Would create Pull Request for branch" + f" {branch_name} targeting issue {target_issue}" + ) + pr_num = "" + else: + pr_url = self.gh.create_pr(version, 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 create Pull Request for branch {branch_name} targeting issue {target_issue}" + f"[DRY RUN] Would update tracking issue {target_issue} checklist" + " 'Prepare Release' task status to PENDING" ) - pr_num = "" else: - pr_url = gh.create_pr(version, 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" + print( + f"Updating tracking issue #{issue_num} checklist 'Prepare" + " Release' task status to PENDING..." + ) + body = self.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 + ) + self.gh.update_issue_body(issue_num, updated_body) + print("Preparation pipeline completed successfully!") + + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for prepare subcommand.""" + parser = subparsers.add_parser( + "prepare", + help="Prepare the release (updates changelog, placeholders).", ) - else: - print( - f"Updating tracking issue #{issue_num} checklist 'Prepare Release' task status to PENDING..." + parser.add_argument( + "version", + nargs="?", + type=semver_type, + help="The new release version (e.g., 0.28.0). If not provided, " + "it will be determined automatically.", ) - 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 + parser.add_argument( + "--issue", + type=int, + help="The tracking issue number (optional, triggers automated branch/PR pipeline).", ) - gh.update_issue_body(issue_num, updated_body) - print("Preparation pipeline completed successfully!") - - return 0 + parser.add_argument( + "--dry-run", + action=argparse.BooleanOptionalAction, + default=True, + help="Perform a dry run (default: True). Use --no-dry-run to actually execute.", + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + git = Git(".") + gh = GitHub() + return cls(args, git, gh).run() diff --git a/tools/private/release/process_backports.py b/tools/private/release/process_backports.py index 373e1848ec..21af2d5154 100644 --- a/tools/private/release/process_backports.py +++ b/tools/private/release/process_backports.py @@ -1,9 +1,12 @@ """Subcommand to process pending backports.""" +import argparse import datetime from typing import Any -from tools.private.release import changelog_news, gh, git +from tools.private.release import changelog_news +from tools.private.release.gh import GitHub +from tools.private.release.git import Git from tools.private.release.release_issue import ( RELEASE_TITLE_RE, parse_backports, @@ -12,231 +15,290 @@ from tools.private.release.utils import get_latest_rc_tag -def _process_pr_commit_infos( - pr_commit_infos, body, issue, dry_run -) -> tuple[list[str], dict[str, Any], list[str], list[str], str]: - shas = [] - sha_to_item = {} - failed_prs = [] - ignored_prs = [] - for item in pr_commit_infos: - if item.commit: - sha = item.commit - sha_to_item[sha] = item - shas.append(sha) - elif item.status in ("open-pr", "draft-pr"): - print(f"PR {item.pr_ref} is open or draft. Ignoring.") - ignored_prs.append(item.pr_ref) - else: - failed_prs.append(item.pr_ref) - status_to_set = item.status or "error-unmerged-pr" - if dry_run: - print( - f"[DRY RUN] Would update tracking issue checklist for unresolved PR {item.pr_ref} to status={status_to_set}" - ) +class ProcessBackports: + """Class to process pending backports.""" + + def __init__(self, args, git: Git, gh: GitHub): + self.args = args + self.git = git + self.gh = gh + + def _process_pr_commit_infos( + self, pr_commit_infos, body, issue, dry_run + ) -> tuple[list[str], dict[str, Any], list[str], list[str], str]: + shas = [] + sha_to_item = {} + failed_prs = [] + ignored_prs = [] + for item in pr_commit_infos: + if item.commit: + sha = item.commit + sha_to_item[sha] = item + shas.append(sha) + elif item.status in ("open-pr", "draft-pr"): + print(f"PR {item.pr_ref} is open or draft. Ignoring.") + ignored_prs.append(item.pr_ref) else: - print( - f"Updating tracking issue checklist for unresolved PR {item.pr_ref}..." - ) - try: - body = update_task_in_body( - body, - item.pr_ref, - checked=False, - metadata={"status": status_to_set}, + failed_prs.append(item.pr_ref) + status_to_set = item.status or "error-unmerged-pr" + if dry_run: + print( + f"[DRY RUN] Would update tracking issue checklist for" + f" unresolved PR {item.pr_ref} to status={status_to_set}" ) - gh.update_issue_body(issue, body) - except Exception as e: + else: print( - f"ERROR: Failed to update tracking issue for unresolved PR {item.pr_ref}: {e}" + f"Updating tracking issue checklist for unresolved PR" + f" {item.pr_ref}..." ) - return shas, sha_to_item, failed_prs, ignored_prs, body - - -def _cherry_pick_and_update_prs( - sorted_shas, - sha_to_item, - body, - issue, - remote, - dry_run, - version, - branch_name, - next_rc_suffix, -) -> tuple[list[str], str]: - failed_prs = [] - for sha in sorted_shas: - item = sha_to_item[sha] - print(f"Cherry-picking {item.pr_ref} / {sha}...") - try: - git.cherry_pick(sha) - - # Perform news processing (merging news/ files into the changelog) - print(f"Merging news fragments into changelog for PR {item.pr_ref}...") - release_date = datetime.date.today().strftime("%Y-%m-%d") - changelog_news.update_changelog(version, release_date) - - # Stage changelog changes and news/ deletions - git.add("CHANGELOG.md", "news/") - - # Amend cherry-pick commit to include news merging and deletions, - # and reference the release tracking issue. - print(f"Amending cherry-pick commit for PR {item.pr_ref}...") - current_msg = git.get_commit_message("HEAD") - new_msg = f"{current_msg.strip()}\n\nWork towards #{issue}" - git.commit(new_msg, amend=True) - - if not dry_run: - # Push amended commit - git.push(remote, branch_name) - - new_sha = git.get_commit_sha("HEAD", short=True) - metadata = {"status": "done", "rc": next_rc_suffix, "commit": new_sha} - print(f"Updating tracking issue checklist for PR {item.pr_ref}...") - try: - body = update_task_in_body( - body, item.pr_ref, checked=True, metadata=metadata + try: + body = update_task_in_body( + body, + item.pr_ref, + checked=False, + metadata={"status": status_to_set}, + ) + self.gh.update_issue_body(issue, body) + except Exception as e: + print( + f"ERROR: Failed to update tracking issue for" + f" unresolved PR {item.pr_ref}: {e}" + ) + return shas, sha_to_item, failed_prs, ignored_prs, body + + def _cherry_pick_and_update_prs( + self, + sorted_shas, + sha_to_item, + body, + issue, + remote, + dry_run, + version, + branch_name, + next_rc_suffix, + ) -> tuple[list[str], str]: + failed_prs = [] + for sha in sorted_shas: + item = sha_to_item[sha] + print(f"Cherry-picking {item.pr_ref} / {sha}...") + try: + self.git.cherry_pick(sha) + + # Perform news processing (merging news/ files into the changelog) + print(f"Merging news fragments into changelog for PR {item.pr_ref}...") + release_date = datetime.date.today().strftime("%Y-%m-%d") + changelog_news.update_changelog(version, release_date) + + # Stage changelog changes and news/ deletions + self.git.add("CHANGELOG.md", "news/") + + # Amend cherry-pick commit to include news merging and deletions, + # and reference the release tracking issue. + print(f"Amending cherry-pick commit for PR {item.pr_ref}...") + current_msg = self.git.get_commit_message("HEAD") + new_msg = f"{current_msg.strip()}\n\nWork towards #{issue}" + self.git.commit(new_msg, amend=True) + + if not dry_run: + # Push amended commit + self.git.push(remote, branch_name) + + new_sha = self.git.get_commit_sha("HEAD", short=True) + metadata = { + "status": "done", + "rc": next_rc_suffix, + "commit": new_sha, + } + print(f"Updating tracking issue checklist for PR {item.pr_ref}...") + try: + body = update_task_in_body( + body, item.pr_ref, checked=True, metadata=metadata + ) + self.gh.update_issue_body(issue, body) + except Exception as e: + print( + f"ERROR: Failed to update tracking issue for PR" + f" {item.pr_ref}: {e}" + ) + print(f"Success: backported {item.pr_ref} / {sha} to {branch_name}") + else: + print( + f"[DRY RUN] Success: {item.pr_ref} / {sha} can be" + f" backported without error." ) - gh.update_issue_body(issue, body) - except Exception as e: print( - f"ERROR: Failed to update tracking issue for PR {item.pr_ref}: {e}" + f"[DRY RUN] Would update tracking issue checklist for" + f" PR {item.pr_ref} to status=done" ) - print(f"Success: backported {item.pr_ref} / {sha} to {branch_name}") - else: - print( - f"[DRY RUN] Success: {item.pr_ref} / {sha} can be backported without error." - ) - print( - f"[DRY RUN] Would update tracking issue checklist for PR {item.pr_ref} to status=done" - ) - except Exception as e: - print(f"ERROR: Conflict or error on {sha}: {e}. Aborting.") - try: - git.cherry_pick_abort() - except Exception: - pass - failed_prs.append(item.pr_ref) - - if dry_run: - print( - f"[DRY RUN] Would update tracking issue checklist for failed PR {item.pr_ref} to status=error-merge-conflict" - ) - else: - print( - f"Updating tracking issue checklist for failed PR {item.pr_ref}..." - ) + except Exception as e: + print(f"ERROR: Conflict or error on {sha}: {e}. Aborting.") try: - body = update_task_in_body( - body, - item.pr_ref, - checked=False, - metadata={"status": "error-merge-conflict"}, - ) - gh.update_issue_body(issue, body) + self.git.cherry_pick_abort() + except Exception: + pass + failed_prs.append(item.pr_ref) + + if dry_run: print( - f"Updated back port of {item.pr_ref} to status=error-merge-conflict (unchecked)" + f"[DRY RUN] Would update tracking issue checklist for" + f" failed PR {item.pr_ref} to status=error-merge-conflict" ) - except Exception as e: + else: print( - f"ERROR: Failed to update tracking issue for failed PR {item.pr_ref}: {e}" + f"Updating tracking issue checklist for failed PR" + f" {item.pr_ref}..." ) - return failed_prs, body + try: + body = update_task_in_body( + body, + item.pr_ref, + checked=False, + metadata={"status": "error-merge-conflict"}, + ) + self.gh.update_issue_body(issue, body) + print( + f"Updated back port of {item.pr_ref} to" + f" status=error-merge-conflict (unchecked)" + ) + except Exception as e: + print( + f"ERROR: Failed to update tracking issue for" + f" failed PR {item.pr_ref}: {e}" + ) + return failed_prs, body + def run(self) -> int: + """Executes the process-backports subcommand.""" + args = self.args + body = self.gh.get_issue_body(args.issue) + items = parse_backports(body) -def cmd_process_backports(args): - """Executes the process-backports subcommand.""" - body = gh.get_issue_body(args.issue) - items = parse_backports(body) + pending_items = [ + item + for item in items + if not item.checked and not item.status.startswith("error-") + ] - pending_items = [ - item - for item in items - if not item.checked and not item.status.startswith("error-") - ] + if not pending_items: + print("No pending backports found.") + return 0 - if not pending_items: - print("No pending backports found.") - return 0 + print(f"Found {len(pending_items)} pending backports to process.") + + # Determine branch name from issue title + issue_title = self.gh.get_issue_title(args.issue) + version_match = RELEASE_TITLE_RE.search(issue_title) + if not version_match: + print(f"Error: Could not parse version from issue title: {issue_title}") + return 1 + + version = version_match.group(1) + branch_version = ".".join(version.split(".")[:2]) + branch_name = f"release/{branch_version}" + + # Determine next RC tag to write to backport metadata + self.git.fetch(args.remote, tags=True, force=True) + latest_rc = get_latest_rc_tag(version, remote=args.remote) + if not latest_rc: + next_rc_suffix = "rc0" + else: + rc_num = int(latest_rc.split("-rc")[-1]) + next_rc_suffix = f"rc{rc_num + 1}" + + # Resolve PRs to merge commits using gh helper. + pr_commit_infos = self.gh.get_merge_commits_for_prs(pending_items) + + shas, sha_to_item, failed_prs, ignored_prs, body = ( + self._process_pr_commit_infos( + pr_commit_infos, body, args.issue, args.dry_run + ) + ) + + if not shas: + print("No valid merge commits to process.") + if failed_prs: + print("Failed PRs:") + for pr in failed_prs: + print(f"- {pr}") + return 1 + return 0 + + # Verify workspace is clean before proceeding + if self.git.status(): + print( + "ERROR: Git workspace is dirty. Please commit or stash changes" + " before running backports." + ) + return 1 + + # Sort chronologically using git helper + sorted_shas = self.git.sort_commits_chronologically(shas) + + self.git.fetch(args.remote) + self.git.checkout(branch_name, track_remote=args.remote) + start_sha = self.git.get_commit_sha("HEAD") + + try: + new_failed_prs, body = self._cherry_pick_and_update_prs( + sorted_shas, + sha_to_item, + body, + args.issue, + args.remote, + args.dry_run, + version, + branch_name, + next_rc_suffix, + ) + failed_prs.extend(new_failed_prs) + finally: + if args.dry_run: + print(f"[DRY RUN] Resetting branch {branch_name} to {start_sha}") + self.git.reset_hard(start_sha) - print(f"Found {len(pending_items)} pending backports to process.") - - # Determine branch name from issue title - issue_title = gh.get_issue_title(args.issue) - version_match = RELEASE_TITLE_RE.search(issue_title) - if not version_match: - print(f"Error: Could not parse version from issue title: {issue_title}") - return 1 - - version = version_match.group(1) - branch_version = ".".join(version.split(".")[:2]) - branch_name = f"release/{branch_version}" - - # Determine next RC tag to write to backport metadata - git.fetch(args.remote, tags=True, force=True) - latest_rc = get_latest_rc_tag(version, remote=args.remote) - if not latest_rc: - next_rc_suffix = "rc0" - else: - rc_num = int(latest_rc.split("-rc")[-1]) - next_rc_suffix = f"rc{rc_num + 1}" - - # Resolve PRs to merge commits using gh helper. - pr_commit_infos = gh.get_merge_commits_for_prs(pending_items) - - shas, sha_to_item, failed_prs, ignored_prs, body = _process_pr_commit_infos( - pr_commit_infos, body, args.issue, args.dry_run - ) - - if not shas: - print("No valid merge commits to process.") if failed_prs: - print("Failed PRs:") + print("ERROR: One or more cherry-picks/resolutions failed:") for pr in failed_prs: print(f"- {pr}") return 1 + + if args.dry_run: + print("Dry run completed successfully. No errors found.") + else: + print("All backports successfully processed!") return 0 - # Verify workspace is clean before proceeding - if git.status(): - print( - "ERROR: Git workspace is dirty. Please commit or stash changes before running backports." + @classmethod + def add_parser(cls, subparsers): + """Adds parser for process-backports subcommand.""" + parser = subparsers.add_parser( + "process-backports", + help="Cherry-pick pending backports listed in the tracking issue.", ) - return 1 - - # Sort chronologically using git helper - sorted_shas = git.sort_commits_chronologically(shas) - - git.fetch(args.remote) - git.checkout(branch_name, track_remote=args.remote) - start_sha = git.get_commit_sha("HEAD") - - try: - new_failed_prs, body = _cherry_pick_and_update_prs( - sorted_shas, - sha_to_item, - body, - args.issue, - args.remote, - args.dry_run, - version, - branch_name, - next_rc_suffix, + parser.add_argument( + "--issue", + type=int, + required=True, + help="The tracking issue number (required).", ) - failed_prs.extend(new_failed_prs) - finally: - if args.dry_run: - print(f"[DRY RUN] Resetting branch {branch_name} to {start_sha}") - git.reset_hard(start_sha) - - if failed_prs: - print("ERROR: One or more cherry-picks/resolutions failed:") - for pr in failed_prs: - print(f"- {pr}") - return 1 - - if args.dry_run: - print("Dry run completed successfully. No errors found.") - else: - print("All backports successfully processed!") - return 0 + parser.add_argument( + "--remote", + type=str, + required=True, + help="The git remote to push changes to (required).", + ) + parser.add_argument( + "--dry-run", + action=argparse.BooleanOptionalAction, + default=True, + help="Perform a dry run (default: True). Use --no-dry-run to actually execute.", + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + git = Git(".") + gh = GitHub() + return cls(args, git, gh).run() diff --git a/tools/private/release/promote_rc.py b/tools/private/release/promote_rc.py new file mode 100644 index 0000000000..877a65b485 --- /dev/null +++ b/tools/private/release/promote_rc.py @@ -0,0 +1,143 @@ +"""Subcommand to promote a release candidate to final release.""" + +import argparse +import urllib.parse + +from tools.private.release.gh import GitHub +from tools.private.release.git import Git +from tools.private.release.release_issue import update_task_in_body +from tools.private.release.utils import ( + REPO_URL, + determine_next_version, + get_latest_rc_tag, + semver_type, +) + + +class PromoteRc: + """Class to promote a release candidate to final release.""" + + def __init__(self, args, git: Git, gh: GitHub): + self.args = args + self.git = git + self.gh = gh + + def run(self) -> int: + """Executes the promote-rc subcommand (Phase 3).""" + args = self.args + # Fetch from upstream to ensure we have the latest tags + self.git.fetch("upstream", tags=True, force=True) + + version = args.version + if version is None: + version = determine_next_version() + + latest_rc = get_latest_rc_tag(version, remote="upstream") + if not latest_rc: + print(f"Error: No release candidate tags found matching {version}-rc*") + return 1 + + # Verify final tag doesn't already exist + if self.git.tag_exists(version): + print(f"Error: Final tag {version} already exists.") + return 1 + + # Verify issue can be found + issue_num = args.issue + if not issue_num: + try: + issue_num = self.gh.get_release_tracking_issue(version) + except ValueError as e: + print(f"Error: {e}") + return 1 + except Exception as e: + print(f"Error: Unexpected error finding tracking issue: {e}") + return 1 + + # Get commit SHA of the RC tag (which will be the same for the final tag) + commit_sha = self.git.get_commit_sha(latest_rc) + + # Verify issue is in the right format by trying to prepare the update + print(f"Verifying tracking issue #{issue_num} format...") + body = self.gh.get_issue_body(issue_num) + metadata = {"status": "done", "tag": version, "commit": commit_sha[:8]} + try: + updated_body = update_task_in_body( + body, "Tag Final", checked=True, metadata=metadata + ) + except ValueError as e: + print(f"Error: Tracking issue #{issue_num} is malformed: {e}") + return 1 + + # All pre-conditions met, perform modifications + if args.dry_run: + print( + f"[DRY RUN] Pre-conditions passed successfully for promoting" + f" {latest_rc} to {version}." + ) + print(f"[DRY RUN] Would tag commit {commit_sha[:8]} as {version}") + print(f"[DRY RUN] Would push tag {version} to upstream") + print(f"[DRY RUN] Would update tracking issue #{issue_num} checklist") + print(f"[DRY RUN] Would post comment to tracking issue #{issue_num}") + return 0 + + print( + f"Promoting {latest_rc} to final release {version} (commit" + f" {commit_sha[:8]}) using tracking issue #{issue_num}..." + ) + + # Tag the specific commit without checkout, and push to upstream + self.git.tag(version, commit_sha) + self.git.push("upstream", version) + + print(f"Updating tracking issue #{issue_num} checklist...") + self.gh.update_issue_body(issue_num, updated_body) + + print(f"Posting comment to tracking issue #{issue_num}...") + + release_url = f"{REPO_URL}/releases/tag/{version}" + bcr_query = ( + f'is:pr ("bazel-contrib/rules_python" in:title) ("@{version}" in:title)' + ) + bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q={urllib.parse.quote(bcr_query)}" + comment_body = ( + f"Version {version} has been tagged.\n\n" + f"- **Release Page**: {release_url}\n" + f"- **BCR PR Search**: [{bcr_query}]({bcr_search_url})" + ) + self.gh.post_issue_comment(issue_num, comment_body) + + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for promote-rc subcommand.""" + parser = subparsers.add_parser( + "promote-rc", + help="Promote the latest RC to final release.", + ) + parser.add_argument( + "version", + nargs="?", + type=semver_type, + help="The final version to release (e.g., 0.38.0).", + ) + parser.add_argument( + "--issue", + type=int, + help="The tracking issue number (optional).", + ) + parser.add_argument( + "--dry-run", + action=argparse.BooleanOptionalAction, + default=True, + help="Perform a dry run (default: True). Use --no-dry-run to actually execute.", + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + git = Git(".") + gh = GitHub() + return cls(args, git, gh).run() diff --git a/tools/private/release/release.py b/tools/private/release/release.py index a6c5b3f88b..a6f3543e71 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -2,192 +2,27 @@ import argparse import os -import pathlib -import re import sys -from tools.private.release import gh, git -from tools.private.release.create_rc import cmd_create_rc -from tools.private.release.create_release_branch import cmd_create_release_branch -from tools.private.release.prepare import cmd_prepare -from tools.private.release.process_backports import cmd_process_backports -from tools.private.release.release_issue import ( - update_task_in_body, -) -from tools.private.release.utils import ( - REPO_URL, - determine_next_version, - get_latest_rc_tag, -) - - -def _semver_type(value): - if not re.match(r"^\d+\.\d+\.\d+(rc\d+)?$", value): - raise argparse.ArgumentTypeError( - f"'{value}' is not a valid semantic version (X.Y.Z or X.Y.ZrcN)" - ) - return value - - -# ============================================================================== -# Subcommand Execution Functions -# ============================================================================== - - -def cmd_determine_next_version(args): - """Executes the determine-next-version subcommand.""" - version = determine_next_version() - print(version) - return 0 - - -def cmd_create_release_issue(args): - """Executes the create-release-issue subcommand.""" - version = args.version - if version is None: - version = determine_next_version() - - # Concurrency check - open_issues = gh.get_open_tracking_issues() - if open_issues: - print("Error: A release is already in progress. Active tracking issues:") - for issue in open_issues: - print(f"- {issue['title']}: {issue['url']}") - return 1 - - 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"Created tracking issue #{issue_num} for v{version}") - return 0 - - -def cmd_complete_prepare(args): - """Executes the complete-prepare subcommand (Phase 2 PR merged).""" - print(f"Completing preparation for PR #{args.pr}...") - - pr_info = gh.get_pr_info(args.pr) - if not pr_info or pr_info.get("state") != "MERGED": - state = pr_info.get("state", "UNKNOWN") - print(f"Error: PR #{args.pr} is not merged yet (state: {state}).") - return 1 - - # Resolve issue number from PR body - pr_body = pr_info.get("body", "") - match = re.search(r"Work towards #(\d+)", pr_body) - if not match: - match = re.search(r"#(\d+)", pr_body) - if not match: - print( - f"Error: Could not determine tracking issue number from PR #{args.pr}" - f" body: {pr_body}" - ) - return 1 - - issue_num = int(match.group(1)) - print(f"Resolved tracking issue #{issue_num} from PR #{args.pr} body.") - - commit_sha = pr_info["mergeCommit"]["oid"] - short_commit = commit_sha[:8] - print(f"PR #{args.pr} merged at commit {commit_sha}. Updating tracking issue...") - - # Update checklist: mark Prepare Release as done (checked) and set SUCCESS - body = gh.get_issue_body(issue_num) - metadata = {"status": "done", "pr": f"#{args.pr}", "commit": short_commit} - updated_body = update_task_in_body( - body, "Prepare Release", checked=True, metadata=metadata - ) - gh.update_issue_body(issue_num, updated_body) - print("Prepare Release task marked complete successfully!") - return 0 - - -def cmd_promote_rc(args): - """Executes the promote-rc subcommand (Phase 3).""" - # Fetch from upstream to ensure we have the latest tags - git.fetch("upstream", tags=True, force=True) - - version = args.version - if version is None: - version = determine_next_version() - - latest_rc = get_latest_rc_tag(version, remote="upstream") - if not latest_rc: - print(f"Error: No release candidate tags found matching {version}-rc*") - return 1 - - # Verify final tag doesn't already exist - if git.tag_exists(version): - print(f"Error: Final tag {version} already exists.") - return 1 - - # Verify issue can be found - issue_num = args.issue - if not issue_num: - try: - issue_num = gh.get_release_tracking_issue(version) - except ValueError as e: - print(f"Error: {e}") - return 1 - except Exception as e: - print(f"Error: Unexpected error finding tracking issue: {e}") - return 1 - - # Get commit SHA of the RC tag (which will be the same for the final tag) - commit_sha = git.get_commit_sha(latest_rc) - - # Verify issue is in the right format by trying to prepare the update - print(f"Verifying tracking issue #{issue_num} format...") - body = gh.get_issue_body(issue_num) - metadata = {"status": "done", "tag": version, "commit": commit_sha[:8]} - try: - updated_body = update_task_in_body( - body, "Tag Final", checked=True, metadata=metadata - ) - except ValueError as e: - print(f"Error: Tracking issue #{issue_num} is malformed: {e}") - return 1 - - # All pre-conditions met, perform modifications - if args.dry_run: - print( - f"[DRY RUN] Pre-conditions passed successfully for promoting {latest_rc} to {version}." - ) - print(f"[DRY RUN] Would tag commit {commit_sha[:8]} as {version}") - print(f"[DRY RUN] Would push tag {version} to upstream") - print(f"[DRY RUN] Would update tracking issue #{issue_num} checklist") - print(f"[DRY RUN] Would post comment to tracking issue #{issue_num}") - return 0 - - print( - f"Promoting {latest_rc} to final release {version} (commit" - f" {commit_sha[:8]}) using tracking issue #{issue_num}..." - ) - - # Tag the specific commit without checkout, and push to upstream - git.tag(version, commit_sha) - git.push("upstream", version) - - print(f"Updating tracking issue #{issue_num} checklist...") - gh.update_issue_body(issue_num, updated_body) - - print(f"Posting comment to tracking issue #{issue_num}...") - import urllib.parse - - release_url = f"{REPO_URL}/releases/tag/{version}" - bcr_query = f'is:pr ("bazel-contrib/rules_python" in:title) ("@{version}" in:title)' - bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q={urllib.parse.quote(bcr_query)}" - comment_body = ( - f"Version {version} has been tagged.\n\n" - f"- **Release Page**: {release_url}\n" - f"- **BCR PR Search**: [{bcr_query}]({bcr_search_url})" - ) - gh.post_issue_comment(issue_num, comment_body) - - return 0 +from tools.private.release.complete_prepare import CompletePrepare +from tools.private.release.create_rc import CreateRc +from tools.private.release.create_release_branch import CreateReleaseBranch +from tools.private.release.create_release_issue import CreateReleaseIssue +from tools.private.release.determine_next_version import DetermineNextVersion +from tools.private.release.prepare import Prepare +from tools.private.release.process_backports import ProcessBackports +from tools.private.release.promote_rc import PromoteRc + +cmds = [ + DetermineNextVersion, + CreateReleaseIssue, + Prepare, + CompletePrepare, + CreateReleaseBranch, + ProcessBackports, + CreateRc, + PromoteRc, +] def create_parser(): @@ -200,141 +35,8 @@ def create_parser(): dest="command", required=True, help="Subcommands" ) - # Subcommand: determine-next-version - subparsers.add_parser( - "determine-next-version", - help="Determine the next version and print it, without making any changes.", - ) - - # Subcommand: create-release-issue - create_issue_parser = subparsers.add_parser( - "create-release-issue", - help="Search for open releases and create a new tracking issue.", - ) - create_issue_parser.add_argument( - "--version", - type=_semver_type, - help="The release version (e.g., 0.38.0). If not provided, determined automatically.", - ) - - # Subcommand: prepare - prepare_parser = subparsers.add_parser( - "prepare", - help="Prepare the release (updates changelog, placeholders).", - ) - prepare_parser.add_argument( - "version", - nargs="?", - type=_semver_type, - help="The new release version (e.g., 0.28.0). If not provided, " - "it will be determined automatically.", - ) - prepare_parser.add_argument( - "--issue", - 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( - "complete-prepare", - help="Mark the Prepare Release task as complete in the tracking issue.", - ) - complete_prep_parser.add_argument( - "--pr", - type=int, - required=True, - help="The merged preparation PR number.", - ) - - # Subcommand: create-release-branch - create_branch_parser = subparsers.add_parser( - "create-release-branch", - help="Create the release branch pointing to the merged PR commit.", - ) - create_branch_parser.add_argument( - "--issue", - type=int, - required=True, - help="The tracking issue number (required).", - ) - create_branch_parser.add_argument( - "--remote", - type=str, - required=True, - help="The git remote to create the branch on (required).", - ) - - # Subcommand: process-backports - process_backports_parser = subparsers.add_parser( - "process-backports", - help="Cherry-pick pending backports listed in the tracking issue.", - ) - process_backports_parser.add_argument( - "--issue", - type=int, - required=True, - help="The tracking issue number (required).", - ) - process_backports_parser.add_argument( - "--remote", - type=str, - required=True, - help="The git remote to push changes to (required).", - ) - process_backports_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: create-rc - create_rc_parser = subparsers.add_parser( - "create-rc", - help="Tags the next RC on the release branch if no backports remain.", - ) - create_rc_parser.add_argument( - "--issue", - type=int, - required=True, - help="The tracking issue number (required).", - ) - create_rc_parser.add_argument( - "--remote", - type=str, - required=True, - help="The git remote to push the RC tag to (required).", - ) - - # Subcommand: promote-rc - promote_parser = subparsers.add_parser( - "promote-rc", - help="Promote the latest RC to final release.", - ) - promote_parser.add_argument( - "version", - nargs="?", - type=_semver_type, - help="The final version to release (e.g., 0.38.0).", - ) - promote_parser.add_argument( - "--issue", - type=int, - help="The tracking issue number (optional).", - ) - promote_parser.add_argument( - "--dry-run", - action=argparse.BooleanOptionalAction, - default=True, - help="Perform a dry run (default: True). Use --no-dry-run to actually execute.", - ) + for cmd in cmds: + cmd.add_parser(subparsers) return parser @@ -348,24 +50,10 @@ def main(): exit_code = 1 try: - if args.command == "determine-next-version": - exit_code = cmd_determine_next_version(args) - elif args.command == "create-release-issue": - exit_code = cmd_create_release_issue(args) - elif args.command == "prepare": - exit_code = cmd_prepare(args) - elif args.command == "complete-prepare": - exit_code = cmd_complete_prepare(args) - elif args.command == "create-release-branch": - exit_code = cmd_create_release_branch(args) - elif args.command == "process-backports": - exit_code = cmd_process_backports(args) - elif args.command == "create-rc": - exit_code = cmd_create_rc(args) - elif args.command == "promote-rc": - exit_code = cmd_promote_rc(args) + # args.command is the run_from_args classmethod of the selected command + exit_code = args.command(args) except Exception as e: - print(f"Fatal error executing {args.command}: {e}", file=sys.stderr) + print(f"Fatal error: {e}", file=sys.stderr) if hasattr(e, "__notes__"): for note in e.__notes__: print(note, file=sys.stderr) diff --git a/tools/private/release/release_issue.py b/tools/private/release/release_issue.py index adc0d88086..276261bef6 100644 --- a/tools/private/release/release_issue.py +++ b/tools/private/release/release_issue.py @@ -38,6 +38,49 @@ def __repr__(self): ) +class ReleaseTask: + """Represents a release task from the tracking issue checklist.""" + + def __init__( + self, + name: str, + checked: bool, + status: str | None = None, + pr: str | None = None, + commit: str | None = None, + branch: str | None = None, + tag: str | None = None, + metadata: dict[str, str] | None = None, + ): + """Initializes a ReleaseTask. + + Args: + name: The name of the task (e.g. 'Prepare Release'). + checked: Whether the checklist item is checked. + status: The status of the task (e.g. 'pending', 'done'). + pr: The associated PR reference (e.g. '#123'). + commit: The associated commit SHA. + branch: The associated branch name. + tag: The associated tag name. + metadata: Raw metadata parsed from the checklist line. + """ + self.name = name + self.checked = checked + self.status = status + self.pr = pr + self.commit = commit + self.branch = branch + self.tag = tag + self.metadata = metadata or {} + + def __repr__(self): + return ( + f"ReleaseTask(name={self.name!r}, checked={self.checked!r}, " + f"status={self.status!r}, pr={self.pr!r}, commit={self.commit!r}, " + f"branch={self.branch!r}, tag={self.tag!r})" + ) + + RELEASE_TITLE_RE = re.compile(r"Release (\d+\.\d+\.\d+)", re.IGNORECASE) @@ -101,22 +144,17 @@ def update_task_in_body(body, task_name, checked, metadata): def parse_checklist_state(body): - """Parses the main checklist tasks and their metadata.""" + """Parses the main checklist tasks and their metadata. + + Returns: + A dict containing ReleaseTask objects for 'prepare_release', + 'create_branch', 'tag_final', and a dict of RC tags. + """ state = { - "prepare_release": { - "checked": False, - "status": None, - "pr": None, - "commit": None, - }, - "create_branch": { - "checked": False, - "status": None, - "branch": None, - "commit": None, - }, - "tag_final": {"checked": False, "status": None, "tag": None, "commit": None}, - "rc_tags": {}, # Dynamically mapped: int -> metadata dict + "prepare_release": ReleaseTask("Prepare Release", False), + "create_branch": ReleaseTask("Create Release branch", False), + "tag_final": ReleaseTask("Tag Final", False), + "rc_tags": {}, # Dynamically mapped: int -> ReleaseTask } lines = body.splitlines() @@ -131,37 +169,45 @@ def parse_checklist_state(body): name_lower = name.lower() if "prepare release" in name_lower: - state["prepare_release"] = { - "checked": checked, - "status": meta.get("status"), - "pr": meta.get("pr"), - "commit": meta.get("commit"), - } + state["prepare_release"] = ReleaseTask( + name=name, + checked=checked, + status=meta.get("status"), + pr=meta.get("pr"), + commit=meta.get("commit"), + metadata=meta, + ) elif "create release branch" in name_lower: - state["create_branch"] = { - "checked": checked, - "status": meta.get("status"), - "branch": meta.get("branch"), - "commit": meta.get("commit"), - } + state["create_branch"] = ReleaseTask( + name=name, + checked=checked, + status=meta.get("status"), + branch=meta.get("branch"), + commit=meta.get("commit"), + metadata=meta, + ) elif "tag final" in name_lower: - state["tag_final"] = { - "checked": checked, - "status": meta.get("status"), - "tag": meta.get("tag"), - "commit": meta.get("commit"), - } + state["tag_final"] = ReleaseTask( + name=name, + checked=checked, + status=meta.get("status"), + tag=meta.get("tag"), + commit=meta.get("commit"), + metadata=meta, + ) else: # Match Tag RC rc_match = re.match(r"Tag RC(\d+)", name, re.IGNORECASE) if rc_match: rc_num = int(rc_match.group(1)) - state["rc_tags"][rc_num] = { - "checked": checked, - "status": meta.get("status"), - "tag": meta.get("tag"), - "commit": meta.get("commit"), - } + state["rc_tags"][rc_num] = ReleaseTask( + name=name, + checked=checked, + status=meta.get("status"), + tag=meta.get("tag"), + commit=meta.get("commit"), + metadata=meta, + ) return state diff --git a/tools/private/release/shell.py b/tools/private/release/shell.py index cfff53f4e4..0093a28574 100644 --- a/tools/private/release/shell.py +++ b/tools/private/release/shell.py @@ -4,14 +4,15 @@ import subprocess -def run_cmd(*args, check=True, capture_output=True): +def run_cmd(*args, check=True, capture_output=True, cwd=None): """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)}") + cwd_suffix = f" (cwd: {cwd})" if cwd else "" + print(f"> {shlex.join(cmd)}{cwd_suffix}") try: result = subprocess.run( cmd, @@ -19,10 +20,11 @@ def run_cmd(*args, check=True, capture_output=True): stdout=subprocess.PIPE if capture_output else None, stderr=subprocess.PIPE if capture_output else None, universal_newlines=True, + cwd=cwd, ) return result.stdout.strip() if capture_output else None except subprocess.CalledProcessError as e: - note = f"Error running command: {shlex.join(cmd)}" + note = f"Error running command: {shlex.join(cmd)}{cwd_suffix}" if capture_output: note += f"\nStdout: {e.stdout}\nStderr: {e.stderr}" e.add_note(note) diff --git a/tools/private/release/utils.py b/tools/private/release/utils.py index 2d050022b1..e774c80134 100644 --- a/tools/private/release/utils.py +++ b/tools/private/release/utils.py @@ -1,15 +1,26 @@ """Utility functions for the release tool.""" +import argparse import fnmatch import os import re from packaging.version import parse as parse_version -from tools.private.release import git +from tools.private.release.git import Git REPO_URL = "https://github.com/bazel-contrib/rules_python" + +def semver_type(value): + """Argparse type validator for semantic versions.""" + if not re.match(r"^\d+\.\d+\.\d+(rc\d+)?$", value): + raise argparse.ArgumentTypeError( + f"'{value}' is not a valid semantic version (X.Y.Z or X.Y.ZrcN)" + ) + return value + + _EXCLUDE_PATTERNS = [ "./.git/*", "./.github/*", @@ -45,6 +56,7 @@ def _iter_version_placeholder_files(): def get_latest_version(): """Gets the latest version from git tags.""" + git = Git(".") tags = git.get_tags() versions = [ (tag, parse_version(tag)) @@ -69,6 +81,7 @@ def get_latest_version(): def get_latest_rc_tag(version, remote=None): """Queries git tags and returns the highest RC tag for the version.""" + git = Git(".") if remote: tags = git.get_remote_tags(remote) else: @@ -97,6 +110,7 @@ def should_increment_minor(): def determine_next_version(branch_name=None): """Determines the next version based on git tags and the current branch.""" + git = Git(".") if branch_name is None: branch_name = git.get_current_branch() From 14dfed48e1759ff8696829cf6cf8070aabf7b09c Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Thu, 2 Jul 2026 07:13:15 +0000 Subject: [PATCH 2/3] Rename workflow files to match release tool commands. Renamed release-related GHA workflow files in .github/workflows to start with 'release_' and match the corresponding subcommands of the release tool. Also unified extension to .yaml. --- ...repare_release_pr_merged.yml => release_complete_prepare.yaml} | 0 .github/workflows/{generate_rc.yml => release_create_rc.yaml} | 0 ...{cut_release_branch.yml => release_create_release_branch.yaml} | 0 .github/workflows/{prepare_release.yml => release_prepare.yaml} | 0 .../{process_backports.yml => release_process_backports.yaml} | 0 .github/workflows/{promote_rc.yml => release_promote_rc.yaml} | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{on_prepare_release_pr_merged.yml => release_complete_prepare.yaml} (100%) rename .github/workflows/{generate_rc.yml => release_create_rc.yaml} (100%) rename .github/workflows/{cut_release_branch.yml => release_create_release_branch.yaml} (100%) rename .github/workflows/{prepare_release.yml => release_prepare.yaml} (100%) rename .github/workflows/{process_backports.yml => release_process_backports.yaml} (100%) rename .github/workflows/{promote_rc.yml => release_promote_rc.yaml} (100%) diff --git a/.github/workflows/on_prepare_release_pr_merged.yml b/.github/workflows/release_complete_prepare.yaml similarity index 100% rename from .github/workflows/on_prepare_release_pr_merged.yml rename to .github/workflows/release_complete_prepare.yaml diff --git a/.github/workflows/generate_rc.yml b/.github/workflows/release_create_rc.yaml similarity index 100% rename from .github/workflows/generate_rc.yml rename to .github/workflows/release_create_rc.yaml diff --git a/.github/workflows/cut_release_branch.yml b/.github/workflows/release_create_release_branch.yaml similarity index 100% rename from .github/workflows/cut_release_branch.yml rename to .github/workflows/release_create_release_branch.yaml diff --git a/.github/workflows/prepare_release.yml b/.github/workflows/release_prepare.yaml similarity index 100% rename from .github/workflows/prepare_release.yml rename to .github/workflows/release_prepare.yaml diff --git a/.github/workflows/process_backports.yml b/.github/workflows/release_process_backports.yaml similarity index 100% rename from .github/workflows/process_backports.yml rename to .github/workflows/release_process_backports.yaml diff --git a/.github/workflows/promote_rc.yml b/.github/workflows/release_promote_rc.yaml similarity index 100% rename from .github/workflows/promote_rc.yml rename to .github/workflows/release_promote_rc.yaml From 25763a33f22dc88a3f3506bf1cb343d0ea70b103 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Thu, 2 Jul 2026 07:16:03 +0000 Subject: [PATCH 3/3] Update workflow names to Release: . Updated the name field in the GHA workflow files to follow the 'Release: ' convention, matching the release tool subcommands. --- .github/workflows/release_complete_prepare.yaml | 2 +- .github/workflows/release_create_rc.yaml | 2 +- .github/workflows/release_create_release_branch.yaml | 2 +- .github/workflows/release_prepare.yaml | 2 +- .github/workflows/release_process_backports.yaml | 2 +- .github/workflows/release_promote_rc.yaml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release_complete_prepare.yaml b/.github/workflows/release_complete_prepare.yaml index 8ad4d1055c..6d8bc8fd03 100644 --- a/.github/workflows/release_complete_prepare.yaml +++ b/.github/workflows/release_complete_prepare.yaml @@ -1,4 +1,4 @@ -name: On PR Merged (Release Prepared) +name: "Release: Complete Prepare" on: pull_request: diff --git a/.github/workflows/release_create_rc.yaml b/.github/workflows/release_create_rc.yaml index 19a80bb1de..2e22ed1413 100644 --- a/.github/workflows/release_create_rc.yaml +++ b/.github/workflows/release_create_rc.yaml @@ -1,4 +1,4 @@ -name: Generate RC Tag +name: "Release: Create RC" on: workflow_dispatch: diff --git a/.github/workflows/release_create_release_branch.yaml b/.github/workflows/release_create_release_branch.yaml index f054f4cbc8..ea030a7085 100644 --- a/.github/workflows/release_create_release_branch.yaml +++ b/.github/workflows/release_create_release_branch.yaml @@ -1,4 +1,4 @@ -name: Cut Release Branch +name: "Release: Create Release Branch" on: issues: diff --git a/.github/workflows/release_prepare.yaml b/.github/workflows/release_prepare.yaml index b080f121eb..5c9a0b8f49 100644 --- a/.github/workflows/release_prepare.yaml +++ b/.github/workflows/release_prepare.yaml @@ -1,4 +1,4 @@ -name: Prepare Release +name: "Release: Prepare" on: workflow_dispatch: diff --git a/.github/workflows/release_process_backports.yaml b/.github/workflows/release_process_backports.yaml index 4d8055d3c5..ac6ea99d80 100644 --- a/.github/workflows/release_process_backports.yaml +++ b/.github/workflows/release_process_backports.yaml @@ -1,4 +1,4 @@ -name: Process Backports +name: "Release: Process Backports" on: workflow_dispatch: diff --git a/.github/workflows/release_promote_rc.yaml b/.github/workflows/release_promote_rc.yaml index d5e697f6d7..b668a15338 100644 --- a/.github/workflows/release_promote_rc.yaml +++ b/.github/workflows/release_promote_rc.yaml @@ -1,4 +1,4 @@ -name: Promote RC to Final Release +name: "Release: Promote RC" on: workflow_dispatch: