Skip to content

Commit 9734b11

Browse files
authored
chore(release): add dry-run to release tool prepare command (#3869)
To enable safe testing of the release pipeline, this change introduces a --dry-run flag to the prepare command that simulates all repository and GitHub actions. This also fixes a few issues with the prepare command not fully working end-to-end. Along the way, factor out the code a bit. release.py was over 1000 lines and becoming unwieldly.
1 parent 097f6a1 commit 9734b11

10 files changed

Lines changed: 601 additions & 425 deletions

File tree

.github/workflows/prepare_release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,6 @@ jobs:
3131
- name: Run Release Preparation Pipeline
3232
run: |
3333
# Manual trigger: run full preparation
34-
bazel run //tools/private/release -- prepare
34+
bazel run //tools/private/release -- prepare --no-dry-run
3535
env:
3636
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

tests/tools/private/release/release_test.py

Lines changed: 160 additions & 93 deletions
Large diffs are not rendered by default.

tools/private/release/BUILD.bazel

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ py_binary(
1212
srcs = [
1313
"gh.py",
1414
"git.py",
15+
"prepare.py",
1516
"release.py",
17+
"release_issue.py",
18+
"shell.py",
1619
"utils.py",
1720
],
1821
main = "release.py",

tools/private/release/gh.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,24 @@
44
import os
55
import tempfile
66

7-
from tools.private.release.utils import run_cmd
7+
from tools.private.release.shell import run_cmd
88

99
_REPO = "bazel-contrib/rules_python"
1010
_LABEL = "type: release"
1111

1212

13+
class MultipleTrackingIssuesError(ValueError):
14+
"""Raised when multiple open tracking issues are found for a version."""
15+
16+
pass
17+
18+
19+
class NoTrackingIssueError(ValueError):
20+
"""Raised when no open tracking issue is found for a version."""
21+
22+
pass
23+
24+
1325
def list_issues(*, fields, label=None, state=None, search=None):
1426
"""Helper to list issues using gh CLI."""
1527
cmd = ["gh", "issue", "list", f"--repo={_REPO}"]
@@ -50,13 +62,13 @@ def get_release_tracking_issue(version):
5062
exact_matches.append(issue)
5163

5264
if not exact_matches:
53-
raise ValueError(
65+
raise NoTrackingIssueError(
5466
f"No open tracking issue found matching 'Release {version}' "
5567
f"in repo {_REPO} with label '{_LABEL}'"
5668
)
5769
if len(exact_matches) > 1:
5870
urls = [issue["url"] for issue in exact_matches]
59-
raise ValueError(
71+
raise MultipleTrackingIssuesError(
6072
f"Multiple open tracking issues found for version {version} "
6173
f"in repo {_REPO} with label '{_LABEL}':\n" + "\n".join(urls)
6274
)
@@ -138,17 +150,15 @@ def update_issue_body(issue_num, body):
138150
os.unlink(temp_path)
139151

140152

141-
def create_pr(version, branch, issue_num):
153+
def create_pr(version, issue_num):
142154
"""Creates a pull request for release preparation."""
143155
return run_cmd(
144156
"gh",
145157
"pr",
146158
"create",
147159
f"--title=Prepare release v{version}",
148160
f"--body=Work towards #{issue_num}",
149-
f"--head={branch}",
150161
"--base=main",
151-
"--label=release-prepared",
152162
)
153163

154164

tools/private/release/git.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import subprocess
44

5-
from tools.private.release.utils import run_cmd
5+
from tools.private.release.shell import run_cmd
66

77

88
def get_tags():
@@ -24,6 +24,11 @@ def add(*files):
2424
run_cmd("git", "add", *files, capture_output=False)
2525

2626

27+
def add_modified_and_deleted():
28+
"""Stages all modified and deleted tracked files."""
29+
run_cmd("git", "add", "--update", capture_output=False)
30+
31+
2732
def commit(message, amend=False, no_edit=False):
2833
"""Commits staged changes, optionally amending the previous commit."""
2934
cmd = ["git", "commit"]
@@ -36,9 +41,13 @@ def commit(message, amend=False, no_edit=False):
3641
run_cmd(*cmd, capture_output=False)
3742

3843

39-
def push(remote, ref):
44+
def push(remote, ref, set_upstream=False):
4045
"""Pushes a reference to a remote repository."""
41-
run_cmd("git", "push", remote, ref, capture_output=False)
46+
cmd = ["git", "push"]
47+
if set_upstream:
48+
cmd.append("-u")
49+
cmd.extend([remote, ref])
50+
run_cmd(*cmd, capture_output=False)
4251

4352

4453
def fetch(remote="origin", tags=False, force=False):

tools/private/release/prepare.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import datetime
2+
import pathlib
3+
4+
from tools.private.release import changelog_news, gh, git
5+
from tools.private.release.release_issue import update_task_in_body
6+
from tools.private.release.utils import (
7+
determine_next_version,
8+
replace_version_next,
9+
)
10+
11+
12+
def cmd_prepare(args):
13+
"""Executes the prepare subcommand."""
14+
print("Fetching upstream to verify fresh release history...")
15+
git.fetch(tags=True, force=True)
16+
17+
# Run pre-check: verify there are no local edits
18+
status = git.status()
19+
if status:
20+
print(
21+
"Error: Local edits detected. Workspace must be completely clean"
22+
" before running release preparation."
23+
)
24+
for line in status.splitlines():
25+
print(f" {line}")
26+
return 1
27+
print("Pre-check passed: Workspace is clean.")
28+
29+
version = args.version
30+
if version is None:
31+
version = determine_next_version()
32+
33+
print(f"Running preparation pipeline for {version}...")
34+
35+
# 1. Find or create tracking issue (EARLY)
36+
# We do this before any write operations (branch creation, commit, push)
37+
issue_num = args.issue
38+
39+
if not issue_num:
40+
try:
41+
issue_num = gh.get_release_tracking_issue(version)
42+
print(f"Tracking issue: #{issue_num}")
43+
except gh.MultipleTrackingIssuesError as e:
44+
print(f"Error: {e}")
45+
return 1
46+
except gh.NoTrackingIssueError:
47+
# Not found, we need the template
48+
template_path = pathlib.Path(
49+
".github/ISSUE_TEMPLATE/release_tracking_template.md"
50+
)
51+
if not template_path.exists():
52+
raise FileNotFoundError(f"Template file not found at {template_path}")
53+
template_content = template_path.read_text(encoding="utf-8")
54+
55+
if args.dry_run:
56+
print(
57+
f"[DRY RUN] No active tracking issue found for {version}. Would create a new one."
58+
)
59+
print(f"[DRY RUN] Title: Release {version}\n{template_content}")
60+
issue_num = None # Keep it None for dry-run prints later
61+
else:
62+
print(
63+
f"No active tracking issue found for {version}. Creating a new one..."
64+
)
65+
issue_num = gh.create_tracking_issue(version, template_content)
66+
print(f"Tracking issue: #{issue_num}")
67+
else:
68+
print(f"Tracking issue: #{issue_num}")
69+
70+
branch_name = f"prepare-{version}"
71+
72+
# 2. Interleaved git and write operations
73+
74+
# --- Branch selection/creation ---
75+
if git.branch_exists(branch_name):
76+
if args.dry_run:
77+
print(
78+
f"[DRY RUN] Branch {branch_name} already exists. Would checkout existing branch."
79+
)
80+
else:
81+
print(f"Branch {branch_name} already exists. Checking it out...")
82+
git.checkout(branch_name)
83+
else:
84+
if args.dry_run:
85+
print(f"[DRY RUN] Would create and checkout branch {branch_name}")
86+
else:
87+
git.checkout(branch_name, create_branch=True)
88+
89+
# --- Update files ---
90+
if args.dry_run:
91+
print(
92+
f"[DRY RUN] Would update CHANGELOG.md and version placeholders for {version}"
93+
)
94+
else:
95+
print("Updating changelog and placeholders...")
96+
release_date = datetime.date.today().strftime("%Y-%m-%d")
97+
changelog_news.update_changelog(version, release_date)
98+
replace_version_next(version)
99+
100+
# --- Commit and Push ---
101+
if args.dry_run:
102+
print(f"[DRY RUN] Would push branch {branch_name} to origin")
103+
else:
104+
modified_files = git.status()
105+
if not modified_files:
106+
print("No files modified by the release tool. Nothing to commit.")
107+
return 0
108+
109+
# Stage all modified and deleted tracked files
110+
git.add_modified_and_deleted()
111+
112+
git.commit(f"Prepare release {version}")
113+
git.push("origin", branch_name, set_upstream=True)
114+
115+
# --- Create PR ---
116+
if args.dry_run:
117+
target_issue = f"#{issue_num}" if issue_num else "<NEW_ISSUE>"
118+
print(
119+
f"[DRY RUN] Would create Pull Request for branch {branch_name} targeting issue {target_issue}"
120+
)
121+
else:
122+
pr_url = gh.create_pr(version, issue_num)
123+
pr_num = pr_url.split("/")[-1]
124+
print(f"Created Pull Request: {pr_url} (PR #{pr_num})")
125+
126+
# --- Update checklist ---
127+
if args.dry_run:
128+
target_issue = f"#{issue_num}" if issue_num else "<NEW_ISSUE>"
129+
print(
130+
f"[DRY RUN] Would update tracking issue {target_issue} checklist 'Prepare Release' task status to PENDING"
131+
)
132+
else:
133+
print(
134+
f"Updating tracking issue #{issue_num} checklist 'Prepare Release' task status to PENDING..."
135+
)
136+
body = gh.get_issue_body(issue_num)
137+
metadata = {"status": "pending", "pr": f"#{pr_num}"}
138+
updated_body = update_task_in_body(
139+
body, "Prepare Release", checked=False, metadata=metadata
140+
)
141+
gh.update_issue_body(issue_num, updated_body)
142+
print("Preparation pipeline completed successfully!")
143+
144+
return 0

0 commit comments

Comments
 (0)