diff --git a/.github/ISSUE_TEMPLATE/release_tracking_template.md b/.github/ISSUE_TEMPLATE/release_tracking_template.md
index 6bae3f3c2c..57a51a16ef 100644
--- a/.github/ISSUE_TEMPLATE/release_tracking_template.md
+++ b/.github/ISSUE_TEMPLATE/release_tracking_template.md
@@ -12,46 +12,21 @@ labels: ['type: release']
## Backports
-
-How to add backports
-
-To request a backport:
-1. Add a new checklist item under the `## Backports` section.
-2. The format must be: `- [ ] #` (e.g., `- [ ] #1234`).
-3. Trigger the [Process Backports Workflow][process_backports].
-
+To request a backport, add it to the checklist below and process it. See [RELEASING.md: How to add backports](https://github.com/bazel-contrib/rules_python/blob/main/RELEASING.md#how-to-add-backports) for details.
---
-*Maintainers: Automation will react to changes on this issue.*
-
-Manual Editing
-
-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`).
-
+To manually control the release flow, see the [RELEASING.md: Manual Editing](https://github.com/bazel-contrib/rules_python/blob/main/RELEASING.md#manual-editing-of-tracking-issue) section.
Available Commands
-Maintainers can trigger automation by:
-- Running manual workflows:
- - [Process Backports Workflow][process_backports]
- - [Create RC Workflow][create_rc]
- - [Promote RC to Final Release Workflow][promote_rc]
-- Commenting on this issue (requires the issue to have the `type: release`
- label):
- - `/prepare` at the beginning of a line to trigger the Release Prepare
- workflow.
- - `/create-rc` at the beginning of a line to trigger the Create RC
- workflow.
- - `/process-backports` at the beginning of a line to trigger the Process
- Backports workflow.
+Comment commands:
+- `/prepare`: Determines version, creates tracking issue and preparation PR.
+- `/create-rc`: Tags and publishes a new release candidate (RC).
+- `/process-backports`: Cherry-picks pending backports.
+- `/add-backports `: Adds PRs to the backports and processes backports.
+- `/promote`: Promotes the latest RC to final release.
+See [RELEASING.md](https://github.com/bazel-contrib/rules_python/blob/main/RELEASING.md) for details on how to use them.
-
-[process_backports]: https://github.com/bazel-contrib/rules_python/actions/workflows/release_process_backports.yaml
-[create_rc]: https://github.com/bazel-contrib/rules_python/actions/workflows/release_create_rc.yaml
-[promote_rc]: https://github.com/bazel-contrib/rules_python/actions/workflows/release_promote_rc.yaml
diff --git a/.github/workflows/on_issue_comment.yaml b/.github/workflows/on_issue_comment.yaml
index d37ae1516c..4be5906596 100644
--- a/.github/workflows/on_issue_comment.yaml
+++ b/.github/workflows/on_issue_comment.yaml
@@ -27,11 +27,13 @@ jobs:
outputs:
command: ${{ steps.parse.outputs.command }}
issue_number: ${{ github.event.issue.number }}
+ backports: ${{ steps.parse.outputs.backports }}
steps:
- name: Parse comment
id: parse
env:
COMMENT_BODY: ${{ github.event.comment.body }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/create-rc([[:space:]]|$)'; then
echo "command=create-rc" >> "$GITHUB_OUTPUT"
@@ -39,6 +41,27 @@ jobs:
echo "command=prepare" >> "$GITHUB_OUTPUT"
elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/process-backports([[:space:]]|$)'; then
echo "command=process-backports" >> "$GITHUB_OUTPUT"
+ elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/add-backports([[:space:]]|$)'; then
+ args=$(echo "$COMMENT_BODY" | grep -E '^[[:space:]]*/add-backports([[:space:]]|$)' | sed -E 's/^[[:space:]]*\/add-backports[[:space:]]*//')
+ # Strip leading/trailing spaces and commas
+ args=$(echo "$args" | sed -e 's/^[[:space:],]*//' -e 's/[[:space:],]*$//')
+ # Replace internal spaces/commas with single comma
+ csv=$(echo "$args" | sed -E 's/[[:space:],]+/ /g' | tr ' ' ',')
+ if [ -n "$csv" ]; then
+ echo "command=add-backports" >> "$GITHUB_OUTPUT"
+ echo "backports=$csv" >> "$GITHUB_OUTPUT"
+ else
+ echo "command=none" >> "$GITHUB_OUTPUT"
+ echo "Error: No PRs specified for add-backports." >&2
+ gh api \
+ --method POST \
+ -H "Accept: application/vnd.github+json" \
+ -H "X-GitHub-Api-Version: 2022-11-28" \
+ /repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \
+ -f "content=-1"
+ fi
+ elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/promote([[:space:]]|$)'; then
+ echo "command=promote" >> "$GITHUB_OUTPUT"
else
echo "command=none" >> "$GITHUB_OUTPUT"
fi
@@ -61,8 +84,20 @@ jobs:
call_process_backports:
needs: parse_comment
- if: needs.parse_comment.outputs.command == 'process-backports'
+ if: |
+ needs.parse_comment.outputs.command == 'process-backports' ||
+ needs.parse_comment.outputs.command == 'add-backports'
uses: ./.github/workflows/release_process_backports.yaml
+ with:
+ issue: ${{ needs.parse_comment.outputs.issue_number }}
+ add_backports: ${{ needs.parse_comment.outputs.command == 'add-backports' && needs.parse_comment.outputs.backports || '' }}
+ comment_id: "${{ github.event.comment.id }}"
+ secrets: inherit
+
+ call_promote:
+ needs: parse_comment
+ if: needs.parse_comment.outputs.command == 'promote'
+ uses: ./.github/workflows/release_promote_rc.yaml
with:
issue: ${{ needs.parse_comment.outputs.issue_number }}
secrets: inherit
diff --git a/.github/workflows/release_process_backports.yaml b/.github/workflows/release_process_backports.yaml
index 0812aa5248..394a5b0fe9 100644
--- a/.github/workflows/release_process_backports.yaml
+++ b/.github/workflows/release_process_backports.yaml
@@ -7,12 +7,28 @@ on:
description: 'The Release Tracking Issue Number (e.g., 142)'
required: true
type: string
+ add_backports:
+ description: 'CSV list of PR numbers to add and process (optional)'
+ required: false
+ type: string
+ comment_id:
+ description: 'The ID of the comment that triggered this run (optional)'
+ required: false
+ type: string
workflow_call:
inputs:
issue:
description: 'The Release Tracking Issue Number (e.g., 142)'
required: true
type: string
+ add_backports:
+ description: 'CSV list of PR numbers to add and process (optional)'
+ required: false
+ type: string
+ comment_id:
+ description: 'The ID of the comment that triggered this run (optional)'
+ required: false
+ type: string
permissions:
contents: write
@@ -40,7 +56,18 @@ jobs:
- name: Process Pending Backports
run: |
- bazel run //tools/private/release -- \
- process-backports --issue ${{ inputs.issue }} --remote origin --no-dry-run
+ ARGS=()
+ if [ -n "${{ inputs.add_backports }}" ]; then
+ ARGS+=("--add=${{ inputs.add_backports }}")
+ fi
+ if [ -n "${{ inputs.comment_id }}" ]; then
+ ARGS+=("--triggering-comment=${{ inputs.comment_id }}")
+ fi
+
+ bazel run //tools/private/release -- process-backports \
+ --issue ${{ inputs.issue }} \
+ --remote origin \
+ --no-dry-run \
+ "${ARGS[@]}"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/release_promote_rc.yaml b/.github/workflows/release_promote_rc.yaml
index 5a0ad72a80..987583a1fd 100644
--- a/.github/workflows/release_promote_rc.yaml
+++ b/.github/workflows/release_promote_rc.yaml
@@ -7,6 +7,16 @@ on:
description: 'The final version to release (e.g., 0.38.0)'
required: true
type: string
+ workflow_call:
+ inputs:
+ version:
+ description: 'The final version to release (e.g., 0.38.0)'
+ required: false
+ type: string
+ issue:
+ description: 'The tracking issue number'
+ required: true
+ type: string
permissions:
contents: write
@@ -33,7 +43,16 @@ jobs:
- name: Run Promote RC
run: |
- bazel run //tools/private/release -- \
- promote-rc ${{ inputs.version }} --remote origin
+ ARGS=()
+ if [ -n "${{ inputs.version }}" ]; then
+ ARGS+=("${{ inputs.version }}")
+ fi
+ if [ -n "${{ inputs.issue }}" ]; then
+ ARGS+=("--issue" "${{ inputs.issue }}")
+ fi
+ ARGS+=("--remote" "origin")
+ ARGS+=("--no-dry-run")
+
+ bazel run //tools/private/release -- promote-rc "${ARGS[@]}"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/RELEASING.md b/RELEASING.md
index 13eb4ddc0a..528450f611 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -8,54 +8,40 @@ existing Bazel workspace to sanity check functionality.
## Releasing from HEAD
-These are the steps for a regularly scheduled release from HEAD.
+Releases are managed using a semi-automated process centered around a GitHub
+Release Tracking Issue and automated workflows triggered by comments or issue edits.
+
+> [!NOTE]
+> Comment-based commands must be posted by project maintainers (Owner,
+> Member, or Collaborator) and must be on their own line (leading and trailing
+> whitespace is ignored).
### Steps
-1. Update the changelog and replace the version placeholders by running the
- release tool. The next version number will be automatically determined
- based on the presence of `VERSION_NEXT_*` placeholders and git tags. The
- tool will read all news entry files in the `news/` directory, assemble
- them into the changelog, and delete the processed news files.
-
- ```shell
- bazel run //tools/private/release
- ```
-
- If you want to append news entries to an already existing release section in
- the changelog (for example, to update a drafted release or a release
- branch), you can specify the version explicitly:
-
- ```shell
- bazel run //tools/private/release -- X.Y.Z
- ```
-
-1. Send these changes for review and get them merged.
-1. Create a branch for the new release, named `release/X.Y`
- ```
- git branch --no-track release/X.Y upstream/main && git push upstream release/X.Y
- ```
-
-The next step is to create tags to trigger release workflow, **however**
-we start by using release candidate tags (`X.Y.Z-rcN`) before tagging the
-final release (`X.Y.Z`).
-
-1. Create release candidate tag and push. The first RC uses `N=0`. Increment
- `N` for each RC.
- ```
- git tag X.Y.0-rcN upstream/release/X.Y && git push upstream tag X.Y.0-rcN
- ```
-2. Announce the RC release: see [Announcing Releases]
-3. Wait a week for feedback.
- * Follow [Patch release with cherry picks] to pull bug fixes into the
- release branch.
- * Repeat the RC tagging step, incrementing `N`.
-4. Finally, tag the final release tag:
- ```shell
- git tag X.Y.0 upstream/release/X.Y && git push upstream tag X.Y.0
- ```
-
-Release automation will create a GitHub release and BCR pull request.
+1. **Prepare the Release**: Run the [Release: Prepare](https://github.com/bazel-contrib/rules_python/actions/workflows/release_prepare.yaml)
+ workflow manually. You can trigger it from the GitHub Actions UI or using
+ the GitHub CLI:
+ ```shell
+ gh workflow run release_prepare.yaml --repo bazel-contrib/rules_python
+ ```
+ This will automatically determine the next version, create a release tracking
+ issue, and send a preparation PR.
+
+2. **Approve and Merge**: Approve and merge the PR. Once merged, a release
+ branch will be created automatically.
+
+3. **Add Backports (if needed)**: If there are backports, add them following
+ the [How to add backports](#how-to-add-backports) steps.
+
+4. **Create an RC**: Comment `/create-rc` on the tracking issue. All pending
+ backports must be successfully processed before creating the RC.
+
+5. **Iterate**: Repeat steps 3 and 4 until backports and RCs are no longer
+ needed.
+
+6. **Finalize the Release**: Comment `/promote` on the tracking issue to
+ finalize the release.
+
### Manually triggering the release workflow
@@ -87,6 +73,31 @@ the `VERSION_NEXT_*` placeholders in the codebase. To see what changes are
being accumulated for the next release, review the pending news entries in the
`news/` directory.
+## How to add backports
+
+To add backports to an active release, you can use one of the following
+methods:
+
+### Method A: Manual Checklist Update
+1. Manually add checklist items under the `## Backports` section of the
+ Release Tracking Issue. The format must be: `- [ ] #` (e.g.,
+ `- [ ] #1234`).
+2. When ready, comment `/process-backports` on the tracking issue to trigger
+ processing.
+
+### Method B: Comment Shortcut
+1. Comment `/add-backports [ ...]` (space or comma
+ separated) on the tracking issue. This will automatically add the PRs to the
+ checklist and trigger processing.
+
+### Failure Behavior
+If a backport fails to process (e.g., due to cherry-pick conflicts):
+* The failed backport checklist item will remain unchecked with
+ `status=error-`.
+* You must resolve the conflict manually: checkout the release branch,
+ cherry-pick the PR, resolve conflicts, push to remote, and manually check
+ the box on the tracking issue checklist with `status=done` metadata.
+
## Patch release with cherry picks
If a patch release from head would contain changes that aren't appropriate for
@@ -105,8 +116,8 @@ The fix being included is commit `deadbeef`.
If multiple commits need to be applied, repeat the `git cherry-pick` step for
each.
-Once the release branch is in the desired state, use `git tag` to tag it, as
-done with a release from head. Release automation will do the rest.
+Once the release branch is in the desired state, comment `/create-rc` on the
+tracking issue to tag it, as done with a release from head.
### Announcing releases
@@ -139,6 +150,14 @@ The two points of no return are:
If release steps fail _prior_ to those steps, then its OK to change the tag. You
may need to manually delete the GitHub release.
+## Manual Editing of Tracking Issue
+
+You can manually edit the Release Tracking Issue to control the release flow.
+The checklist items use metadata suffix: `| key=value key2=value2`.
+
+* **Retry Prepare Release**: Reset the task to `- [ ] Prepare Release | status=awaiting-preparation`.
+* **Force Task Done**: Check the box `- [x]` and add appropriate metadata (e.g. `status=done`).
+
## Secrets
### PyPI user rules-python
diff --git a/tests/tools/private/release/process_backports_test.py b/tests/tools/private/release/process_backports_test.py
index 0ceb7b3210..a0de7c8566 100644
--- a/tests/tools/private/release/process_backports_test.py
+++ b/tests/tools/private/release/process_backports_test.py
@@ -1,6 +1,7 @@
+import argparse
import datetime
import unittest
-from unittest.mock import MagicMock, call, patch
+from unittest.mock import call, patch
from tests.tools.private.release.release_test_helper import _mock_git_and_gh
from tools.private.release.process_backports import ProcessBackports
@@ -18,7 +19,9 @@ def setUp(self):
self.addCleanup(patch.stopall)
def test_process_backports_no_pending(self):
- args = MagicMock(issue=123, remote="origin", dry_run=False)
+ args = argparse.Namespace(
+ issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None
+ )
self.mock_gh.get_issue_body.return_value = "No backports here"
result = ProcessBackports(args, self.mock_git, self.mock_gh).run()
@@ -30,7 +33,9 @@ def test_process_backports_no_pending(self):
@patch("tools.private.release.process_backports.datetime")
def test_process_backports_success(self, mock_datetime):
mock_datetime.date.today.return_value = datetime.date(2026, 7, 1)
- args = MagicMock(issue=123, remote="origin", dry_run=False)
+ args = argparse.Namespace(
+ issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None
+ )
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
self.mock_gh.get_issue_body.return_value = """
## Checklist
@@ -83,7 +88,9 @@ def mock_resolve(items):
@patch("tools.private.release.process_backports.datetime")
def test_process_backports_dry_run(self, mock_datetime):
mock_datetime.date.today.return_value = datetime.date(2026, 7, 1)
- args = MagicMock(issue=123, remote="origin", dry_run=True)
+ args = argparse.Namespace(
+ issue=123, remote="origin", dry_run=True, add=None, triggering_comment=None
+ )
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
self.mock_gh.get_issue_body.return_value = """
## Checklist
@@ -131,7 +138,9 @@ def mock_resolve(items):
self.mock_gh.update_issue_body.assert_not_called()
def test_process_backports_ignored_and_failed_states(self):
- args = MagicMock(issue=123, remote="origin", dry_run=False)
+ args = argparse.Namespace(
+ issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None
+ )
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
self.mock_gh.get_issue_body.return_value = """
## Checklist
@@ -170,7 +179,9 @@ def mock_resolve(items):
self.mock_git.cherry_pick.assert_not_called()
def test_process_backports_ignored_error_status(self):
- args = MagicMock(issue=123, remote="origin", dry_run=False)
+ args = argparse.Namespace(
+ issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None
+ )
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
self.mock_gh.get_issue_body.return_value = """
## Checklist
@@ -193,7 +204,9 @@ def test_process_backports_ignored_error_status(self):
@patch("tools.private.release.process_backports.datetime")
def test_process_backports_cherry_pick_failed(self, mock_datetime):
mock_datetime.date.today.return_value = datetime.date(2026, 7, 1)
- args = MagicMock(issue=123, remote="origin", dry_run=False)
+ args = argparse.Namespace(
+ issue=123, remote="origin", dry_run=False, add=None, triggering_comment=None
+ )
self.mock_gh.get_issue_title.return_value = "Release 2.0.0"
self.mock_gh.get_issue_body.return_value = """
## Checklist
diff --git a/tests/tools/private/release/promote_rc_test.py b/tests/tools/private/release/promote_rc_test.py
index 71980dc1fd..68797e2e1e 100644
--- a/tests/tools/private/release/promote_rc_test.py
+++ b/tests/tools/private/release/promote_rc_test.py
@@ -1,5 +1,6 @@
+import argparse
import unittest
-from unittest.mock import MagicMock, call, patch
+from unittest.mock import call, patch
from tests.tools.private.release.release_test_helper import _mock_git_and_gh
from tools.private.release.gh import NoTrackingIssueError
@@ -12,7 +13,9 @@ def setUp(self):
def test_promote_rc_success(self):
# Arrange
- args = MagicMock(version="2.0.0", issue=123, dry_run=False, remote="my-remote")
+ args = argparse.Namespace(
+ version="2.0.0", issue=123, dry_run=False, remote="my-remote"
+ )
self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"]
self.mock_git.get_commit_sha.return_value = "abcdef123456"
self.mock_git.tag_exists.return_value = False
@@ -24,8 +27,15 @@ def test_promote_rc_success(self):
# Assert
self.assertEqual(result, 0)
- self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True)
- self.mock_git.get_commit_sha.assert_called_once_with("2.0.0-rc1")
+ self.mock_git.fetch.assert_has_calls(
+ [
+ call("my-remote", tags=True, force=True),
+ call("my-remote", refspec="release/2.0"),
+ ]
+ )
+ self.mock_git.get_commit_sha.assert_has_calls(
+ [call("2.0.0-rc1"), call(remote_ref="my-remote/release/2.0")]
+ )
self.mock_git.checkout.assert_not_called()
self.mock_git.tag_exists.assert_called_once_with("2.0.0")
self.mock_git.tag.assert_called_once_with("2.0.0", "abcdef123456")
@@ -48,7 +58,9 @@ def test_promote_rc_success(self):
def test_promote_rc_resolve_issue_success(self):
# Arrange
- args = MagicMock(version="2.0.0", issue=None, dry_run=False, remote="my-remote")
+ args = argparse.Namespace(
+ version="2.0.0", issue=None, dry_run=False, remote="my-remote"
+ )
self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"]
self.mock_git.tag_exists.return_value = False
self.mock_gh.get_release_tracking_issue.side_effect = None
@@ -62,9 +74,16 @@ def test_promote_rc_resolve_issue_success(self):
# Assert
self.assertEqual(result, 0)
- self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True)
+ self.mock_git.fetch.assert_has_calls(
+ [
+ call("my-remote", tags=True, force=True),
+ call("my-remote", refspec="release/2.0"),
+ ]
+ )
self.mock_gh.get_release_tracking_issue.assert_called_once_with("2.0.0")
- self.mock_git.get_commit_sha.assert_called_once_with("2.0.0-rc1")
+ self.mock_git.get_commit_sha.assert_has_calls(
+ [call("2.0.0-rc1"), call(remote_ref="my-remote/release/2.0")]
+ )
self.mock_git.checkout.assert_not_called()
self.mock_git.tag.assert_called_once_with("2.0.0", "abcdef123456")
self.mock_git.push.assert_called_once_with("my-remote", "2.0.0")
@@ -82,11 +101,12 @@ def test_promote_rc_resolve_issue_success(self):
)
self.mock_gh.post_issue_comment.assert_called_once_with(123, expected_comment)
- def test_promote_rc_defaults_to_determine_next_version(self):
+ def test_promote_rc_resolves_version_from_issue(self):
# Arrange
- args = MagicMock(version=None, issue=123, dry_run=False, remote="my-remote")
- self.mock_git.get_current_branch.return_value = "release/2.0"
- self.mock_git.get_tags.return_value = ["2.0.0"]
+ args = argparse.Namespace(
+ version=None, issue=123, dry_run=False, remote="my-remote"
+ )
+ self.mock_gh.get_issue_title.return_value = "Release 2.0.1"
self.mock_git.get_remote_tags.return_value = ["2.0.1-rc0"]
self.mock_git.get_commit_sha.return_value = "12345678"
self.mock_git.tag_exists.return_value = False
@@ -98,13 +118,20 @@ def test_promote_rc_defaults_to_determine_next_version(self):
# Assert
self.assertEqual(result, 0)
- self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True)
- self.mock_git.get_current_branch.assert_called_once()
- self.mock_git.get_tags.assert_called_once()
+ self.mock_git.fetch.assert_has_calls(
+ [
+ call("my-remote", tags=True, force=True),
+ call("my-remote", refspec="release/2.0"),
+ ]
+ )
+ self.mock_git.get_current_branch.assert_not_called()
+ self.mock_git.get_tags.assert_not_called()
self.mock_git.get_remote_tags.assert_called_once_with("my-remote")
self.mock_git.checkout.assert_not_called()
- self.mock_git.get_commit_sha.assert_called_once_with("2.0.1-rc0")
+ self.mock_git.get_commit_sha.assert_has_calls(
+ [call("2.0.1-rc0"), call(remote_ref="my-remote/release/2.0")]
+ )
self.mock_git.tag.assert_called_once_with("2.0.1", "12345678")
self.mock_git.push.assert_called_once_with("my-remote", "2.0.1")
@@ -124,7 +151,9 @@ def test_promote_rc_defaults_to_determine_next_version(self):
@patch("builtins.print")
def test_promote_rc_dry_run_success(self, mock_print):
# Arrange
- args = MagicMock(version="2.0.0", issue=123, dry_run=True, remote="my-remote")
+ args = argparse.Namespace(
+ version="2.0.0", issue=123, dry_run=True, remote="my-remote"
+ )
self.mock_git.get_remote_tags.return_value = ["2.0.0-rc0", "2.0.0-rc1"]
self.mock_git.get_commit_sha.return_value = "abcdef123456"
self.mock_git.tag_exists.return_value = False
@@ -136,8 +165,15 @@ def test_promote_rc_dry_run_success(self, mock_print):
# Assert
self.assertEqual(result, 0)
- self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True)
- self.mock_git.get_commit_sha.assert_called_once_with("2.0.0-rc1")
+ self.mock_git.fetch.assert_has_calls(
+ [
+ call("my-remote", tags=True, force=True),
+ call("my-remote", refspec="release/2.0"),
+ ]
+ )
+ self.mock_git.get_commit_sha.assert_has_calls(
+ [call("2.0.0-rc1"), call(remote_ref="my-remote/release/2.0")]
+ )
self.mock_git.tag_exists.assert_called_once_with("2.0.0")
# Core dry-run assertions: NO modifications
@@ -149,6 +185,7 @@ def test_promote_rc_dry_run_success(self, mock_print):
mock_print.assert_has_calls(
[
call("Verifying tracking issue #123 format..."),
+ call("Fetching remote branch my-remote/release/2.0..."),
call(
"[DRY RUN] Pre-conditions passed successfully for promoting"
" 2.0.0-rc1 to 2.0.0."
@@ -162,7 +199,9 @@ def test_promote_rc_dry_run_success(self, mock_print):
def test_promote_rc_tag_already_exists(self):
# Arrange
- args = MagicMock(version="2.0.0", issue=123, remote="my-remote")
+ args = argparse.Namespace(
+ version="2.0.0", issue=123, dry_run=False, remote="my-remote"
+ )
self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"]
self.mock_git.tag_exists.return_value = True
@@ -179,7 +218,9 @@ def test_promote_rc_tag_already_exists(self):
def test_promote_rc_issue_not_found(self):
# Arrange
- args = MagicMock(version="2.0.0", issue=None, remote="my-remote")
+ args = argparse.Namespace(
+ version="2.0.0", issue=None, dry_run=False, remote="my-remote"
+ )
self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"]
self.mock_git.tag_exists.return_value = False
self.mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError(
@@ -199,7 +240,9 @@ def test_promote_rc_issue_not_found(self):
def test_promote_rc_issue_malformed(self):
# Arrange
- args = MagicMock(version="2.0.0", issue=123, remote="my-remote")
+ args = argparse.Namespace(
+ version="2.0.0", issue=123, dry_run=False, remote="my-remote"
+ )
self.mock_git.get_remote_tags.return_value = ["2.0.0-rc1"]
self.mock_git.tag_exists.return_value = False
self.mock_git.get_commit_sha.return_value = "abcdef123456"
@@ -219,7 +262,9 @@ def test_promote_rc_issue_malformed(self):
def test_promote_rc_no_rc_found(self):
# Arrange
- args = MagicMock(version="2.0.0", issue=123, remote="my-remote")
+ args = argparse.Namespace(
+ version="2.0.0", issue=123, dry_run=False, remote="my-remote"
+ )
self.mock_git.get_remote_tags.return_value = []
# Act
diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py
index 8b938aecf6..e670fc98df 100644
--- a/tools/private/release/gh.py
+++ b/tools/private/release/gh.py
@@ -7,6 +7,17 @@
from tools.private.release.release_issue import BackportTask
from tools.private.release.shell import run_cmd
+# GitHub reaction types
+# See: https://docs.github.com/en/rest/reactions/reactions?apiVersion=2022-11-28#about-reactions
+GH_REACTION_THUMBS_UP = "+1"
+GH_REACTION_THUMBS_DOWN = "-1"
+GH_REACTION_LAUGH = "laugh"
+GH_REACTION_CONFUSED = "confused"
+GH_REACTION_HEART = "heart"
+GH_REACTION_HOORAY = "hooray"
+GH_REACTION_ROCKET = "rocket"
+GH_REACTION_EYES = "eyes"
+
class MultipleTrackingIssuesError(ValueError):
"""Raised when multiple open tracking issues are found for a version."""
@@ -319,6 +330,28 @@ def post_issue_comment(self, issue_num: int, comment_body: str) -> None:
capture_output=False,
)
+ def add_comment_reaction(self, comment_id: int, reaction: str) -> None:
+ """Adds a reaction to a comment.
+
+ Args:
+ comment_id: The ID of the comment.
+ reaction: The reaction type (e.g. '+1', '-1', 'eyes', etc).
+ """
+ path = f"/repos/{self.repo}/issues/comments/{comment_id}/reactions"
+ self._run_gh(
+ "api",
+ "--method",
+ "POST",
+ "-H",
+ "Accept: application/vnd.github+json",
+ "-H",
+ "X-GitHub-Api-Version: 2022-11-28",
+ path,
+ "-f",
+ f"content={reaction}",
+ capture_output=False,
+ )
+
def get_merge_commits_for_prs(
self, pending_items: list[BackportTask]
) -> list[BackportTask]:
diff --git a/tools/private/release/process_backports.py b/tools/private/release/process_backports.py
index 83394bd27e..f0308d7847 100644
--- a/tools/private/release/process_backports.py
+++ b/tools/private/release/process_backports.py
@@ -5,15 +5,17 @@
from typing import Any
from tools.private.release import changelog_news
-from tools.private.release.gh import GitHub
+from tools.private.release.gh import GH_REACTION_THUMBS_DOWN, GitHub
from tools.private.release.git import Git
from tools.private.release.release_issue import (
RELEASE_TITLE_RE,
+ add_backports_to_body,
parse_backports,
update_task_in_body,
)
from tools.private.release.utils import (
get_latest_rc_tag,
+ parse_pr_list,
replace_version_next,
)
@@ -178,7 +180,46 @@ def _cherry_pick_and_update_prs(
def run(self) -> int:
"""Executes the process-backports subcommand."""
args = self.args
+ exit_code = 0
+ try:
+ exit_code = self._run_internal()
+ except Exception as e:
+ print(f"Unexpected error: {e}")
+ exit_code = 1
+
+ if exit_code != 0 and args.triggering_comment:
+ print(f"Reacting with thumbs-down to comment {args.triggering_comment}...")
+ try:
+ self.gh.add_comment_reaction(
+ args.triggering_comment, GH_REACTION_THUMBS_DOWN
+ )
+ except Exception as e:
+ print(f"Failed to add reaction to comment: {e}")
+
+ return exit_code
+
+ def _run_internal(self) -> int:
+ """Internal implementation of process-backports."""
+ args = self.args
body = self.gh.get_issue_body(args.issue)
+
+ if args.add:
+ print(f"Adding backports {args.add} to tracking issue #{args.issue}...")
+ try:
+ body = add_backports_to_body(body, args.add)
+ except ValueError as e:
+ print(f"Error: {e}")
+ return 1
+
+ if not args.dry_run:
+ self.gh.update_issue_body(args.issue, body)
+ print("Successfully updated tracking issue checklist.")
+ else:
+ print(
+ "[DRY RUN] Would update tracking issue checklist with new"
+ " backports."
+ )
+
items = parse_backports(body)
pending_items = [
@@ -295,6 +336,16 @@ def add_parser(cls, subparsers):
required=True,
help="The git remote to push changes to (required).",
)
+ parser.add_argument(
+ "--add",
+ type=parse_pr_list,
+ help="PR numbers (comma or space separated) to add before processing.",
+ )
+ parser.add_argument(
+ "--triggering-comment",
+ type=int,
+ help="The ID of the comment that triggered this run (optional).",
+ )
parser.add_argument(
"--dry-run",
action=argparse.BooleanOptionalAction,
diff --git a/tools/private/release/promote_rc.py b/tools/private/release/promote_rc.py
index 80b4ac8065..ac3a841526 100644
--- a/tools/private/release/promote_rc.py
+++ b/tools/private/release/promote_rc.py
@@ -5,7 +5,10 @@
from tools.private.release.gh import GitHub
from tools.private.release.git import Git
-from tools.private.release.release_issue import update_task_in_body
+from tools.private.release.release_issue import (
+ RELEASE_TITLE_RE,
+ update_task_in_body,
+)
from tools.private.release.utils import (
REPO_URL,
determine_next_version,
@@ -30,7 +33,23 @@ def run(self) -> int:
version = args.version
if version is None:
- version = determine_next_version()
+ if args.issue:
+ issue_title = self.gh.get_issue_title(args.issue)
+ version_match = RELEASE_TITLE_RE.search(issue_title)
+ if version_match:
+ version = version_match.group(1)
+ print(
+ f"Resolved version {version} from tracking issue"
+ f" #{args.issue} title."
+ )
+ else:
+ print(
+ f"Error: Could not parse version from issue title:"
+ f" {issue_title}"
+ )
+ return 1
+ else:
+ version = determine_next_version()
latest_rc = get_latest_rc_tag(version, remote=args.remote)
if not latest_rc:
@@ -57,9 +76,56 @@ def run(self) -> int:
# Get commit SHA of the RC tag (which will be the same for the final tag)
commit_sha = self.git.get_commit_sha(latest_rc)
- # Verify issue is in the right format by trying to prepare the update
+ # Verify issue can be found and read it early
print(f"Verifying tracking issue #{issue_num} format...")
body = self.gh.get_issue_body(issue_num)
+
+ # Determine release branch name and verify it matches the RC tag commit
+ branch_version = ".".join(version.split(".")[:2])
+ branch_name = f"release/{branch_version}"
+ remote_branch = f"{args.remote}/{branch_name}"
+
+ print(f"Fetching remote branch {remote_branch}...")
+ self.git.fetch(args.remote, refspec=branch_name)
+ try:
+ branch_sha = self.git.get_commit_sha(remote_ref=remote_branch)
+ except Exception as e:
+ print(
+ f"Error: Could not get commit SHA for remote branch"
+ f" {remote_branch}: {e}"
+ )
+ return 1
+
+ if commit_sha != branch_sha:
+ print(
+ f"Error: The latest RC tag {latest_rc} ({commit_sha[:8]}) is not at"
+ f" the head of release branch {remote_branch} ({branch_sha[:8]})."
+ )
+ metadata = {
+ "status": "error-rc-tag-not-branch-head",
+ "rc": latest_rc,
+ "branch_commit": branch_sha[:8],
+ "tag_commit": commit_sha[:8],
+ }
+ try:
+ updated_body = update_task_in_body(
+ body, "Tag Final", checked=False, metadata=metadata
+ )
+ except ValueError as e:
+ print(f"Error: Tracking issue #{issue_num} is malformed: {e}")
+ return 1
+
+ if not args.dry_run:
+ self.gh.update_issue_body(issue_num, updated_body)
+ print(f"Updated tracking issue #{issue_num} with error status.")
+ else:
+ print(
+ f"[DRY RUN] Would update tracking issue #{issue_num} with"
+ f" error status."
+ )
+ return 1
+
+ # Verify issue is in the right format by trying to prepare the update (for success case)
metadata = {"status": "done", "tag": version, "commit": commit_sha[:8]}
try:
updated_body = update_task_in_body(
diff --git a/tools/private/release/release_issue.py b/tools/private/release/release_issue.py
index 32e0a0fee7..75d00759ec 100644
--- a/tools/private/release/release_issue.py
+++ b/tools/private/release/release_issue.py
@@ -115,11 +115,11 @@ def format_metadata_line(checked, name, metadata):
metadata_pairs = []
for k, v in metadata.items():
- if k == "commit":
- # The 'commit' key is special-cased with a space after '=' so that
- # GitHub autolinks the commit SHA. Autolinking requires certain
- # characters to precede the value.
- metadata_pairs.append(f"commit= {v}")
+ if k == "commit" or k.endswith("_commit"):
+ # The 'commit' key (and keys ending with '_commit') is special-cased with
+ # a space after '=' so that GitHub autolinks the commit SHA. Autolinking
+ # requires certain characters to precede the value.
+ metadata_pairs.append(f"{k}= {v}")
else:
metadata_pairs.append(f"{k}={v}")
metadata_str = " ".join(metadata_pairs)
@@ -245,3 +245,42 @@ def parse_backports(body):
)
)
return items
+
+
+def add_backports_to_body(body: str, prs: list[int]) -> str:
+ """Adds new backport checklist items to the ## Backports section."""
+ body = body.replace("\r\n", "\n")
+ # Find the Backports section
+ pattern = r"(## Backports\n)(.*?)(?=\n##|\n---|\Z)"
+ match = re.search(pattern, body, re.DOTALL | re.IGNORECASE)
+ if not match:
+ raise ValueError("Could not find '## Backports' section in issue body.")
+
+ section_content = match.group(2)
+
+ # Parse existing backports to avoid duplicates
+ existing_items = parse_backports(body)
+ existing_prs = {
+ int(item.pr_ref.lstrip("#"))
+ for item in existing_items
+ if item.pr_ref.startswith("#")
+ }
+
+ new_lines = []
+ for pr in prs:
+ if pr in existing_prs:
+ print(f"PR #{pr} is already in the backports list. Skipping.")
+ continue
+ new_lines.append(f"- [ ] #{pr}")
+
+ if not new_lines:
+ return body
+
+ # Append new lines to the section content.
+ section_content_clean = section_content.rstrip("\n")
+ separator = "\n" if section_content_clean else ""
+ updated_section = section_content_clean + separator + "\n".join(new_lines) + "\n\n"
+
+ # Replace the old section with the updated one
+ start, end = match.span(2)
+ return body[:start] + updated_section + body[end:]
diff --git a/tools/private/release/utils.py b/tools/private/release/utils.py
index e774c80134..db7b96a378 100644
--- a/tools/private/release/utils.py
+++ b/tools/private/release/utils.py
@@ -172,3 +172,25 @@ def replace_version_next(version):
new_content = new_content.replace("VERSION_NEXT_PATCH", version)
with open(filepath, "w") as f:
f.write(new_content)
+
+
+def parse_pr_list(value: str) -> list[int]:
+ """Parses a comma or space separated list of PR numbers.
+
+ PR numbers can optionally be prefixed with '#'.
+ """
+ if not value:
+ return []
+ # Split by space and/or comma
+ pr_strings = [p for p in re.split(r"[\s,]+", value.strip()) if p]
+ prs = []
+ for pr_str in pr_strings:
+ clean_pr_str = pr_str.lstrip("#")
+ try:
+ prs.append(int(clean_pr_str))
+ except ValueError as e:
+ raise argparse.ArgumentTypeError(
+ f"Invalid PR reference '{pr_str}'. Must be integer optionally"
+ f" prefixed with '#'."
+ ) from e
+ return prs