From b547f16962983a5fc61997edf138e2a71b29b48c Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Wed, 1 Jul 2026 02:39:25 +0000 Subject: [PATCH 1/6] chore(release): avoid checkout in create-release-branch This change refactors the create-release-branch subcommand to push the commit SHA directly to the remote branch reference, avoiding checking out the branch locally and changing the working directory state. Tests are updated to match. --- tests/tools/private/release/release_test.py | 7 ++++--- tools/private/release/create_release_branch.py | 17 ++++++----------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index d199eac99e..7aefeabd3f 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -1241,9 +1241,10 @@ def test_create_release_branch_success(self): # Assert self.assertEqual(result, 0) self.mock_git.fetch.assert_called_once_with("my-remote") - self.mock_git.checkout.assert_any_call("abcdef12") - self.mock_git.checkout.assert_any_call("release/2.0", create_branch=True) - self.mock_git.push.assert_called_once_with("my-remote", "release/2.0") + self.mock_git.checkout.assert_not_called() + self.mock_git.push.assert_called_once_with( + "my-remote", "abcdef12:refs/heads/release/2.0" + ) self.mock_gh.update_issue_body.assert_called_once() call_args = self.mock_gh.update_issue_body.call_args[0] diff --git a/tools/private/release/create_release_branch.py b/tools/private/release/create_release_branch.py index 45fec27196..e40ddf9af5 100644 --- a/tools/private/release/create_release_branch.py +++ b/tools/private/release/create_release_branch.py @@ -42,18 +42,13 @@ def cmd_create_release_branch(args): commit_sha = state["prepare_release"]["commit"] print(f"Cutting branch {branch_name} from commit {commit_sha}...") - # Create and push branch + # Create and push branch without affecting local checkout git.fetch(args.remote) - git.checkout(commit_sha) - - if not git.branch_exists(branch_name): - git.checkout(branch_name, create_branch=True) - else: - git.checkout(branch_name) - git.merge(commit_sha, ff_only=True) - - git.push(args.remote, branch_name) - print(f"Successfully pushed branch {branch_name} to {args.remote}") + 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...") From 08ac2c0d1b2c90e0cfec76944bca24806b569f12 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Wed, 1 Jul 2026 02:42:54 +0000 Subject: [PATCH 2/6] chore(release): refactor create-rc and handle existing remote release branch - Move cmd_create_rc logic from release.py to a new create_rc.py module. - Move parse_backports from release.py to release_issue.py. - Add required --remote flag to create-rc subcommand. - Update generate_rc.yml workflow to pass --remote origin. - In create-release-branch, detect if remote branch already exists and handle same-commit, fast-forward, and non-fast-forward cases. - Add remote_branch_exists and is_ancestor helpers to git.py. --- .github/workflows/generate_rc.yml | 2 +- tests/tools/private/release/release_test.py | 80 +++++++++- tools/private/release/BUILD.bazel | 1 + tools/private/release/create_rc.py | 111 ++++++++++++++ .../private/release/create_release_branch.py | 32 +++- tools/private/release/git.py | 18 +++ tools/private/release/release.py | 137 +----------------- tools/private/release/release_issue.py | 29 ++++ 8 files changed, 271 insertions(+), 139 deletions(-) create mode 100644 tools/private/release/create_rc.py diff --git a/.github/workflows/generate_rc.yml b/.github/workflows/generate_rc.yml index 26685c2f62..19a80bb1de 100644 --- a/.github/workflows/generate_rc.yml +++ b/.github/workflows/generate_rc.yml @@ -34,6 +34,6 @@ jobs: - name: Attempt RC Tagging run: | bazel run //tools/private/release -- \ - create-rc --issue ${{ inputs.issue }} + create-rc --issue ${{ inputs.issue }} --remote origin env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index 7aefeabd3f..7134c7797d 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -19,11 +19,13 @@ def _mock_git_and_gh(test_case): 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.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() mock_gh.MultipleTrackingIssuesError = MultipleTrackingIssuesError mock_gh.NoTrackingIssueError = NoTrackingIssueError @@ -932,7 +934,7 @@ def setUp(self): def test_create_rc_success_first_rc(self): # Arrange - args = MagicMock(issue=123) + args = MagicMock(issue=123, remote="my-remote") self.mock_gh.get_issue_title.return_value = "Release 2.0.0" self.mock_gh.get_issue_body.return_value = """ ## Checklist @@ -949,8 +951,9 @@ def test_create_rc_success_first_rc(self): # Assert self.assertEqual(result, 0) + self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True) self.mock_git.tag.assert_called_once_with("2.0.0-rc0", "HEAD") - self.mock_git.push.assert_called_once_with("origin", "2.0.0-rc0") + self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0") self.mock_gh.update_issue_body.assert_called_once() call_args = self.mock_gh.update_issue_body.call_args[0] @@ -962,7 +965,7 @@ def test_create_rc_success_first_rc(self): def test_create_rc_success_next_rc(self): # Arrange - args = MagicMock(issue=123) + args = MagicMock(issue=123, remote="my-remote") self.mock_gh.get_issue_title.return_value = "Release 2.0.0" self.mock_gh.get_issue_body.return_value = """ ## Checklist @@ -980,8 +983,9 @@ def test_create_rc_success_next_rc(self): # Assert self.assertEqual(result, 0) + self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True) self.mock_git.tag.assert_called_once_with("2.0.0-rc1", "HEAD") - self.mock_git.push.assert_called_once_with("origin", "2.0.0-rc1") + self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc1") self.mock_gh.update_issue_body.assert_called_once() call_args = self.mock_gh.update_issue_body.call_args[0] @@ -1234,6 +1238,7 @@ def test_create_release_branch_success(self): - [ ] Create Release branch | status=pending """ self.mock_git.branch_exists.return_value = False + self.mock_git.remote_branch_exists.return_value = False # Act result = releaser.cmd_create_release_branch(args) @@ -1291,6 +1296,73 @@ def test_create_release_branch_already_checked(self): self.mock_git.push.assert_not_called() self.mock_gh.update_issue_body.assert_not_called() + def test_create_release_branch_already_exists_same_commit(self): + # Arrange + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [ ] Create Release branch | status=pending +""" + self.mock_git.remote_branch_exists.return_value = True + self.mock_git.get_commit_sha.return_value = "abcdef12" + + # Act + result = releaser.cmd_create_release_branch(args) + + # Assert + self.assertEqual(result, 0) + self.mock_git.fetch.assert_called_once_with("my-remote") + self.mock_git.push.assert_not_called() + self.mock_gh.update_issue_body.assert_called_once() # Should still update checklist + + def test_create_release_branch_already_exists_fast_forward(self): + # Arrange + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [ ] Create Release branch | status=pending +""" + self.mock_git.remote_branch_exists.return_value = True + self.mock_git.get_commit_sha.return_value = "oldcommit" + self.mock_git.is_ancestor.return_value = True + + # Act + result = releaser.cmd_create_release_branch(args) + + # Assert + self.assertEqual(result, 0) + self.mock_git.fetch.assert_called_once_with("my-remote") + self.mock_git.push.assert_called_once_with( + "my-remote", "abcdef12:refs/heads/release/2.0" + ) + self.mock_gh.update_issue_body.assert_called_once() + + def test_create_release_branch_already_exists_non_ff(self): + # Arrange + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [ ] Create Release branch | status=pending +""" + self.mock_git.remote_branch_exists.return_value = True + self.mock_git.get_commit_sha.return_value = "othercommit" + self.mock_git.is_ancestor.return_value = False + + # Act + result = releaser.cmd_create_release_branch(args) + + # Assert + self.assertEqual(result, 1) + self.mock_git.fetch.assert_called_once_with("my-remote") + self.mock_git.push.assert_not_called() + self.mock_gh.update_issue_body.assert_not_called() + if __name__ == "__main__": unittest.main() diff --git a/tools/private/release/BUILD.bazel b/tools/private/release/BUILD.bazel index de2aba4b71..ad2e1b4cdc 100644 --- a/tools/private/release/BUILD.bazel +++ b/tools/private/release/BUILD.bazel @@ -10,6 +10,7 @@ py_library( py_binary( name = "release", srcs = [ + "create_rc.py", "create_release_branch.py", "gh.py", "git.py", diff --git a/tools/private/release/create_rc.py b/tools/private/release/create_rc.py new file mode 100644 index 0000000000..81852c7b66 --- /dev/null +++ b/tools/private/release/create_rc.py @@ -0,0 +1,111 @@ +"""Subcommand to tag and push the next release candidate.""" + +from tools.private.release import gh, git +from tools.private.release.release_issue import ( + RELEASE_TITLE_RE, + parse_backports, + parse_checklist_state, + update_task_in_body, +) +from tools.private.release.utils import ( + REPO_URL, + get_latest_rc_tag, +) + + +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, tags=True, force=True) + latest_rc = get_latest_rc_tag(version) + + 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 + + # Verify HEAD is not already tagged + git.checkout(branch_name) + head_tags = git.get_tags_at_head() + if any(tag.startswith(f"{version}-rc") for tag in head_tags): + print(f"HEAD of {branch_name} is already tagged with an RC. Skipping.") + return 0 + + print(f"Tagging and pushing next RC: {next_rc}...") + git.tag(next_rc, "HEAD") + git.push(args.remote, next_rc) + + commit_sha = git.get_commit_sha("HEAD") + + # 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_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+{version}" + comment_body = f"""🚀 **New Release Candidate Tagged!** + +Release Candidate **{next_rc}** has been successfully generated and tagged on branch `{branch_name}`. + +View Tag: [{next_rc}]({tag_url}) +Track BCR Progress: [Search BCR Pull Requests]({bcr_search_url})""" + gh.post_issue_comment(args.issue, comment_body) + print("RC creation completed successfully!") + return 0 diff --git a/tools/private/release/create_release_branch.py b/tools/private/release/create_release_branch.py index e40ddf9af5..7b12465f4c 100644 --- a/tools/private/release/create_release_branch.py +++ b/tools/private/release/create_release_branch.py @@ -44,11 +44,33 @@ def cmd_create_release_branch(args): # Create and push branch without affecting local checkout git.fetch(args.remote) - 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}" - ) + + 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) + else: + 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." + ) + 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...") diff --git a/tools/private/release/git.py b/tools/private/release/git.py index ed6e637d3f..ce2bd8ca03 100644 --- a/tools/private/release/git.py +++ b/tools/private/release/git.py @@ -132,3 +132,21 @@ def get_tags_at_head(): 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 diff --git a/tools/private/release/release.py b/tools/private/release/release.py index cae0053014..7bc6f9ba05 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -8,12 +8,12 @@ import sys from tools.private.release import changelog_news, 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.release_issue import ( RELEASE_TITLE_RE, - parse_checklist_state, - parse_metadata_line, + parse_backports, update_task_in_body, ) from tools.private.release.utils import ( @@ -36,35 +36,6 @@ def _semver_type(value): # ============================================================================== -def parse_backports(body): - """Parses the ## Backports checklist section.""" - body = body.replace("\r\n", "\n") - match = re.search( - r"## Backports\n(.*?)(?=\n##|\n---|\Z)", body, re.DOTALL | re.IGNORECASE - ) - if not match: - return [] - - section_content = match.group(1) - items = [] - lines = section_content.splitlines() - - for line in lines: - parsed = parse_metadata_line(line) - if parsed: - items.append( - { - "pr_ref": parsed["name"], - "checked": parsed["checked"], - "status": parsed["metadata"].get("status", "PENDING"), - "rc": parsed["metadata"].get("rc"), - "commit": parsed["metadata"].get("commit"), - "metadata": parsed["metadata"], - } - ) - return items - - # ============================================================================== # Subcommand Execution Functions # ============================================================================== @@ -263,104 +234,6 @@ def cmd_process_backports(args): return 0 -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("--tags", "--force") - latest_rc = get_latest_rc_tag(version) - - 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 - - # Verify HEAD is not already tagged - git.checkout(branch_name) - head_tags = git.get_tags_at_head() - if any(tag.startswith(f"{version}-rc") for tag in head_tags): - print(f"HEAD of {branch_name} is already tagged with an RC. Skipping.") - return 0 - - print(f"Tagging and pushing next RC: {next_rc}...") - git.tag(next_rc, "HEAD") - git.push("origin", next_rc) - - commit_sha = git.get_commit_sha("HEAD") - - # 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_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+{version}" - comment_body = f"""🚀 **New Release Candidate Tagged!** - -Release Candidate **{next_rc}** has been successfully generated and tagged on branch `{branch_name}`. - -View Tag: [{next_rc}]({tag_url}) -Track BCR Progress: [Search BCR Pull Requests]({bcr_search_url})""" - gh.post_issue_comment(args.issue, comment_body) - print("RC creation completed successfully!") - return 0 - - def cmd_promote_rc(args): """Executes the promote-rc subcommand (Phase 3).""" version = args.version @@ -549,6 +422,12 @@ def create_parser(): 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( diff --git a/tools/private/release/release_issue.py b/tools/private/release/release_issue.py index c170217471..eb7414e39a 100644 --- a/tools/private/release/release_issue.py +++ b/tools/private/release/release_issue.py @@ -128,3 +128,32 @@ def parse_checklist_state(body): } return state + + +def parse_backports(body): + """Parses the ## Backports checklist section.""" + body = body.replace("\r\n", "\n") + match = re.search( + r"## Backports\n(.*?)(?=\n##|\n---|\Z)", body, re.DOTALL | re.IGNORECASE + ) + if not match: + return [] + + section_content = match.group(1) + items = [] + lines = section_content.splitlines() + + for line in lines: + parsed = parse_metadata_line(line) + if parsed: + items.append( + { + "pr_ref": parsed["name"], + "checked": parsed["checked"], + "status": parsed["metadata"].get("status", "PENDING"), + "rc": parsed["metadata"].get("rc"), + "commit": parsed["metadata"].get("commit"), + "metadata": parsed["metadata"], + } + ) + return items From 74bcfbd1aeed7d0d1022af418c1a48811ae947ff Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Wed, 1 Jul 2026 02:52:31 +0000 Subject: [PATCH 3/6] chore(release): include release workflow link in RC comment --- tests/tools/private/release/release_test.py | 12 ++++++++++++ tools/private/release/create_rc.py | 4 +++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index 7134c7797d..48a6fd6424 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -962,6 +962,12 @@ def test_create_rc_success_first_rc(self): self.assertIn("commit=12345678", call_args[1]) self.mock_gh.post_issue_comment.assert_called_once() + comment_call_args = self.mock_gh.post_issue_comment.call_args[0] + self.assertEqual(comment_call_args[0], 123) + self.assertIn( + "Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)", + comment_call_args[1], + ) def test_create_rc_success_next_rc(self): # Arrange @@ -993,6 +999,12 @@ def test_create_rc_success_next_rc(self): self.assertIn("tag=2.0.0-rc1", call_args[1]) self.mock_gh.post_issue_comment.assert_called_once() + comment_call_args = self.mock_gh.post_issue_comment.call_args[0] + self.assertEqual(comment_call_args[0], 123) + self.assertIn( + "Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)", + comment_call_args[1], + ) def test_create_rc_already_tagged(self): # Arrange diff --git a/tools/private/release/create_rc.py b/tools/private/release/create_rc.py index 81852c7b66..7d573860e8 100644 --- a/tools/private/release/create_rc.py +++ b/tools/private/release/create_rc.py @@ -100,12 +100,14 @@ def cmd_create_rc(args): tag_url = f"{REPO_URL}/releases/tag/{next_rc}" 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}`. View Tag: [{next_rc}]({tag_url}) -Track BCR Progress: [Search BCR Pull Requests]({bcr_search_url})""" +Track BCR Progress: [Search BCR Pull Requests]({bcr_search_url}) +Trigger Release Workflow: [Release Workflow]({release_workflow_url})""" gh.post_issue_comment(args.issue, comment_body) print("RC creation completed successfully!") return 0 From 79fe59d9d0ee60d6fc42074da8eeea7b9ba2eac1 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Wed, 1 Jul 2026 02:53:42 +0000 Subject: [PATCH 4/6] chore(release): format RC comment links as bulleted list --- tests/tools/private/release/release_test.py | 4 ++-- tools/private/release/create_rc.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index 48a6fd6424..abb3495823 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -965,7 +965,7 @@ def test_create_rc_success_first_rc(self): comment_call_args = self.mock_gh.post_issue_comment.call_args[0] self.assertEqual(comment_call_args[0], 123) self.assertIn( - "Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)", + "- Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)", comment_call_args[1], ) @@ -1002,7 +1002,7 @@ def test_create_rc_success_next_rc(self): comment_call_args = self.mock_gh.post_issue_comment.call_args[0] self.assertEqual(comment_call_args[0], 123) self.assertIn( - "Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)", + "- Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)", comment_call_args[1], ) diff --git a/tools/private/release/create_rc.py b/tools/private/release/create_rc.py index 7d573860e8..ee4df2b6d2 100644 --- a/tools/private/release/create_rc.py +++ b/tools/private/release/create_rc.py @@ -105,9 +105,9 @@ def cmd_create_rc(args): Release Candidate **{next_rc}** has been successfully generated and tagged on branch `{branch_name}`. -View Tag: [{next_rc}]({tag_url}) -Track BCR Progress: [Search BCR Pull Requests]({bcr_search_url}) -Trigger Release Workflow: [Release Workflow]({release_workflow_url})""" +- View Tag: [{next_rc}]({tag_url}) +- Track BCR Progress: [Search BCR Pull Requests]({bcr_search_url}) +- Trigger Release Workflow: [Release Workflow]({release_workflow_url})""" gh.post_issue_comment(args.issue, comment_body) print("RC creation completed successfully!") return 0 From ebd77d536162cd714222936e2a4f5733c23e3d7d Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Wed, 1 Jul 2026 02:56:17 +0000 Subject: [PATCH 5/6] chore(release): address review comments on create-rc - Fetch remote before fetching tags. - Checkout remote-tracking branch directly instead of local branch. - Remove rocket emoji and add snake and basil emojis to the comment. - Update tests to match new behavior. --- tests/tools/private/release/release_test.py | 18 +++++++++++++----- tools/private/release/create_rc.py | 7 ++++--- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index abb3495823..ba42631006 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -3,7 +3,7 @@ import shutil import tempfile import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch from tools.private.release import changelog_news, release as releaser, utils from tools.private.release.gh import MultipleTrackingIssuesError, NoTrackingIssueError @@ -951,7 +951,10 @@ def test_create_rc_success_first_rc(self): # Assert self.assertEqual(result, 0) - self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True) + self.mock_git.fetch.assert_has_calls( + [call("my-remote"), call("my-remote", tags=True, force=True)] + ) + self.mock_git.checkout.assert_called_once_with("my-remote/release/2.0") self.mock_git.tag.assert_called_once_with("2.0.0-rc0", "HEAD") self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0") @@ -965,9 +968,10 @@ def test_create_rc_success_first_rc(self): comment_call_args = self.mock_gh.post_issue_comment.call_args[0] self.assertEqual(comment_call_args[0], 123) self.assertIn( - "- Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)", + "- Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml) 🐍🌿", comment_call_args[1], ) + self.assertNotIn("🚀", comment_call_args[1]) def test_create_rc_success_next_rc(self): # Arrange @@ -989,7 +993,10 @@ def test_create_rc_success_next_rc(self): # Assert self.assertEqual(result, 0) - self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True) + self.mock_git.fetch.assert_has_calls( + [call("my-remote"), call("my-remote", tags=True, force=True)] + ) + self.mock_git.checkout.assert_called_once_with("my-remote/release/2.0") self.mock_git.tag.assert_called_once_with("2.0.0-rc1", "HEAD") self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc1") @@ -1002,9 +1009,10 @@ def test_create_rc_success_next_rc(self): comment_call_args = self.mock_gh.post_issue_comment.call_args[0] self.assertEqual(comment_call_args[0], 123) self.assertIn( - "- Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)", + "- Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml) 🐍🌿", comment_call_args[1], ) + self.assertNotIn("🚀", comment_call_args[1]) def test_create_rc_already_tagged(self): # Arrange diff --git a/tools/private/release/create_rc.py b/tools/private/release/create_rc.py index ee4df2b6d2..980b8cdf14 100644 --- a/tools/private/release/create_rc.py +++ b/tools/private/release/create_rc.py @@ -51,6 +51,7 @@ def cmd_create_rc(args): 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) @@ -79,7 +80,7 @@ def cmd_create_rc(args): return 1 # Verify HEAD is not already tagged - git.checkout(branch_name) + git.checkout(f"{args.remote}/{branch_name}") head_tags = git.get_tags_at_head() if any(tag.startswith(f"{version}-rc") for tag in head_tags): print(f"HEAD of {branch_name} is already tagged with an RC. Skipping.") @@ -101,13 +102,13 @@ def cmd_create_rc(args): tag_url = f"{REPO_URL}/releases/tag/{next_rc}" 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!** + comment_body = f"""**New Release Candidate Tagged!** Release Candidate **{next_rc}** has been successfully generated and tagged on branch `{branch_name}`. - View Tag: [{next_rc}]({tag_url}) - Track BCR Progress: [Search BCR Pull Requests]({bcr_search_url}) -- Trigger Release Workflow: [Release Workflow]({release_workflow_url})""" +- Trigger Release Workflow: [Release Workflow]({release_workflow_url}) 🐍🌿""" gh.post_issue_comment(args.issue, comment_body) print("RC creation completed successfully!") return 0 From 6bfccc686649d25ce505f2bf814133b5687a3afc Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Wed, 1 Jul 2026 02:57:36 +0000 Subject: [PATCH 6/6] chore(release): move emojis to header in RC comment --- tests/tools/private/release/release_test.py | 12 ++++++++++-- tools/private/release/create_rc.py | 4 ++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index ba42631006..636c9b2589 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -968,7 +968,11 @@ def test_create_rc_success_first_rc(self): comment_call_args = self.mock_gh.post_issue_comment.call_args[0] self.assertEqual(comment_call_args[0], 123) self.assertIn( - "- Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml) 🐍🌿", + "**New Release Candidate Tagged!** 🐍🌿", + comment_call_args[1], + ) + self.assertIn( + "- Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)", comment_call_args[1], ) self.assertNotIn("🚀", comment_call_args[1]) @@ -1009,7 +1013,11 @@ def test_create_rc_success_next_rc(self): comment_call_args = self.mock_gh.post_issue_comment.call_args[0] self.assertEqual(comment_call_args[0], 123) self.assertIn( - "- Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml) 🐍🌿", + "**New Release Candidate Tagged!** 🐍🌿", + comment_call_args[1], + ) + self.assertIn( + "- Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)", comment_call_args[1], ) self.assertNotIn("🚀", comment_call_args[1]) diff --git a/tools/private/release/create_rc.py b/tools/private/release/create_rc.py index 980b8cdf14..9cd1a3b291 100644 --- a/tools/private/release/create_rc.py +++ b/tools/private/release/create_rc.py @@ -102,13 +102,13 @@ def cmd_create_rc(args): tag_url = f"{REPO_URL}/releases/tag/{next_rc}" 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!** + comment_body = f"""**New Release Candidate Tagged!** 🐍🌿 Release Candidate **{next_rc}** has been successfully generated and tagged on branch `{branch_name}`. - View Tag: [{next_rc}]({tag_url}) - Track BCR Progress: [Search BCR Pull Requests]({bcr_search_url}) -- Trigger Release Workflow: [Release Workflow]({release_workflow_url}) 🐍🌿""" +- Trigger Release Workflow: [Release Workflow]({release_workflow_url})""" gh.post_issue_comment(args.issue, comment_body) print("RC creation completed successfully!") return 0