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
19 changes: 18 additions & 1 deletion .github/ISSUE_TEMPLATE/release_tracking_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,28 @@ labels: ['type: release']
- [ ] Tag Final

## Backports
<!-- Items to be cherry-picked into the next RC go here -->

<details>
<summary><b>How to add backports</b></summary>

To request a backport:
1. Add a new checklist item under the `## Backports` section.
2. The format must be: `- [ ] #<PR_NUMBER>` (e.g., `- [ ] #1234`).
3. Trigger the [Process Backports Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/process_backports.yml).
</details>

---
*Maintainers: Automation will react to changes on this issue.*

<details>
<summary><b>Manual Editing</b></summary>

You can manually edit this issue to control the release flow.
The checklist items use metadata suffix: `| key=value key2=value2`.
- **Retry Prepare Release**: Reset to `- [ ] Prepare Release | status=awaiting-preparation`.
- **Force Task Done**: Check the box `- [x]` and add appropriate metadata (e.g. `status=done`).
</details>

<details>
<summary><b>Available Commands</b></summary>

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/cut_release_branch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,6 @@ jobs:
- name: Attempt Branch Creation
run: |
bazel run //tools/private/release -- \
create-release-branch --issue ${{ github.event.issue.number }}
create-release-branch --issue ${{ github.event.issue.number }} --remote origin
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
73 changes: 73 additions & 0 deletions tests/tools/private/release/release_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ def _mock_git_and_gh(test_case):
# Patch bindings in modules that import them at module level
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.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()
mock_gh.MultipleTrackingIssuesError = MultipleTrackingIssuesError
mock_gh.NoTrackingIssueError = NoTrackingIssueError

Expand Down Expand Up @@ -1218,5 +1220,76 @@ def test_promote_rc_no_rc_found(self):
self.mock_gh.get_issue_body.assert_not_called()


class CmdCreateReleaseBranchTest(unittest.TestCase):
def setUp(self):
_mock_git_and_gh(self)

