| """Subcommand to promote a release candidate to final release.""" | |
| import argparse | |
| import os | |
| import urllib.parse | |
| from dev.release.gh import GitHub | |
| from dev.release.git import Git | |
| from dev.release.release_issue import ( | |
| RELEASE_TITLE_RE, | |
| update_task_in_body, | |
| ) | |
| from dev.release.utils import ( | |
| REPO_URL, | |
| determine_next_version, | |
| get_latest_rc_tag, | |
| semver_type, | |
| set_github_output, | |
| ) | |
| class Promote: | |
| """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 remote to ensure we have the latest tags | |
| self.git.fetch(args.remote, tags=True, force=True) | |
| version = args.version | |
| if version is None: | |
| if args.issue: | |
| issue_title = self.gh.get_issue_title(args.issue) | |
| version_match = RELEASE_TITLE_RE.search(issue_title) | |
| if version_match: | |
| version = version_match.group(1) | |
| print( | |
| f"Resolved version {version} from tracking issue" | |
| f" #{args.issue} title." | |
| ) | |
| else: | |
| print( | |
| f"Error: Could not parse version from issue title:" | |
| f" {issue_title}" | |
| ) | |
| return 1 | |
| else: | |
| version = determine_next_version() | |
| # Verify final tag doesn't already exist | |
| if self.git.tag_exists(version): | |
| print(f"Error: Final tag {version} already exists.") | |
| return 1 | |
| is_first_release = version.endswith(".0") | |
| # 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 | |
| if is_first_release: | |
| latest_rc = get_latest_rc_tag(version, remote=args.remote) | |
| if not latest_rc: | |
| print(f"Error: No release candidate tags found matching {version}-rc*") | |
| return 1 | |
| rc_commit_sha = self.git.get_commit_sha(latest_rc) | |
| else: | |
| latest_rc = None | |
| rc_commit_sha = None | |
| # Verify issue can be found and read it early | |
| print(f"Verifying tracking issue #{issue_num} format...") | |
| body = self.gh.get_issue_body(issue_num) | |
| # Determine release branch name | |
| branch_version = ".".join(version.split(".")[:2]) | |
| branch_name = f"release/{branch_version}" | |
| remote_branch = f"{args.remote}/{branch_name}" | |
| print(f"Fetching remote branch {remote_branch}...") | |
| self.git.fetch(args.remote, refspec=branch_name) | |
| try: | |
| branch_sha = self.git.get_commit_sha(remote_branch) | |
| except Exception as e: | |
| print( | |
| f"Error: Could not get commit SHA for remote branch" | |
| f" {remote_branch}: {e}" | |
| ) | |
| return 1 | |
| if is_first_release: | |
| assert rc_commit_sha is not None # type assert | |
| if rc_commit_sha != branch_sha: | |
| print( | |
| f"Error: The latest RC tag {latest_rc} ({rc_commit_sha[:8]}) is not at" | |
| f" the head of release branch {remote_branch} ({branch_sha[:8]})." | |
| ) | |
| metadata = { | |
| "status": "error-rc-tag-not-branch-head", | |
| "rc": latest_rc, | |
| "branch_commit": branch_sha[:8], | |
| "tag_commit": rc_commit_sha[:8], | |
| } | |
| try: | |
| updated_body = update_task_in_body( | |
| body, "Tag Final", checked=False, metadata=metadata | |
| ) | |
| except ValueError as e: | |
| print(f"Error: Tracking issue #{issue_num} is malformed: {e}") | |
| return 1 | |
| if not args.dry_run: | |
| self.gh.update_issue_body(issue_num, updated_body) | |
| print(f"Updated tracking issue #{issue_num} with error status.") | |
| else: | |
| print( | |
| f"[DRY RUN] Would update tracking issue #{issue_num} with" | |
| f" error status." | |
| ) | |
| return 1 | |
| commit_sha = rc_commit_sha | |
| else: | |
| # Patch release: tag branch head directly | |
| commit_sha = branch_sha | |
| latest_rc = None | |
| # Verify issue is in the right format by trying to prepare the update (for success case) | |
| 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 | |
| promote_source = ( | |
| latest_rc | |
| if is_first_release | |
| else f"head of {branch_name} ({commit_sha[:8]})" | |
| ) | |
| if args.dry_run: | |
| print( | |
| f"[DRY RUN] Pre-conditions passed successfully for promoting" | |
| f" {promote_source} to {version}." | |
| ) | |
| print(f"[DRY RUN] Would tag commit {commit_sha[:8]} as {version}") | |
| print(f"[DRY RUN] Would push tag {version} to {args.remote}") | |
| 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 {promote_source} to final release {version} (commit" | |
| f" {commit_sha[:8]}) using tracking issue #{issue_num}..." | |
| ) | |
| # Tag the specific commit without checkout, and push to remote | |
| self.git.tag(version, commit_sha) | |
| self.git.push(args.remote, version) | |
| set_github_output("version", 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}...") | |
| branch_url = f"{REPO_URL}/tree/{branch_name}" | |
| release_url = f"{REPO_URL}/releases/tag/{version}" | |
| bcr_entry_url = f"https://registry.bazel.build/modules/rules_python/{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)}" | |
| 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_promote.yaml" | |
| comment_body = f"""**New Release Tagged!** 🐍🌿 | |
| Version **{version}** has been successfully generated and tagged on branch [`{branch_name}`]({branch_url}). | |
| - [Github Release {version}]({release_url}) | |
| - [BCR Entry {version}]({bcr_entry_url}) | |
| - [BCR PRs]({bcr_search_url}) | |
| - [Release workflow status]({release_workflow_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", | |
| 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( | |
| "--remote", | |
| type=str, | |
| required=True, | |
| help="The git remote to push the final tag 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() |