Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/generate_rc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,6 @@ jobs:
- name: Attempt RC Tagging
run: |
bazel run //tools/private/release -- \
create-rc --issue ${{ inputs.issue }}
create-rc --issue ${{ inputs.issue }} --remote origin
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
117 changes: 109 additions & 8 deletions tests/tools/private/release/release_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import shutil
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, call, patch

from tools.private.release import changelog_news, release as releaser, utils
from tools.private.release.gh import MultipleTrackingIssuesError, NoTrackingIssueError
Expand All @@ -19,11 +19,13 @@ def _mock_git_and_gh(test_case):
patch("tools.private.release.release.git", new=mock_git).start()
patch("tools.private.release.prepare.git", new=mock_git).start()
patch("tools.private.release.create_release_branch.git", new=mock_git).start()
patch("tools.private.release.create_rc.git", new=mock_git).start()
patch("tools.private.release.utils.git", new=mock_git).start()

patch("tools.private.release.release.gh", new=mock_gh).start()
patch("tools.private.release.prepare.gh", new=mock_gh).start()
patch("tools.private.release.create_release_branch.gh", new=mock_gh).start()
patch("tools.private.release.create_rc.gh", new=mock_gh).start()
mock_gh.MultipleTrackingIssuesError = MultipleTrackingIssuesError
mock_gh.NoTrackingIssueError = NoTrackingIssueError

Expand Down Expand Up @@ -932,7 +934,7 @@ def setUp(self):

def test_create_rc_success_first_rc(self):
# Arrange
args = MagicMock(issue=123)
args = MagicMock(issue=123, remote="my-remote")
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
self.mock_gh.get_issue_body.return_value = """
## Checklist
Expand All @@ -949,8 +951,12 @@ def test_create_rc_success_first_rc(self):

# Assert
self.assertEqual(result, 0)
self.mock_git.fetch.assert_has_calls(
[call("my-remote"), call("my-remote", tags=True, force=True)]
)
self.mock_git.checkout.assert_called_once_with("my-remote/release/2.0")
self.mock_git.tag.assert_called_once_with("2.0.0-rc0", "HEAD")
self.mock_git.push.assert_called_once_with("origin", "2.0.0-rc0")
self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0")

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

self.mock_gh.post_issue_comment.assert_called_once()
comment_call_args = self.mock_gh.post_issue_comment.call_args[0]
self.assertEqual(comment_call_args[0], 123)
self.assertIn(
"**New Release Candidate Tagged!** 🐍🌿",
comment_call_args[1],
)
self.assertIn(
"- Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)",
comment_call_args[1],
)
self.assertNotIn("🚀", comment_call_args[1])

def test_create_rc_success_next_rc(self):
# Arrange
args = MagicMock(issue=123)
args = MagicMock(issue=123, remote="my-remote")
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
self.mock_gh.get_issue_body.return_value = """
## Checklist
Expand All @@ -980,15 +997,30 @@ def test_create_rc_success_next_rc(self):

# Assert
self.assertEqual(result, 0)
self.mock_git.fetch.assert_has_calls(
[call("my-remote"), call("my-remote", tags=True, force=True)]
)
self.mock_git.checkout.assert_called_once_with("my-remote/release/2.0")
self.mock_git.tag.assert_called_once_with("2.0.0-rc1", "HEAD")
self.mock_git.push.assert_called_once_with("origin", "2.0.0-rc1")
self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc1")

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

self.mock_gh.post_issue_comment.assert_called_once()
comment_call_args = self.mock_gh.post_issue_comment.call_args[0]
self.assertEqual(comment_call_args[0], 123)
self.assertIn(
"**New Release Candidate Tagged!** 🐍🌿",
comment_call_args[1],
)
self.assertIn(
"- Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)",
comment_call_args[1],
)
self.assertNotIn("🚀", comment_call_args[1])

