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