def test_create_release_branch_success(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.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_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(
"branch_url=https://github.com/bazel-contrib/rules_python/tree/release/2.0",
call_args[1],
)
Comment on lines +1251 to +1254

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add an assertion to verify that the branch metadata is also correctly written to the tracking issue body, aligning the test with the backward-compatibility fix.

Suggested change
self.assertIn(
"branch_url=https://github.com/bazel-contrib/rules_python/tree/release/2.0",
call_args[1],
)
self.assertIn(
"branch_url=https://github.com/bazel-contrib/rules_python/tree/release/2.0",
call_args[1],
)
self.assertIn("branch=release/2.0", call_args[1])

self.assertIn("commit=abcdef12", call_args[1])

def test_create_release_branch_prepare_not_done(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
- [ ] Prepare Release | status=pending
- [ ] Create Release branch | status=pending
"""
# Act
result = releaser.cmd_create_release_branch(args)

# Assert
self.assertEqual(result, 1)
self.mock_git.fetch.assert_not_called()
self.mock_git.push.assert_not_called()
self.mock_gh.update_issue_body.assert_not_called()

def test_create_release_branch_already_checked(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
- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12
"""
# Act
result = releaser.cmd_create_release_branch(args)

# Assert
self.assertEqual(result, 0)
self.mock_git.fetch.assert_not_called()
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_release_branch.py",
"gh.py",
"git.py",
"prepare.py",
Expand Down
67 changes: 67 additions & 0 deletions tools/private/release/create_release_branch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Subcommand to create a release branch from a merged PR commit."""

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


def cmd_create_release_branch(args):
"""Executes the create-release-branch subcommand."""
print(f"Evaluating branch creation for tracking issue #{args.issue}...")
body = gh.get_issue_body(args.issue)
state = parse_checklist_state(body)

if (
state["prepare_release"]["status"] != "done"
or not state["prepare_release"]["commit"]
):
print(
"Error: Prepare Release task is not marked 'done' with a valid commit SHA."
)
return 1

if state["create_branch"]["checked"]:
print("Release branch has already been created and checked. Skipping.")
return 0

# Extract version from issue title
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}"

commit_sha = state["prepare_release"]["commit"]
print(f"Cutting branch {branch_name} from commit {commit_sha}...")

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

if not git.branch_exists(branch_name):
git.checkout(branch_name, create_branch=True)
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}")

# Update tracking issue checklist
print("Updating tracking issue checklist...")
branch_url = f"{REPO_URL}/tree/{branch_name}"
metadata = {"status": "done", "branch_url": branch_url, "commit": commit_sha[:8]}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The metadata key has been changed from branch to branch_url. However, parse_checklist_state in tools/private/release/release_issue.py still expects the key branch (i.e., meta.get("branch")) and does not parse branch_url. To maintain backward compatibility with existing tracking issues and ensure that the parsed checklist state remains consistent, we should write both branch and branch_url to the metadata.

Suggested change
metadata = {"status": "done", "branch_url": branch_url, "commit": commit_sha[:8]}
metadata = {
"status": "done",
"branch": branch_name,
"branch_url": branch_url,
"commit": commit_sha[:8],
}

updated_body = update_task_in_body(
body, "Create Release branch", checked=True, metadata=metadata
)
gh.update_issue_body(args.issue, updated_body)
print("Create Release branch task marked complete successfully!")
return 0
77 changes: 13 additions & 64 deletions tools/private/release/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,20 @@
import sys

from tools.private.release import changelog_news, gh, git
from tools.private.release.create_release_branch import cmd_create_release_branch
from tools.private.release.prepare import cmd_prepare
from tools.private.release.release_issue import (
RELEASE_TITLE_RE,
parse_checklist_state,
parse_metadata_line,
update_task_in_body,
)
from tools.private.release.utils import (
_REPO_URL,
REPO_URL,
determine_next_version,
get_latest_rc_tag,
)

_RELEASE_TITLE_RE = re.compile(r"Release (\d+\.\d+\.\d+)", re.IGNORECASE)


def _semver_type(value):
if not re.match(r"^\d+\.\d+\.\d+(rc\d+)?$", value):
Expand Down Expand Up @@ -141,63 +141,6 @@ def cmd_complete_prepare(args):
return 0


def cmd_create_release_branch(args):
"""Executes the create-release-branch subcommand."""
print(f"Evaluating branch creation for tracking issue #{args.issue}...")
body = gh.get_issue_body(args.issue)
state = parse_checklist_state(body)

if (
state["prepare_release"]["status"] != "done"
or not state["prepare_release"]["commit"]
):
print(
"Error: Prepare Release task is not marked 'done' with a valid commit SHA."
)
return 1

if state["create_branch"]["checked"]:
print("Release branch has already been created and checked. Skipping.")
return 0

# Extract version from issue title
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}"

commit_sha = state["prepare_release"]["commit"]
print(f"Cutting branch {branch_name} from commit {commit_sha}...")

# Create and push branch
git.fetch("origin")
git.checkout(commit_sha)

if not git.branch_exists(branch_name):
git.checkout(branch_name, create_branch=True)
else:
git.checkout(branch_name)
git.merge(commit_sha, ff_only=True)

git.push("origin", branch_name)
print(f"Successfully pushed branch {branch_name}")

# Update tracking issue checklist
print("Updating tracking issue checklist...")
metadata = {"status": "done", "branch": branch_name, "commit": commit_sha[:8]}
updated_body = update_task_in_body(
body, "Create Release branch", checked=True, metadata=metadata
)
gh.update_issue_body(args.issue, updated_body)
print("Create Release branch task marked complete successfully!")
return 0


def cmd_process_backports(args):
"""Executes the process-backports subcommand."""
body = gh.get_issue_body(args.issue)
Expand All @@ -217,7 +160,7 @@ def cmd_process_backports(args):

# Determine branch name from issue title
issue_title = gh.get_issue_title(args.issue)
version_match = _RELEASE_TITLE_RE.search(issue_title)
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
Expand Down Expand Up @@ -348,7 +291,7 @@ def cmd_create_rc(args):

# Resolve version and branch
issue_title = gh.get_issue_title(args.issue)
version_match = _RELEASE_TITLE_RE.search(issue_title)
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
Expand Down Expand Up @@ -405,7 +348,7 @@ def cmd_create_rc(args):
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}"
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}"
comment_body = f"""🚀 **New Release Candidate Tagged!**

Expand Down Expand Up @@ -489,7 +432,7 @@ def cmd_promote_rc(args):
print(f"Posting comment to tracking issue #{issue_num}...")
import urllib.parse

release_url = f"{_REPO_URL}/releases/tag/{version}"
release_url = f"{REPO_URL}/releases/tag/{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)}"
comment_body = (
Expand Down Expand Up @@ -576,6 +519,12 @@ def create_parser():
required=True,
help="The tracking issue number (required).",
)
create_branch_parser.add_argument(
"--remote",
type=str,
required=True,
help="The git remote to create the branch on (required).",
)

# Subcommand: process-backports
process_backports_parser = subparsers.add_parser(
Expand Down
2 changes: 2 additions & 0 deletions tools/private/release/release_issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import re

RELEASE_TITLE_RE = re.compile(r"Release (\d+\.\d+\.\d+)", re.IGNORECASE)


def parse_metadata_line(line):
"""Parses a checklist line with optional | key=value metadata."""
Expand Down
2 changes: 1 addition & 1 deletion tools/private/release/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from tools.private.release import git

_REPO_URL = "https://github.com/bazel-contrib/rules_python"
REPO_URL = "https://github.com/bazel-contrib/rules_python"

_EXCLUDE_PATTERNS = [
"./.git/*",
Expand Down