Skip to content

Commit 08ac2c0

Browse files
committed
chore(release): refactor create-rc and handle existing remote release branch
- Move cmd_create_rc logic from release.py to a new create_rc.py module. - Move parse_backports from release.py to release_issue.py. - Add required --remote flag to create-rc subcommand. - Update generate_rc.yml workflow to pass --remote origin. - In create-release-branch, detect if remote branch already exists and handle same-commit, fast-forward, and non-fast-forward cases. - Add remote_branch_exists and is_ancestor helpers to git.py.
1 parent b547f16 commit 08ac2c0

8 files changed

Lines changed: 271 additions & 139 deletions

File tree

.github/workflows/generate_rc.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,6 @@ jobs:
3434
- name: Attempt RC Tagging
3535
run: |
3636
bazel run //tools/private/release -- \
37-
create-rc --issue ${{ inputs.issue }}
37+
create-rc --issue ${{ inputs.issue }} --remote origin
3838
env:
3939
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

tests/tools/private/release/release_test.py

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@ def _mock_git_and_gh(test_case):
1919
patch("tools.private.release.release.git", new=mock_git).start()
2020
patch("tools.private.release.prepare.git", new=mock_git).start()
2121
patch("tools.private.release.create_release_branch.git", new=mock_git).start()
22+
patch("tools.private.release.create_rc.git", new=mock_git).start()
2223
patch("tools.private.release.utils.git", new=mock_git).start()
2324

2425
patch("tools.private.release.release.gh", new=mock_gh).start()
2526
patch("tools.private.release.prepare.gh", new=mock_gh).start()
2627
patch("tools.private.release.create_release_branch.gh", new=mock_gh).start()
28+
patch("tools.private.release.create_rc.gh", new=mock_gh).start()
2729
mock_gh.MultipleTrackingIssuesError = MultipleTrackingIssuesError
2830
mock_gh.NoTrackingIssueError = NoTrackingIssueError
2931

@@ -932,7 +934,7 @@ def setUp(self):
932934

933935
def test_create_rc_success_first_rc(self):
934936
# Arrange
935-
args = MagicMock(issue=123)
937+
args = MagicMock(issue=123, remote="my-remote")
936938
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
937939
self.mock_gh.get_issue_body.return_value = """
938940
## Checklist
@@ -949,8 +951,9 @@ def test_create_rc_success_first_rc(self):
949951

950952
# Assert
951953
self.assertEqual(result, 0)
954+
self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True)
952955
self.mock_git.tag.assert_called_once_with("2.0.0-rc0", "HEAD")
953-
self.mock_git.push.assert_called_once_with("origin", "2.0.0-rc0")
956+
self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0")
954957

955958
self.mock_gh.update_issue_body.assert_called_once()
956959
call_args = self.mock_gh.update_issue_body.call_args[0]
@@ -962,7 +965,7 @@ def test_create_rc_success_first_rc(self):
962965

963966
def test_create_rc_success_next_rc(self):
964967
# Arrange
965-
args = MagicMock(issue=123)
968+
args = MagicMock(issue=123, remote="my-remote")
966969
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
967970
self.mock_gh.get_issue_body.return_value = """
968971
## Checklist
@@ -980,8 +983,9 @@ def test_create_rc_success_next_rc(self):
980983

981984
# Assert
982985
self.assertEqual(result, 0)
986+
self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True)
983987
self.mock_git.tag.assert_called_once_with("2.0.0-rc1", "HEAD")
984-
self.mock_git.push.assert_called_once_with("origin", "2.0.0-rc1")
988+
self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc1")
985989

