blob: 874a347bd170c7dc8c89e95921dca795ea528ac0 [file]
"""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()