def test_create_rc_already_tagged(self):
# Arrange
Expand Down Expand Up @@ -1234,16 +1266,18 @@ def test_create_release_branch_success(self):
- [ ] Create Release branch | status=pending
"""
self.mock_git.branch_exists.return_value = False
self.mock_git.remote_branch_exists.return_value = False

# Act
result = releaser.cmd_create_release_branch(args)

# Assert
self.assertEqual(result, 0)
self.mock_git.fetch.assert_called_once_with("my-remote")
self.mock_git.checkout.assert_any_call("abcdef12")
self.mock_git.checkout.assert_any_call("release/2.0", create_branch=True)
self.mock_git.push.assert_called_once_with("my-remote", "release/2.0")
self.mock_git.checkout.assert_not_called()
self.mock_git.push.assert_called_once_with(
"my-remote", "abcdef12:refs/heads/release/2.0"
)

self.mock_gh.update_issue_body.assert_called_once()
call_args = self.mock_gh.update_issue_body.call_args[0]
Expand Down Expand Up @@ -1290,6 +1324,73 @@ def test_create_release_branch_already_checked(self):
self.mock_git.push.assert_not_called()
self.mock_gh.update_issue_body.assert_not_called()

def test_create_release_branch_already_exists_same_commit(self):
# Arrange
args = MagicMock(issue=123, remote="my-remote")
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
self.mock_gh.get_issue_body.return_value = """
## Checklist
- [x] Prepare Release | status=done pr=#122 commit=abcdef12
- [ ] Create Release branch | status=pending
"""
self.mock_git.remote_branch_exists.return_value = True
self.mock_git.get_commit_sha.return_value = "abcdef12"

# Act
result = releaser.cmd_create_release_branch(args)

# Assert
self.assertEqual(result, 0)
self.mock_git.fetch.assert_called_once_with("my-remote")
self.mock_git.push.assert_not_called()
self.mock_gh.update_issue_body.assert_called_once() # Should still update checklist

def test_create_release_branch_already_exists_fast_forward(self):
# Arrange
args = MagicMock(issue=123, remote="my-remote")
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
self.mock_gh.get_issue_body.return_value = """
## Checklist
- [x] Prepare Release | status=done pr=#122 commit=abcdef12
- [ ] Create Release branch | status=pending
"""
self.mock_git.remote_branch_exists.return_value = True
self.mock_git.get_commit_sha.return_value = "oldcommit"
self.mock_git.is_ancestor.return_value = True

# Act
result = releaser.cmd_create_release_branch(args)

# Assert
self.assertEqual(result, 0)
self.mock_git.fetch.assert_called_once_with("my-remote")
self.mock_git.push.assert_called_once_with(
"my-remote", "abcdef12:refs/heads/release/2.0"
)
self.mock_gh.update_issue_body.assert_called_once()

def test_create_release_branch_already_exists_non_ff(self):
# Arrange
args = MagicMock(issue=123, remote="my-remote")
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
self.mock_gh.get_issue_body.return_value = """
## Checklist
- [x] Prepare Release | status=done pr=#122 commit=abcdef12
- [ ] Create Release branch | status=pending
"""
self.mock_git.remote_branch_exists.return_value = True
self.mock_git.get_commit_sha.return_value = "othercommit"
self.mock_git.is_ancestor.return_value = False

# Act
result = releaser.cmd_create_release_branch(args)

# Assert
self.assertEqual(result, 1)
self.mock_git.fetch.assert_called_once_with("my-remote")
self.mock_git.push.assert_not_called()
self.mock_gh.update_issue_body.assert_not_called()


if __name__ == "__main__":
unittest.main()
1 change: 1 addition & 0 deletions tools/private/release/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ py_library(
py_binary(
name = "release",
srcs = [
"create_rc.py",
"create_release_branch.py",
"gh.py",
"git.py",
Expand Down
114 changes: 114 additions & 0 deletions tools/private/release/create_rc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Subcommand to tag and push the next release candidate."""

from tools.private.release import gh, git
from tools.private.release.release_issue import (
RELEASE_TITLE_RE,
parse_backports,
parse_checklist_state,
update_task_in_body,
)
from tools.private.release.utils import (
REPO_URL,
get_latest_rc_tag,
)


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(args.remote)
git.fetch(args.remote, tags=True, force=True)
Comment thread
rickeylev marked this conversation as resolved.
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(f"{args.remote}/{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(args.remote, 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}"
release_workflow_url = f"{REPO_URL}/actions/workflows/release.yml"
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})
- Trigger Release Workflow: [Release Workflow]({release_workflow_url})"""
gh.post_issue_comment(args.issue, comment_body)
print("RC creation completed successfully!")
return 0
35 changes: 26 additions & 9 deletions tools/private/release/create_release_branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,18 +42,35 @@ def cmd_create_release_branch(args):
commit_sha = state["prepare_release"]["commit"]
print(f"Cutting branch {branch_name} from commit {commit_sha}...")

# Create and push branch
# Create and push branch without affecting local checkout
git.fetch(args.remote)
git.checkout(commit_sha)

if not git.branch_exists(branch_name):
git.checkout(branch_name, create_branch=True)
if git.remote_branch_exists(args.remote, branch_name):
remote_ref = f"{args.remote}/{branch_name}"
remote_sha = git.get_commit_sha(remote_ref)
if remote_sha == commit_sha:
print(
f"Branch {branch_name} already exists on {args.remote} and points to {commit_sha}. Skipping push."
)
elif git.is_ancestor(remote_ref, commit_sha):
print(
f"Branch {branch_name} exists on {args.remote} but can be fast-forwarded to {commit_sha}. Pushing..."
)
ref_spec = f"{commit_sha}:refs/heads/{branch_name}"
git.push(args.remote, ref_spec)
else:
print(
f"Error: Branch {branch_name} already exists on {args.remote} at {remote_sha[:8]}, "
f"which is not an ancestor of {commit_sha[:8]}. Cannot fast-forward."
)
return 1
else:
git.checkout(branch_name)
git.merge(commit_sha, ff_only=True)

git.push(args.remote, branch_name)
print(f"Successfully pushed branch {branch_name} to {args.remote}")
print(f"Branch {branch_name} does not exist on {args.remote}. Pushing...")
ref_spec = f"{commit_sha}:refs/heads/{branch_name}"
git.push(args.remote, ref_spec)
print(
f"Successfully pushed branch {branch_name} pointing to {commit_sha} to {args.remote}"
)

# Update tracking issue checklist
print("Updating tracking issue checklist...")
Expand Down
18 changes: 18 additions & 0 deletions tools/private/release/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,21 @@ def get_tags_at_head():
def get_current_branch():
"""Returns the current git branch name."""
return run_cmd("git", "rev-parse", "--abbrev-ref", "HEAD")


def remote_branch_exists(remote, branch_name):
"""Returns True if a remote branch exists."""
try:
run_cmd("git", "show-ref", "--verify", f"refs/remotes/{remote}/{branch_name}")
return True
except subprocess.CalledProcessError:
return False


def is_ancestor(ancestor, descendant):
"""Returns True if ancestor is an ancestor of descendant (fast-forwardable)."""
try:
run_cmd("git", "merge-base", "--is-ancestor", ancestor, descendant)
return True
except subprocess.CalledProcessError:
return False
Loading