| """A tool to perform release steps.""" |
| |
| import argparse |
| import datetime |
| import os |
| import pathlib |
| import re |
| import sys |
| |
| from tools.private.release import changelog_news, gh, git |
| from tools.private.release.prepare import cmd_prepare |
| from tools.private.release.release_issue import ( |
| parse_checklist_state, |
| parse_metadata_line, |
| update_task_in_body, |
| ) |
| from tools.private.release.utils import ( |
| _REPO_URL, |
| determine_next_version, |
| get_latest_rc_tag, |
| ) |
| |
| _RELEASE_TITLE_RE = re.compile(r"Release (\d+\.\d+\.\d+)", re.IGNORECASE) |
| |
| |
| 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 |
| |
| |
| # ============================================================================== |
| # Checklist Parser and Formatter (Using new | key=value syntax) |
| # ============================================================================== |
| |
| |
| 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 |
| # ============================================================================== |
| |
| |
| 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_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) |
| |
| 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 |
| |
| if state["create_branch"]["checked"]: |
| print("Release branch has already been created and checked. Skipping.") |
| return 0 |
| |
| # 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 |
| |
| version = version_match.group(1) |
| branch_version = ".".join(version.split(".")[:2]) |
| branch_name = f"release/{branch_version}" |
| |
| commit_sha = state["prepare_release"]["commit"] |
| print(f"Cutting branch {branch_name} from commit {commit_sha}...") |
| |
| # Create and push branch |
| git.fetch("origin") |
| 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("origin", branch_name) |
| print(f"Successfully pushed branch {branch_name}") |
| |
| # Update tracking issue checklist |
| print("Updating tracking issue checklist...") |
| metadata = {"status": "done", "branch": branch_name, "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 |
| |
| |
| 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 item["status"] != "merge-conflict" |
| ] |
| |
| 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 = 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("--tags", "--force") |
| latest_rc = get_latest_rc_tag(version) |
| 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 |
| resolved_items = gh.resolve_backport_commits(pending_items) |
| |
| shas = [] |
| sha_to_item = {} |
| any_failed = False |
| for item in resolved_items: |
| if item.get("commit"): |
| sha = item["commit"] |
| sha_to_item[sha] = item |
| shas.append(sha) |
| else: |
| any_failed = True |
| body = update_task_in_body( |
| body, |
| item["pr_ref"], |
| checked=False, |
| metadata={"status": item.get("status", "failed")}, |
| ) |
| gh.update_issue_body(args.issue, body) |
| |
| if not shas: |
| print("No valid merge commits to process.") |
| if any_failed: |
| return 1 |
| return 0 |
| |
| # Sort chronologically using git helper |
| sorted_shas = git.sort_commits_chronologically(shas) |
| |
| git.fetch("origin") |
| git.checkout(branch_name) |
| |
| for sha in sorted_shas: |
| item = sha_to_item[sha] |
| print(f"Cherry-picking {sha} (PR {item['pr_ref']})...") |
| 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 |
| print(f"Amending cherry-pick commit for PR {item['pr_ref']}...") |
| git.commit("", amend=True, no_edit=True) |
| |
| # Push amended commit |
| git.push("origin", branch_name) |
| |
| new_sha = git.get_commit_sha("HEAD", short=True) |
| metadata = {"status": "done", "rc": next_rc_suffix, "commit": new_sha} |
| body = update_task_in_body( |
| body, item["pr_ref"], checked=True, metadata=metadata |
| ) |
| gh.update_issue_body(args.issue, body) |
| print(f"Applied: SUCCESS {new_sha}") |
| except Exception as e: |
| print(f"Conflict or error on {sha}: {e}. Aborting.") |
| try: |
| git.cherry_pick_abort() |
| except Exception: |
| pass |
| any_failed = True |
| |
| body = update_task_in_body( |
| body, |
| item["pr_ref"], |
| checked=False, |
| metadata={"status": "merge-conflict"}, |
| ) |
| gh.update_issue_body(args.issue, body) |
| print("Updated backport item to status=merge-conflict (unchecked)") |
| |
| if any_failed: |
| print("One or more cherry-picks/resolutions failed.") |
| return 1 |
| print("All backports successfully processed!") |
| 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 |
| if version is None: |
| version = determine_next_version() |
| |
| # Fetch from upstream to ensure we have the latest tags |
| git.fetch("upstream", tags=True, force=True) |
| latest_rc = get_latest_rc_tag(version) |
| 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 |
| |
| |
| def create_parser(): |
| """Creates the argument parser with subcommands.""" |
| parser = argparse.ArgumentParser( |
| description="Automate release steps for rules_python." |
| ) |
| |
| subparsers = parser.add_subparsers( |
| 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).", |
| ) |
| |
| # 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).", |
| ) |
| |
| # 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).", |
| ) |
| |
| # 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.", |
| ) |
| |
| return parser |
| |
| |
| def main(): |
| if "BUILD_WORKSPACE_DIRECTORY" in os.environ: |
| os.chdir(os.environ["BUILD_WORKSPACE_DIRECTORY"]) |
| |
| parser = create_parser() |
| args = parser.parse_args() |
| |
| 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) |
| except Exception as e: |
| print(f"Fatal error executing {args.command}: {e}", file=sys.stderr) |
| if hasattr(e, "__notes__"): |
| for note in e.__notes__: |
| print(note, file=sys.stderr) |
| sys.exit(1) |
| |
| sys.exit(exit_code if exit_code is not None else 0) |
| |
| |
| if __name__ == "__main__": |
| main() |