| """Subcommand to tag and push the next release candidate.""" |
| |
| import os |
| import traceback |
| from argparse import Namespace |
| |
| from dev.release.gh import GH_REACTION_THUMBS_DOWN, GitHub |
| from dev.release.git import Git |
| from dev.release.process_backports import ProcessBackports |
| from dev.release.release_issue import ( |
| RELEASE_TITLE_RE, |
| add_rc_task_to_body, |
| parse_backports, |
| parse_checklist_state, |
| update_task_in_body, |
| ) |
| from dev.release.utils import ( |
| REPO_URL, |
| get_latest_rc_tag, |
| set_github_output, |
| ) |
| |
| |
| 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 |
| exit_code = 0 |
| try: |
| exit_code = self._run_internal() |
| except Exception as e: |
| print(f"Unexpected error: {e}") |
| traceback.print_exc() |
| exit_code = 1 |
| |
| if exit_code != 0 and args.triggering_comment: |
| print(f"Reacting with thumbs-down to comment {args.triggering_comment}...") |
| try: |
| self.gh.add_comment_reaction( |
| args.triggering_comment, GH_REACTION_THUMBS_DOWN |
| ) |
| except Exception as e: |
| print(f"Failed to add reaction to comment: {e}") |
| |
| return exit_code |
| |
| def _run_internal(self) -> int: |
| """Internal implementation of create-rc.""" |
| args = self.args |
| |
| # Try to process pending backports first |
| print("Processing pending backports before creating RC...") |
| backport_args = Namespace( |
| issue=args.issue, |
| remote=args.remote, |
| add=None, |
| triggering_comment=None, |
| dry_run=False, |
| ) |
| pb = ProcessBackports(backport_args, self.git, self.gh) |
| backports_exit_code = pb.run() |
| if backports_exit_code != 0: |
| print("Error: Processing backports failed. Aborting RC creation.") |
| return backports_exit_code |
| |
| 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 (b.checked and b.status != "done") |
| or (not b.checked and b.status != "ignore") |
| ] |
| 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"Task 'Tag RC{next_rc_num}' not found in checklist. Adding it...") |
| body = add_rc_task_to_body(body, next_rc_num) |
| self.gh.update_issue_body(args.issue, body) |
| else: |
| 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) |
| |
| set_github_output("tag_name", 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 |
| ) |
| self.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/{next_rc}" |
| bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+{next_rc}" |
| if run_id := os.environ.get("GITHUB_RUN_ID"): |
| release_workflow_url = f"{REPO_URL}/actions/runs/{run_id}" |
| else: |
| release_workflow_url = ( |
| f"{REPO_URL}/actions/workflows/release_create_rc.yaml" |
| ) |
| branch_url = f"{REPO_URL}/tree/{branch_name}" |
| comment_body = f"""**New Release Candidate Tagged!** 🐍🌿 |
| |
| Release Candidate **{next_rc}** has been successfully generated and tagged on branch [`{branch_name}`]({branch_url}). |
| |
| - [Github Release {next_rc}]({tag_url}) |
| - [BCR Entry {next_rc}]({bcr_entry_url}) |
| - [BCR PRs]({bcr_search_url}) |
| - [Release workflow status]({release_workflow_url})""" |
| 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.add_argument( |
| "--triggering-comment", |
| type=int, |
| help="The ID of the comment that triggered this run (optional).", |
| ) |
| 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() |