986990
self.mock_gh.update_issue_body.assert_called_once()
987991
call_args = self.mock_gh.update_issue_body.call_args[0]
@@ -1234,6 +1238,7 @@ def test_create_release_branch_success(self):
12341238
- [ ] Create Release branch | status=pending
12351239
"""
12361240
self.mock_git.branch_exists.return_value = False
1241+
self.mock_git.remote_branch_exists.return_value = False
12371242

12381243
# Act
12391244
result = releaser.cmd_create_release_branch(args)
@@ -1291,6 +1296,73 @@ def test_create_release_branch_already_checked(self):
12911296
self.mock_git.push.assert_not_called()
12921297
self.mock_gh.update_issue_body.assert_not_called()
12931298

1299+
def test_create_release_branch_already_exists_same_commit(self):
1300+
# Arrange
1301+
args = MagicMock(issue=123, remote="my-remote")
1302+
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
1303+
self.mock_gh.get_issue_body.return_value = """
1304+
## Checklist
1305+
- [x] Prepare Release | status=done pr=#122 commit=abcdef12
1306+
- [ ] Create Release branch | status=pending
1307+
"""
1308+
self.mock_git.remote_branch_exists.return_value = True
1309+
self.mock_git.get_commit_sha.return_value = "abcdef12"
1310+
1311+
# Act
1312+
result = releaser.cmd_create_release_branch(args)
1313+
1314+
# Assert
1315+
self.assertEqual(result, 0)
1316+
self.mock_git.fetch.assert_called_once_with("my-remote")
1317+
self.mock_git.push.assert_not_called()
1318+
self.mock_gh.update_issue_body.assert_called_once() # Should still update checklist
1319+
1320+
def test_create_release_branch_already_exists_fast_forward(self):
1321+
# Arrange
1322+
args = MagicMock(issue=123, remote="my-remote")
1323+
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
1324+
self.mock_gh.get_issue_body.return_value = """
1325+
## Checklist
1326+
- [x] Prepare Release | status=done pr=#122 commit=abcdef12
1327+
- [ ] Create Release branch | status=pending
1328+
"""
1329+
self.mock_git.remote_branch_exists.return_value = True
1330+
self.mock_git.get_commit_sha.return_value = "oldcommit"
1331+
self.mock_git.is_ancestor.return_value = True
1332+
1333+
# Act
1334+
result = releaser.cmd_create_release_branch(args)
1335+
1336+
# Assert
1337+
self.assertEqual(result, 0)
1338+
self.mock_git.fetch.assert_called_once_with("my-remote")
1339+
self.mock_git.push.assert_called_once_with(
1340+
"my-remote", "abcdef12:refs/heads/release/2.0"
1341+
)
1342+
self.mock_gh.update_issue_body.assert_called_once()
1343+
1344+
def test_create_release_branch_already_exists_non_ff(self):
1345+
# Arrange
1346+
args = MagicMock(issue=123, remote="my-remote")
1347+
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
1348+
self.mock_gh.get_issue_body.return_value = """
1349+
## Checklist
1350+
- [x] Prepare Release | status=done pr=#122 commit=abcdef12
1351+
- [ ] Create Release branch | status=pending
1352+
"""
1353+
self.mock_git.remote_branch_exists.return_value = True
1354+
self.mock_git.get_commit_sha.return_value = "othercommit"
1355+
self.mock_git.is_ancestor.return_value = False
1356+
1357+
# Act
1358+
result = releaser.cmd_create_release_branch(args)
1359+
1360+
# Assert
1361+
self.assertEqual(result, 1)
1362+
self.mock_git.fetch.assert_called_once_with("my-remote")
1363+
self.mock_git.push.assert_not_called()
1364+
self.mock_gh.update_issue_body.assert_not_called()
1365+
12941366

12951367
if __name__ == "__main__":
12961368
unittest.main()

tools/private/release/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ py_library(
1010
py_binary(
1111
name = "release",
1212
srcs = [
13+
"create_rc.py",
1314
"create_release_branch.py",
1415
"gh.py",
1516
"git.py",

tools/private/release/create_rc.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Subcommand to tag and push the next release candidate."""
2+
3+
from tools.private.release import gh, git
4+
from tools.private.release.release_issue import (
5+
RELEASE_TITLE_RE,
6+
parse_backports,
7+
parse_checklist_state,
8+
update_task_in_body,
9+
)
10+
from tools.private.release.utils import (
11+
REPO_URL,
12+
get_latest_rc_tag,
13+
)
14+
15+
16+
def cmd_create_rc(args):
17+
"""Executes the create-rc subcommand."""
18+
body = gh.get_issue_body(args.issue)
19+
state = parse_checklist_state(body)
20+
21+
if (
22+
state["prepare_release"]["status"] != "done"
23+
or state["create_branch"]["status"] != "done"
24+
):
25+
print(
26+
"Error: Preconditions not met (release must be prepared and branch created)."
27+
)
28+
return 1
29+
30+
# Gating: RC tagging is blocked if any backport is unchecked OR does not have status=done
31+
backports = parse_backports(body)
32+
conflicting_or_pending = [
33+
b for b in backports if not b["checked"] or b["status"] != "done"
34+
]
35+
if conflicting_or_pending:
36+
print(
37+
f"Gating RC tagging: {len(conflicting_or_pending)} backports are still"
38+
" unfinished, failed, or in conflict."
39+
)
40+
return 1
41+
42+
# Resolve version and branch
43+
issue_title = gh.get_issue_title(args.issue)
44+
version_match = RELEASE_TITLE_RE.search(issue_title)
45+
if not version_match:
46+
print(f"Error: Could not parse version from issue title: {issue_title}")
47+
return 1
48+
49+
version = version_match.group(1)
50+
branch_version = ".".join(version.split(".")[:2])
51+
branch_name = f"release/{branch_version}"
52+
53+
# Determine next RC tag
54+
git.fetch(args.remote, tags=True, force=True)
55+
latest_rc = get_latest_rc_tag(version)
56+
57+
if not latest_rc:
58+
next_rc_num = 0
59+
next_rc = f"{version}-rc0"
60+
else:
61+
rc_num = int(latest_rc.split("-rc")[-1])
62+
next_rc_num = rc_num + 1
63+
next_rc = f"{version}-rc{next_rc_num}"
64+
65+
# Precheck: next RC number must exist and be unchecked in the checklist
66+
rc_tags = state.get("rc_tags", {})
67+
if next_rc_num not in rc_tags:
68+
print(
69+
f"Error: Checklist is missing required task 'Tag RC{next_rc_num}'"
70+
f" to cut {version}-rc{next_rc_num}."
71+
)
72+
return 1
73+
74+
target_rc_task = rc_tags[next_rc_num]
75+
if target_rc_task["checked"] or target_rc_task["status"] == "done":
76+
print(
77+
f"Error: Task 'Tag RC{next_rc_num}' is already marked done in the checklist."
78+
)
79+
return 1
80+
81+
# Verify HEAD is not already tagged
82+
git.checkout(branch_name)
83+
head_tags = git.get_tags_at_head()
84+
if any(tag.startswith(f"{version}-rc") for tag in head_tags):
85+
print(f"HEAD of {branch_name} is already tagged with an RC. Skipping.")
86+
return 0
87+
88+
print(f"Tagging and pushing next RC: {next_rc}...")
89+
git.tag(next_rc, "HEAD")
90+
git.push(args.remote, next_rc)
91+
92+
commit_sha = git.get_commit_sha("HEAD")
93+
94+
# Check off the appropriate "Tag RC{N}" task in the checklist
95+
print(f"Checking off Tag RC{next_rc_num} task...")
96+
metadata = {"status": "done", "tag": next_rc, "commit": commit_sha[:8]}
97+
task_name = f"Tag RC{next_rc_num}"
98+
updated_body = update_task_in_body(body, task_name, checked=True, metadata=metadata)
99+
gh.update_issue_body(args.issue, updated_body)
100+
101+
tag_url = f"{REPO_URL}/releases/tag/{next_rc}"
102+
bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+{version}"
103+
comment_body = f"""🚀 **New Release Candidate Tagged!**
104+
105+
Release Candidate **{next_rc}** has been successfully generated and tagged on branch `{branch_name}`.
106+
107+
View Tag: [{next_rc}]({tag_url})
108+
Track BCR Progress: [Search BCR Pull Requests]({bcr_search_url})"""
109+
gh.post_issue_comment(args.issue, comment_body)
110+
print("RC creation completed successfully!")
111+
return 0

