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 d199eac99e..636c9b2589 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 @@ -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,12 @@ def test_create_rc_success_first_rc(self): # Assert self.assertEqual(result, 0) + 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("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] @@ -959,10 +965,21 @@ 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( + "**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]) 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 +997,12 @@ def test_create_rc_success_next_rc(self): # Assert self.assertEqual(result, 0) + 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("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] @@ -989,6 +1010,17 @@ 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( + "**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]) def test_create_rc_already_tagged(self): # Arrange @@ -1234,6 +1266,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) @@ -1241,9 +1274,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] @@ -1290,6 +1324,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..9cd1a3b291 --- /dev/null +++ b/tools/private/release/create_rc.py @@ -0,0 +1,114 @@ +"""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) + 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(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.") + 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}" + 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}) +- Trigger Release Workflow: [Release Workflow]({release_workflow_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 45fec27196..7b12465f4c 100644 --- a/tools/private/release/create_release_branch.py +++ b/tools/private/release/create_release_branch.py @@ -42,18 +42,35 @@ 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) + 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: - 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}") + 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