Skip to content

Commit 7ad0c94

Browse files
committed
Merge upstream/main into fix-create-branch-no-checkout to resolve conflicts
2 parents b547f16 + 2241f8b commit 7ad0c94

8 files changed

Lines changed: 302 additions & 140 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: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import shutil
44
import tempfile
55
import unittest
6-
from unittest.mock import MagicMock, patch
6+
from unittest.mock import MagicMock, call, patch
77

88
from tools.private.release import changelog_news, release as releaser, utils
99
from tools.private.release.gh import MultipleTrackingIssuesError, NoTrackingIssueError
@@ -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,12 @@ def test_create_rc_success_first_rc(self):
949951

950952
# Assert
951953
self.assertEqual(result, 0)
954+
self.mock_git.fetch.assert_has_calls(
955+
[call("my-remote"), call("my-remote", tags=True, force=True)]
956+
)
957+
self.mock_git.checkout.assert_called_once_with("my-remote/release/2.0")
952958
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")
959+
self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0")
954960

955961
self.mock_gh.update_issue_body.assert_called_once()
956962
call_args = self.mock_gh.update_issue_body.call_args[0]
@@ -959,10 +965,21 @@ def test_create_rc_success_first_rc(self):
959965
self.assertIn("commit=12345678", call_args[1])
960966

961967
self.mock_gh.post_issue_comment.assert_called_once()
968+
comment_call_args = self.mock_gh.post_issue_comment.call_args[0]
969+
self.assertEqual(comment_call_args[0], 123)
970+
self.assertIn(
971+
"**New Release Candidate Tagged!** 🐍🌿",
972+
comment_call_args[1],
973+
)
974+
self.assertIn(
975+
"- Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)",
976+
comment_call_args[1],
977+
)
978+
self.assertNotIn("🚀", comment_call_args[1])
962979

963980
def test_create_rc_success_next_rc(self):
964981
# Arrange
965-
args = MagicMock(issue=123)
982+
args = MagicMock(issue=123, remote="my-remote")
966983
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
967984
self.mock_gh.get_issue_body.return_value = """
968985
## Checklist
@@ -980,15 +997,30 @@ def test_create_rc_success_next_rc(self):
980997

981998
# Assert
982999
self.assertEqual(result, 0)
1000+
self.mock_git.fetch.assert_has_calls(
1001+
[call("my-remote"), call("my-remote", tags=True, force=True)]
1002+
)
1003+
self.mock_git.checkout.assert_called_once_with("my-remote/release/2.0")
9831004
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")
1005+
self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc1")
9851006

9861007
self.mock_gh.update_issue_body.assert_called_once()
9871008
call_args = self.mock_gh.update_issue_body.call_args[0]
9881009
self.assertEqual(call_args[0], 123)
9891010
self.assertIn("tag=2.0.0-rc1", call_args[1])
9901011

9911012
self.mock_gh.post_issue_comment.assert_called_once()
1013+
comment_call_args = self.mock_gh.post_issue_comment.call_args[0]
1014+
self.assertEqual(comment_call_args[0], 123)
1015+
self.assertIn(
1016+
"**New Release Candidate Tagged!** 🐍🌿",
1017+
comment_call_args[1],
1018+
)
1019+
self.assertIn(
1020+
"- Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)",
1021+
comment_call_args[1],
1022+
)
1023+
self.assertNotIn("🚀", comment_call_args[1])
9921024

9931025
def test_create_rc_already_tagged(self):
9941026
# Arrange
@@ -1234,6 +1266,7 @@ def test_create_release_branch_success(self):
12341266
- [ ] Create Release branch | status=pending
12351267
"""
12361268
self.mock_git.branch_exists.return_value = False
1269+
self.mock_git.remote_branch_exists.return_value = False
12371270

12381271
# Act
12391272
result = releaser.cmd_create_release_branch(args)
@@ -1291,6 +1324,73 @@ def test_create_release_branch_already_checked(self):
12911324
self.mock_git.push.assert_not_called()
12921325
self.mock_gh.update_issue_body.assert_not_called()
12931326

1327+
def test_create_release_branch_already_exists_same_commit(self):
1328+
# Arrange
1329+
args = MagicMock(issue=123, remote="my-remote")
1330+
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
1331+
self.mock_gh.get_issue_body.return_value = """
1332+
## Checklist
1333+
- [x] Prepare Release | status=done pr=#122 commit=abcdef12
1334+
- [ ] Create Release branch | status=pending
1335+
"""
1336+
self.mock_git.remote_branch_exists.return_value = True
1337+
self.mock_git.get_commit_sha.return_value = "abcdef12"
1338+
1339+
# Act
1340+
result = releaser.cmd_create_release_branch(args)
1341+
1342+
# Assert
1343+
self.assertEqual(result, 0)
1344+
self.mock_git.fetch.assert_called_once_with("my-remote")
1345+
self.mock_git.push.assert_not_called()
1346+
self.mock_gh.update_issue_body.assert_called_once() # Should still update checklist
1347+
1348+
def test_create_release_branch_already_exists_fast_forward(self):
1349+
# Arrange
1350+
args = MagicMock(issue=123, remote="my-remote")
1351+
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
1352+
self.mock_gh.get_issue_body.return_value = """
1353+
## Checklist
1354+
- [x] Prepare Release | status=done pr=#122 commit=abcdef12
1355+
- [ ] Create Release branch | status=pending
1356+
"""
1357+
self.mock_git.remote_branch_exists.return_value = True
1358+
self.mock_git.get_commit_sha.return_value = "oldcommit"
1359+
self.mock_git.is_ancestor.return_value = True
1360+
1361+
# Act
1362+
result = releaser.cmd_create_release_branch(args)
1363+
1364+
# Assert
1365+
self.assertEqual(result, 0)
1366+
self.mock_git.fetch.assert_called_once_with("my-remote")
1367+
self.mock_git.push.assert_called_once_with(
1368+
"my-remote", "abcdef12:refs/heads/release/2.0"
1369+
)
1370+
self.mock_gh.update_issue_body.assert_called_once()
1371+
1372+
def test_create_release_branch_already_exists_non_ff(self):
1373+
# Arrange
1374+
args = MagicMock(issue=123, remote="my-remote")
1375+
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
1376+
self.mock_gh.get_issue_body.return_value = """
1377+
## Checklist
1378+
- [x] Prepare Release | status=done pr=#122 commit=abcdef12
1379+
- [ ] Create Release branch | status=pending
1380+
"""
1381+
self.mock_git.remote_branch_exists.return_value = True
1382+
self.mock_git.get_commit_sha.return_value = "othercommit"
1383+
self.mock_git.is_ancestor.return_value = False
1384+
1385+
# Act
1386+
result = releaser.cmd_create_release_branch(args)
1387+
1388+
# Assert
1389+
self.assertEqual(result, 1)
1390+
self.mock_git.fetch.assert_called_once_with("my-remote")
1391+
self.mock_git.push.assert_not_called()
1392+
self.mock_gh.update_issue_body.assert_not_called()
1393+
12941394