tools/private/release/create_release_branch.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,33 @@ def cmd_create_release_branch(args):
4444

4545
# Create and push branch without affecting local checkout
4646
git.fetch(args.remote)
47-
ref_spec = f"{commit_sha}:refs/heads/{branch_name}"
48-
git.push(args.remote, ref_spec)
49-
print(
50-
f"Successfully pushed branch {branch_name} pointing to {commit_sha} to {args.remote}"
51-
)
47+
48+
if git.remote_branch_exists(args.remote, branch_name):
49+
remote_ref = f"{args.remote}/{branch_name}"
50+
remote_sha = git.get_commit_sha(remote_ref)
51+
if remote_sha == commit_sha:
52+
print(
53+
f"Branch {branch_name} already exists on {args.remote} and points to {commit_sha}. Skipping push."
54+
)
55+
elif git.is_ancestor(remote_ref, commit_sha):
56+
print(
57+
f"Branch {branch_name} exists on {args.remote} but can be fast-forwarded to {commit_sha}. Pushing..."
58+
)
59+
ref_spec = f"{commit_sha}:refs/heads/{branch_name}"
60+
git.push(args.remote, ref_spec)
61+
else:
62+
print(
63+
f"Error: Branch {branch_name} already exists on {args.remote} at {remote_sha[:8]}, "
64+
f"which is not an ancestor of {commit_sha[:8]}. Cannot fast-forward."
65+
)
66+
return 1
67+
else:
68+
print(f"Branch {branch_name} does not exist on {args.remote}. Pushing...")
69+
ref_spec = f"{commit_sha}:refs/heads/{branch_name}"
70+
git.push(args.remote, ref_spec)
71+
print(
72+
f"Successfully pushed branch {branch_name} pointing to {commit_sha} to {args.remote}"
73+
)
5274

5375
# Update tracking issue checklist
5476
print("Updating tracking issue checklist...")

tools/private/release/git.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,21 @@ def get_tags_at_head():
132132
def get_current_branch():
133133
"""Returns the current git branch name."""
134134
return run_cmd("git", "rev-parse", "--abbrev-ref", "HEAD")
135+
136+
137+
def remote_branch_exists(remote, branch_name):
138+
"""Returns True if a remote branch exists."""
139+
try:
140+
run_cmd("git", "show-ref", "--verify", f"refs/remotes/{remote}/{branch_name}")
141+
return True
142+
except subprocess.CalledProcessError:
143+
return False
144+
145+
146+
def is_ancestor(ancestor, descendant):
147+
"""Returns True if ancestor is an ancestor of descendant (fast-forwardable)."""
148+
try:
149+
run_cmd("git", "merge-base", "--is-ancestor", ancestor, descendant)
150+
return True
151+
except subprocess.CalledProcessError:
152+
return False

0 commit comments

Comments
 (0)