diff --git a/.github/workflows/on_issue_comment.yaml b/.github/workflows/on_issue_comment.yaml index 02af5272e6..14248d0d7a 100644 --- a/.github/workflows/on_issue_comment.yaml +++ b/.github/workflows/on_issue_comment.yaml @@ -73,6 +73,7 @@ jobs: uses: ./.github/workflows/release_create_rc.yaml with: issue: ${{ needs.parse_comment.outputs.issue_number }} + comment_id: "${{ github.event.comment.id }}" secrets: inherit call_prepare: diff --git a/.github/workflows/release_create_rc.yaml b/.github/workflows/release_create_rc.yaml index 2c48dd8476..a96a04a0ec 100644 --- a/.github/workflows/release_create_rc.yaml +++ b/.github/workflows/release_create_rc.yaml @@ -7,12 +7,20 @@ on: description: 'The Release Tracking Issue Number (e.g., 142)' required: true 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 + comment_id: + description: 'The ID of the comment that triggered this run (optional)' + required: false + type: string permissions: contents: write @@ -44,9 +52,14 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE: ${{ inputs.issue }} + COMMENT_ID: ${{ inputs.comment_id }} run: | + ARGS=() + if [ -n "$COMMENT_ID" ]; then + ARGS+=("--triggering-comment=$COMMENT_ID") + fi bazel run //tools/private/release -- \ - create-rc --issue "$ISSUE" --remote origin + create-rc --issue "$ISSUE" --remote origin "${ARGS[@]}" call_release: needs: tag_rc diff --git a/RELEASING.md b/RELEASING.md index 528450f611..7846cef883 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -33,8 +33,9 @@ Release Tracking Issue and automated workflows triggered by comments or issue ed 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. +4. **Create an RC**: Comment `/create-rc` on the tracking issue. This will + automatically process pending backports before creating the RC. If any + backport fails, the RC creation will abort. 5. **Iterate**: Repeat steps 3 and 4 until backports and RCs are no longer needed. diff --git a/news/process-backports-before-rc.changed.md b/news/process-backports-before-rc.changed.md new file mode 100644 index 0000000000..5591b700ce --- /dev/null +++ b/news/process-backports-before-rc.changed.md @@ -0,0 +1,3 @@ +Changed `/create-rc` command to automatically process pending backports before +creating the RC, and react with a thumbs-down emoji on failure if triggered by +a comment. diff --git a/tests/tools/private/release/create_rc_test.py b/tests/tools/private/release/create_rc_test.py index 0937dfbac6..06cdb77345 100644 --- a/tests/tools/private/release/create_rc_test.py +++ b/tests/tools/private/release/create_rc_test.py @@ -253,6 +253,130 @@ def test_create_rc_auto_add_task(self): call2_args[1], ) + @patch("tools.private.release.create_rc.ProcessBackports") + def test_create_rc_calls_process_backports(self, mock_pb_class): + # Arrange + mock_pb = mock_pb_class.return_value + mock_pb.run.return_value = 0 + + 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 +- [ ] Tag RC0 | status=pending +""" + self.mock_git.get_remote_tags.return_value = [] + self.mock_git.get_commit_sha.return_value = "1234567890" + + # Act + result = CreateRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + mock_pb_class.assert_called_once() + called_args = mock_pb_class.call_args[0][0] + self.assertEqual(called_args.issue, 123) + self.assertEqual(called_args.remote, "my-remote") + self.assertFalse(called_args.dry_run) + self.assertIsNone(called_args.add) + self.assertIsNone(called_args.triggering_comment) + mock_pb.run.assert_called_once() + + @patch("tools.private.release.create_rc.ProcessBackports") + def test_create_rc_aborts_on_process_backports_failure(self, mock_pb_class): + # Arrange + mock_pb = mock_pb_class.return_value + mock_pb.run.return_value = 1 + + args = MagicMock(issue=123, remote="my-remote") + + # Act + result = CreateRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 1) + mock_pb_class.assert_called_once() + mock_pb.run.assert_called_once() + self.mock_gh.get_issue_body.assert_not_called() + self.mock_git.tag.assert_not_called() + + @patch("tools.private.release.create_rc.ProcessBackports") + def test_create_rc_failure_reacts_to_comment(self, mock_pb_class): + # Arrange + mock_pb = mock_pb_class.return_value + mock_pb.run.return_value = 1 # Simulate failure + + args = MagicMock(issue=123, remote="my-remote", triggering_comment=456) + + # Act + result = CreateRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 1) + self.mock_gh.add_comment_reaction.assert_called_once_with(456, "-1") + + @patch("tools.private.release.create_rc.ProcessBackports") + def test_create_rc_failure_no_comment_no_reaction(self, mock_pb_class): + # Arrange + mock_pb = mock_pb_class.return_value + mock_pb.run.return_value = 1 # Simulate failure + + args = MagicMock(issue=123, remote="my-remote", triggering_comment=None) + + # Act + result = CreateRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 1) + self.mock_gh.add_comment_reaction.assert_not_called() + + @patch("tools.private.release.create_rc.ProcessBackports") + def test_create_rc_success_with_comment_no_reaction(self, mock_pb_class): + # Arrange + mock_pb = mock_pb_class.return_value + mock_pb.run.return_value = 0 + + args = MagicMock(issue=123, remote="my-remote", triggering_comment=456) + 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 +- [ ] Tag RC0 | status=pending +""" + self.mock_git.get_remote_tags.return_value = [] + self.mock_git.get_commit_sha.return_value = "1234567890" + + # Act + result = CreateRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 0) + self.mock_gh.add_comment_reaction.assert_not_called() + + @patch("tools.private.release.create_rc.ProcessBackports") + def test_create_rc_precondition_failure_reacts_to_comment(self, mock_pb_class): + # Arrange + mock_pb = mock_pb_class.return_value + mock_pb.run.return_value = 0 # Backports succeed + + args = MagicMock(issue=123, remote="my-remote", triggering_comment=456) + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release | status=pending +- [ ] Create Release branch | status=pending +- [ ] Tag RC0 | status=pending +""" + + # Act + result = CreateRc(args, self.mock_git, self.mock_gh).run() + + # Assert + self.assertEqual(result, 1) + self.mock_gh.add_comment_reaction.assert_called_once_with(456, "-1") + if __name__ == "__main__": unittest.main() diff --git a/tools/private/release/create_rc.py b/tools/private/release/create_rc.py index abc91a8273..0916377925 100644 --- a/tools/private/release/create_rc.py +++ b/tools/private/release/create_rc.py @@ -1,7 +1,11 @@ """Subcommand to tag and push the next release candidate.""" -from tools.private.release.gh import GitHub +import traceback +from argparse import Namespace + +from tools.private.release.gh import GH_REACTION_THUMBS_DOWN, GitHub from tools.private.release.git import Git +from tools.private.release.process_backports import ProcessBackports from tools.private.release.release_issue import ( RELEASE_TITLE_RE, add_rc_task_to_body, @@ -26,6 +30,44 @@ def __init__(self, args, git: Git, gh: GitHub): def run(self) -> int: """Executes the create-rc subcommand.""" args = self.args + exit_code = 0 + try: + exit_code = self._run_internal() + except Exception as e: + print(f"Unexpected error: {e}") + traceback.print_exc() + 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 create-rc.""" + args = self.args + + # Try to process pending backports first + print("Processing pending backports before creating RC...") + backport_args = Namespace( + issue=args.issue, + remote=args.remote, + add=None, + triggering_comment=None, + dry_run=False, + ) + pb = ProcessBackports(backport_args, self.git, self.gh) + backports_exit_code = pb.run() + if backports_exit_code != 0: + print("Error: Processing backports failed. Aborting RC creation.") + return backports_exit_code + body = self.gh.get_issue_body(args.issue) state = parse_checklist_state(body) @@ -153,6 +195,11 @@ def add_parser(cls, subparsers): required=True, help="The git remote to push the RC tag to (required).", ) + parser.add_argument( + "--triggering-comment", + type=int, + help="The ID of the comment that triggered this run (optional).", + ) parser.set_defaults(command=cls.run_from_args) @classmethod