12951395
if __name__ == "__main__":
12961396
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: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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)
55+
git.fetch(args.remote, tags=True, force=True)
56+
latest_rc = get_latest_rc_tag(version)
57+
58+
if not latest_rc:
59+
next_rc_num = 0
60+
next_rc = f"{version}-rc0"
61+
else:
62+
rc_num = int(latest_rc.split("-rc")[-1])
63+
next_rc_num = rc_num + 1
64+
next_rc = f"{version}-rc{next_rc_num}"
65+
66+
# Precheck: next RC number must exist and be unchecked in the checklist
67+
rc_tags = state.get("rc_tags", {})
68+
if next_rc_num not in rc_tags:
69+
print(
70+
f"Error: Checklist is missing required task 'Tag RC{next_rc_num}'"
71+
f" to cut {version}-rc{next_rc_num}."
72+
)
73+
return 1
74+
75+
target_rc_task = rc_tags[next_rc_num]
76+
if target_rc_task["checked"] or target_rc_task["status"] == "done":
77+
print(
78+
f"Error: Task 'Tag RC{next_rc_num}' is already marked done in the checklist."
79+
)
80+
return 1
81+
82+
# Verify HEAD is not already tagged
83+
git.checkout(f"{args.remote}/{branch_name}")
84+
head_tags = git.get_tags_at_head()
85+
if any(tag.startswith(f"{version}-rc") for tag in head_tags):
86+
print(f"HEAD of {branch_name} is already tagged with an RC. Skipping.")
87+
return 0
88+
89+
print(f"Tagging and pushing next RC: {next_rc}...")
90+
git.tag(next_rc, "HEAD")
91+
git.push(args.remote, next_rc)
92+
93+
commit_sha = git.get_commit_sha("HEAD")
94+
95+
# Check off the appropriate "Tag RC{N}" task in the checklist
96+
print(f"Checking off Tag RC{next_rc_num} task...")
97+
metadata = {"status": "done", "tag": next_rc, "commit": commit_sha[:8]}
98+
task_name = f"Tag RC{next_rc_num}"
99+
updated_body = update_task_in_body(body, task_name, checked=True, metadata=metadata)
100+
gh.update_issue_body(args.issue, updated_body)
101+
102+
tag_url = f"{REPO_URL}/releases/tag/{next_rc}"
103+
bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+{version}"
104+
release_workflow_url = f"{REPO_URL}/actions/workflows/release.yml"
105+
comment_body = f"""**New Release Candidate Tagged!** 🐍🌿
106+
107+
Release Candidate **{next_rc}** has been successfully generated and tagged on branch `{branch_name}`.
108+
109+
- View Tag: [{next_rc}]({tag_url})
110+
- Track BCR Progress: [Search BCR Pull Requests]({bcr_search_url})
111+
- Trigger Release Workflow: [Release Workflow]({release_workflow_url})"""
112+
gh.post_issue_comment(args.issue, comment_body)
113+
print("RC creation completed successfully!")
114+
return 0

tools/private/release/create_release_branch.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,32 @@ 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+
if git.remote_branch_exists(args.remote, branch_name):
48+
remote_ref = f"{args.remote}/{branch_name}"
49+
remote_sha = git.get_commit_sha(remote_ref)
50+
if remote_sha == commit_sha:
51+
print(
52+
f"Branch {branch_name} already exists on {args.remote} and points to {commit_sha}. Skipping push."
53+
)
54+
elif git.is_ancestor(remote_ref, commit_sha):
55+
print(
56+
f"Branch {branch_name} exists on {args.remote} but can be fast-forwarded to {commit_sha}. Pushing..."
57+
)
58+
ref_spec = f"{commit_sha}:refs/heads/{branch_name}"
59+
git.push(args.remote, ref_spec)
60+
else:
61+
print(
62+
f"Error: Branch {branch_name} already exists on {args.remote} at {remote_sha[:8]}, "
63+
f"which is not an ancestor of {commit_sha[:8]}. Cannot fast-forward."
64+
)
65+
return 1
66+
else:
67+
print(f"Branch {branch_name} does not exist on {args.remote}. Pushing...")
68+
ref_spec = f"{commit_sha}:refs/heads/{branch_name}"
69+
git.push(args.remote, ref_spec)
70+
print(
71+
f"Successfully pushed branch {branch_name} pointing to {commit_sha} to {args.remote}"
72+
)
5273

5374
# Update tracking issue checklist
5475
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)