From ad92d0350f5f5354c1f3e1f27b39ca7c63ed95b9 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 17 Jul 2026 00:03:19 +0000 Subject: [PATCH 1/3] Automate multi-version backports and support direct patch promotion Added backport-prepare and backport-create-releases commands to automate backporting PRs to multiple active release branches. Renamed and refactored promote-rc to promote to support direct tagging of patch releases on the branch head, bypassing RC verification. --- .../workflows/backport_create_releases.yaml | 47 +++ .github/workflows/backport_prepare.yaml | 76 +++++ .github/workflows/on_comment.yaml | 96 ++++-- ...e_promote_rc.yaml => release_promote.yaml} | 4 +- RELEASING.md | 64 +++- tests/tools/private/release/BUILD.bazel | 23 +- .../release/backport_create_releases_test.py | 176 ++++++++++ .../private/release/backport_prepare_test.py | 216 +++++++++++++ .../{promote_rc_test.py => promote_test.py} | 42 ++- tests/tools/private/release/release_test.py | 4 +- .../private/release/release_test_helper.py | 44 ++- tools/private/release/BUILD.bazel | 13 +- .../release/backport_create_releases.py | 237 ++++++++++++++ tools/private/release/backport_prepare.py | 305 ++++++++++++++++++ tools/private/release/create_release_issue.py | 8 + tools/private/release/gh.py | 55 +++- tools/private/release/git.py | 22 ++ tools/private/release/mock_gh.py | 84 +++++ .../release/{promote_rc.py => promote.py} | 93 +++--- tools/private/release/release.py | 8 +- 20 files changed, 1495 insertions(+), 122 deletions(-) create mode 100644 .github/workflows/backport_create_releases.yaml create mode 100644 .github/workflows/backport_prepare.yaml rename .github/workflows/{release_promote_rc.yaml => release_promote.yaml} (94%) create mode 100644 tests/tools/private/release/backport_create_releases_test.py create mode 100644 tests/tools/private/release/backport_prepare_test.py rename tests/tools/private/release/{promote_rc_test.py => promote_test.py} (90%) create mode 100644 tools/private/release/backport_create_releases.py create mode 100644 tools/private/release/backport_prepare.py create mode 100644 tools/private/release/mock_gh.py rename tools/private/release/{promote_rc.py => promote.py} (75%) diff --git a/.github/workflows/backport_create_releases.yaml b/.github/workflows/backport_create_releases.yaml new file mode 100644 index 0000000000..346bfdbbdb --- /dev/null +++ b/.github/workflows/backport_create_releases.yaml @@ -0,0 +1,47 @@ +name: "Backport: Create Releases" + +on: + workflow_dispatch: + inputs: + issue: + description: 'The Backport Tracking Issue Number' + required: true + type: string + workflow_call: + inputs: + issue: + description: 'The Backport Tracking Issue Number' + required: true + type: string + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + backport_create_releases: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Bazel + uses: bazel-contrib/setup-bazel@0.19.0 + with: + bazelisk-version: 1.20.0 + + - name: Configure Git Identity + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Run Backport Create Releases Pipeline + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE: ${{ inputs.issue }} + run: | + bazel run //tools/private/release -- \ + backport-create-releases --issue=$ISSUE --no-dry-run diff --git a/.github/workflows/backport_prepare.yaml b/.github/workflows/backport_prepare.yaml new file mode 100644 index 0000000000..587c0fc357 --- /dev/null +++ b/.github/workflows/backport_prepare.yaml @@ -0,0 +1,76 @@ +name: "Backport: Prepare" + +on: + workflow_dispatch: + inputs: + issue: + description: 'The Backport Tracking Issue Number' + required: false + type: string + pr: + description: 'PR to backport (e.g. #123) (if no issue)' + required: false + type: string + from_minor: + description: 'From minor version (e.g. 1.7) (if no issue)' + required: false + type: string + to_minor: + description: 'To minor version (e.g. 1.9) (if no issue, optional)' + required: false + type: string + workflow_call: + inputs: + issue: + description: 'The Backport Tracking Issue Number' + required: false + type: string + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + backport_prepare: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Bazel + uses: bazel-contrib/setup-bazel@0.19.0 + with: + bazelisk-version: 1.20.0 + + - name: Configure Git Identity + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Run Backport Preparation Pipeline + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE: ${{ inputs.issue }} + PR: ${{ inputs.pr }} + FROM_MINOR: ${{ inputs.from_minor }} + TO_MINOR: ${{ inputs.to_minor }} + run: | + ARGS=() + if [ -n "$ISSUE" ]; then + ARGS+=("--issue=$ISSUE") + else + if [ -n "$PR" ]; then + ARGS+=("--pr=$PR") + fi + if [ -n "$FROM_MINOR" ]; then + ARGS+=("--from-minor=$FROM_MINOR") + fi + if [ -n "$TO_MINOR" ]; then + ARGS+=("--to-minor=$TO_MINOR") + fi + fi + bazel run //tools/private/release -- \ + backport-prepare "${ARGS[@]}" --no-dry-run diff --git a/.github/workflows/on_comment.yaml b/.github/workflows/on_comment.yaml index ed7813adac..05074b10ee 100644 --- a/.github/workflows/on_comment.yaml +++ b/.github/workflows/on_comment.yaml @@ -36,45 +36,67 @@ jobs: IS_PR: "${{ github.event.issue.pull_request != null }}" EVENT_NUMBER: "${{ github.event.issue.number }}" HAS_RELEASE_LABEL: "${{ contains(github.event.issue.labels.*.name, 'type: release') }}" + HAS_BACKPORT_LABEL: "${{ contains(github.event.issue.labels.*.name, 'type: backport-pr') }}" GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - if [ "$IS_PR" = "false" ] && [ "$HAS_RELEASE_LABEL" = "true" ]; then + if [ "$IS_PR" = "false" ]; then + # Set issue number for non-PR issues (release or backport tracking issues) issue_number=$EVENT_NUMBER - if echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/create-rc([[:space:]]|$)'; then - echo "command=create-rc" >> "$GITHUB_OUTPUT" - echo "issue_number=$issue_number" >> "$GITHUB_OUTPUT" - elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/prepare([[:space:]]|$)'; then - echo "command=prepare" >> "$GITHUB_OUTPUT" - echo "issue_number=$issue_number" >> "$GITHUB_OUTPUT" - elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/process-backports([[:space:]]|$)'; then - echo "command=process-backports" >> "$GITHUB_OUTPUT" - echo "issue_number=$issue_number" >> "$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:]]*//') - args=$(echo "$args" | sed -e 's/^[[:space:],]*//' -e 's/[[:space:],]*$//') - csv=$(echo "$args" | sed -E 's/[[:space:],]+/ /g' | tr ' ' ',') - if [ -n "$csv" ]; then - echo "command=add-backports" >> "$GITHUB_OUTPUT" - echo "backports=$csv" >> "$GITHUB_OUTPUT" - echo "issue_number=$issue_number" >> "$GITHUB_OUTPUT" + echo "issue_number=$issue_number" >> "$GITHUB_OUTPUT" + echo "issue_number=$issue_number" >> "$GITHUB_ENV" + + # Check if it's a release tracking issue + if [ "$HAS_RELEASE_LABEL" = "true" ]; then + # Handle /create-rc comment + if echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/create-rc([[:space:]]|$)'; then + echo "command=create-rc" >> "$GITHUB_OUTPUT" + # Handle /prepare comment + elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/prepare([[:space:]]|$)'; then + echo "command=prepare" >> "$GITHUB_OUTPUT" + # Handle /process-backports comment + elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/process-backports([[:space:]]|$)'; then + echo "command=process-backports" >> "$GITHUB_OUTPUT" + # Handle /add-backports comment + 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:]]*//') + args=$(echo "$args" | sed -e 's/^[[:space:],]*//' -e 's/[[:space:],]*$//') + 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 + # Handle /promote comment + elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/promote([[:space:]]|$)'; then + echo "command=promote" >> "$GITHUB_OUTPUT" + else + echo "command=none" >> "$GITHUB_OUTPUT" + fi + # Check if it's a backport tracking issue + elif [ "$HAS_BACKPORT_LABEL" = "true" ]; then + # Handle /prepare comment for backports + if echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/prepare([[:space:]]|$)'; then + echo "command=backport-prepare" >> "$GITHUB_OUTPUT" + # Handle /create-releases comment for backports + elif echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/create-releases([[:space:]]|$)'; then + echo "command=backport-create-releases" >> "$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" - echo "issue_number=$issue_number" >> "$GITHUB_OUTPUT" else echo "command=none" >> "$GITHUB_OUTPUT" fi elif [ "$IS_PR" = "true" ]; then pr_number=$EVENT_NUMBER + # Handle /backport comment on PR if echo "$COMMENT_BODY" | grep -qE '^[[:space:]]*/backport([[:space:]]|$)'; then echo "command=pr-backport" >> "$GITHUB_OUTPUT" echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" @@ -134,7 +156,23 @@ jobs: call_promote: needs: parse_comment if: needs.parse_comment.outputs.command == 'promote' - uses: ./.github/workflows/release_promote_rc.yaml + uses: ./.github/workflows/release_promote.yaml + with: + issue: ${{ needs.parse_comment.outputs.issue_number }} + secrets: inherit + + call_backport_prepare: + needs: parse_comment + if: needs.parse_comment.outputs.command == 'backport-prepare' + uses: ./.github/workflows/backport_prepare.yaml + with: + issue: ${{ needs.parse_comment.outputs.issue_number }} + secrets: inherit + + call_backport_create_releases: + needs: parse_comment + if: needs.parse_comment.outputs.command == 'backport-create-releases' + uses: ./.github/workflows/backport_create_releases.yaml with: issue: ${{ needs.parse_comment.outputs.issue_number }} secrets: inherit \ No newline at end of file diff --git a/.github/workflows/release_promote_rc.yaml b/.github/workflows/release_promote.yaml similarity index 94% rename from .github/workflows/release_promote_rc.yaml rename to .github/workflows/release_promote.yaml index 399e7b1fa2..93ec23a882 100644 --- a/.github/workflows/release_promote_rc.yaml +++ b/.github/workflows/release_promote.yaml @@ -1,4 +1,4 @@ -name: "Release: Promote RC" +name: "Release: Promote" on: workflow_dispatch: @@ -60,7 +60,7 @@ jobs: ARGS+=("--remote=origin") ARGS+=("--no-dry-run") - bazel run //tools/private/release -- promote-rc "${ARGS[@]}" + bazel run //tools/private/release -- promote "${ARGS[@]}" publish: needs: promote diff --git a/RELEASING.md b/RELEASING.md index 2298b4c8a3..a03cd83652 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -123,7 +123,69 @@ If a backport fails to process (e.g., due to cherry-pick conflicts): 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 +## Automated patch releases to multiple versions + +If you need to backport a PR to multiple older active release branches (e.g., +backporting a fix to `1.7`, `1.8`, and `1.9` when the latest release is +`2.2.0`), you can automate the creation of release tracking issues and +verification of cherry-picks using a Backport Tracking Issue. + +### Steps + +1. **Create a Backport Tracking Issue**: Create a new issue on GitHub with + the label `type: backport-pr`. + * The title should describe the backport, e.g., `Backport: #1234` + (referencing the PR to backport). + * The body must contain the target range in the following format: + ```markdown + * PR: #1234 + * From version: 1.7 + * To version: 2.2 + ``` + This tells the tool to target all active release branches between + `release/1.7` and `release/2.2` inclusive. + +2. **Prepare the Backports**: Trigger the preparation by commenting `/prepare` + on the backport tracking issue, or by manually running the [Backport: + Prepare](https://github.com/bazel-contrib/rules_python/actions/workflows/backport_prepare.yaml) + workflow with the issue number. + * The workflow will checkout each release branch in the range, attempt to + cherry-pick the PR, and verify if the changelog can be updated. + * It will then update the backport tracking issue body with a list of + tasks: + * `Verify apply `: Automatically checked if the + cherry-pick succeeded, or left unchecked with + `status=failed-conflict` or `status=failed-changelog` if it failed. + * `Release issue `: A task to initiate the release for + each target version. + +3. **Resolve Conflicts (if any)**: If any `Verify apply` task failed: + * You must resolve the conflict manually on that specific release branch + (checkout branch, cherry-pick, resolve conflicts, push to remote). + * Once resolved, manually check the corresponding `Verify apply` task box + on the tracking issue and set its metadata to `status=success` (e.g., + `- [x] Verify apply 1.8 | status=success`). + +4. **Initiate Releases**: Once all `Verify apply` tasks are successful + (either automatically or after manual resolution), comment + `/create-releases` on the backport tracking issue, or manually run the + [Backport: Create + Releases](https://github.com/bazel-contrib/rules_python/actions/workflows/backport_create_releases.yaml) + workflow. + * This will automatically create a standard Release Tracking Issue for + each target version (e.g., `Release 1.7.1`, `Release 1.8.1`, etc.). + * For patch releases, the created release tracking issues will have `Tag + RC` tasks automatically removed, as release candidates are not + required for patch releases. + * The backport PR will be automatically added to the checklist of each + created release tracking issue. + +5. **Execute Releases**: Follow the standard release process for each created + release tracking issue. Since these are patch releases, you can skip the + `/create-rc` step and comment `/promote` directly to tag and publish the + release from the release branch head. + +## Manual patch release with cherry picks If a patch release from head would contain changes that aren't appropriate for a patch release, then the patch release needs to be based on the original diff --git a/tests/tools/private/release/BUILD.bazel b/tests/tools/private/release/BUILD.bazel index 633cfa328f..d49d8fd221 100644 --- a/tests/tools/private/release/BUILD.bazel +++ b/tests/tools/private/release/BUILD.bazel @@ -4,6 +4,7 @@ py_library( name = "release_test_helper", srcs = ["release_test_helper.py"], deps = [ + "//tools/private/release:mock_gh", "//tools/private/release:release_lib", ], ) @@ -97,8 +98,8 @@ py_test( ) py_test( - name = "promote_rc_test", - srcs = ["promote_rc_test.py"], + name = "promote_test", + srcs = ["promote_test.py"], deps = [ ":release_test_helper", "//tools/private/release:release_lib", @@ -130,3 +131,21 @@ py_test( "@dev_pip//packaging", ], ) + +py_test( + name = "backport_prepare_test", + srcs = ["backport_prepare_test.py"], + deps = [ + ":release_test_helper", + "//tools/private/release:release_lib", + ], +) + +py_test( + name = "backport_create_releases_test", + srcs = ["backport_create_releases_test.py"], + deps = [ + ":release_test_helper", + "//tools/private/release:release_lib", + ], +) diff --git a/tests/tools/private/release/backport_create_releases_test.py b/tests/tools/private/release/backport_create_releases_test.py new file mode 100644 index 0000000000..f1bdfe477f --- /dev/null +++ b/tests/tools/private/release/backport_create_releases_test.py @@ -0,0 +1,176 @@ +import argparse +import unittest + +from tests.tools.private.release.release_test_helper import ReleaseToolTestCase +from tools.private.release.backport_create_releases import BackportCreateReleases + + +class CmdBackportCreateReleasesTest(ReleaseToolTestCase): + def test_create_releases_all_success(self): + # Arrange + args = argparse.Namespace(issue=123, dry_run=False) + gh = self.gh + + # Setup backport issue in mock GH + backport_body = """* PR: #456 +* From version: 1.7 +* To version: 1.9 + +## Tasks + +- [x] Verify apply 1.7 | status=success +- [x] Verify apply 1.8 | status=success +- [x] Verify apply 1.9 | status=success +- [ ] Track Release 1.7.2 +- [ ] Track Release 1.8.1 +- [ ] Track Release 1.9.0""" + + gh.issues[123] = { + "title": "Backport: #456", + "body": backport_body, + "labels": ["type:backport-pr"], + "number": 123, + "url": "https://github.com/.../issues/123", + } + + # Act + result = BackportCreateReleases(args, gh).run() + + # Assert + self.assertEqual(result, 0) + + # Verify release issues created (IDs 1001, 1002, 1003) + self.assertIn(1001, gh.issues) + self.assertIn(1002, gh.issues) + self.assertIn(1003, gh.issues) + + # 1.7.2 (patch) should not have Tag RC0 + self.assertEqual(gh.issues[1001]["title"], "Release 1.7.2") + self.assertNotIn("Tag RC0", gh.issues[1001]["body"]) + self.assertIn("## Backports\n- [ ] #456", gh.issues[1001]["body"]) + + # 1.9.0 (minor) should have Tag RC0 + self.assertEqual(gh.issues[1003]["title"], "Release 1.9.0") + self.assertIn("Tag RC0", gh.issues[1003]["body"]) + + # Verify backport issue updated + expected_updated_backport_body = """* PR: #456 +* From version: 1.7 +* To version: 1.9 + +## Tasks + +- [x] Verify apply 1.7 | status=success +- [x] Verify apply 1.8 | status=success +- [x] Verify apply 1.9 | status=success +- [x] Track Release 1.7.2 | status=success release_issue=#1001 +- [x] Track Release 1.8.1 | status=success release_issue=#1002 +- [x] Track Release 1.9.0 | status=success release_issue=#1003""" + + self.assertEqual(gh.issues[123]["body"], expected_updated_backport_body) + + def test_create_releases_dependency_blocking(self): + # Arrange + args = argparse.Namespace(issue=123, dry_run=False) + gh = self.gh + + # 1.8 failed, 1.7 and 1.9 succeeded + backport_body = """* PR: #456 +* From version: 1.7 +* To version: 1.9 + +## Tasks + +- [x] Verify apply 1.7 | status=success +- [ ] Verify apply 1.8 | status=failed-conflict +- [x] Verify apply 1.9 | status=success +- [ ] Track Release 1.7.2 +- [ ] Track Release 1.8.1 +- [ ] Track Release 1.9.0""" + + gh.issues[123] = { + "title": "Backport: #456", + "body": backport_body, + "labels": ["type:backport-pr"], + "number": 123, + "url": "https://github.com/.../issues/123", + } + + # Act + result = BackportCreateReleases(args, gh).run() + + # Assert + self.assertEqual(result, 0) + + # Only 1.9.0 (ID 1001) should be created. 1.7.2 and 1.8.1 are blocked by 1.8 failure. + self.assertEqual(len(gh.issues), 2) # Backport issue + 1 release issue + self.assertIn(1001, gh.issues) + self.assertEqual(gh.issues[1001]["title"], "Release 1.9.0") + + # Verify backport issue updated with block statuses + expected_updated_backport_body = """* PR: #456 +* From version: 1.7 +* To version: 1.9 + +## Tasks + +- [x] Verify apply 1.7 | status=success +- [ ] Verify apply 1.8 | status=failed-conflict +- [x] Verify apply 1.9 | status=success +- [ ] Track Release 1.7.2 | status=error-later-release-did-not-apply +- [ ] Track Release 1.8.1 | status=error-later-release-did-not-apply +- [x] Track Release 1.9.0 | status=success release_issue=#1001""" + + self.assertEqual(gh.issues[123]["body"], expected_updated_backport_body) + + def test_create_releases_already_exists(self): + # Arrange + args = argparse.Namespace(issue=123, dry_run=False) + gh = self.gh + + backport_body = """* PR: #456 +* From version: 1.7 +* To version: 1.7 + +## Tasks + +- [x] Verify apply 1.7 | status=success +- [ ] Track Release 1.7.2""" + + gh.issues[123] = { + "title": "Backport: #456", + "body": backport_body, + "labels": ["type:backport-pr"], + "number": 123, + "url": "https://github.com/.../issues/123", + } + + # Pre-create the release issue to simulate it already existing + gh.create_issue( + title="Release 1.7.2", + body="existing body\n## Backports\n", + labels=["type: release"], + ) # Will get ID 1001 + + # Act + result = BackportCreateReleases(args, gh).run() + + # Assert + self.assertEqual(result, 0) + self.assertEqual(len(gh.issues), 2) + + # Should link existing issue 1001 + expected_updated_backport_body = """* PR: #456 +* From version: 1.7 +* To version: 1.7 + +## Tasks + +- [x] Verify apply 1.7 | status=success +- [x] Track Release 1.7.2 | status=success release_issue=#1001""" + + self.assertEqual(gh.issues[123]["body"], expected_updated_backport_body) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tools/private/release/backport_prepare_test.py b/tests/tools/private/release/backport_prepare_test.py new file mode 100644 index 0000000000..70d074cd94 --- /dev/null +++ b/tests/tools/private/release/backport_prepare_test.py @@ -0,0 +1,216 @@ +import argparse +import unittest +from unittest.mock import call, patch + +from tests.tools.private.release.release_test_helper import ( + ReleaseToolTestCase, + _mock_git, +) +from tools.private.release.backport_prepare import BackportPrepare +from tools.private.release.gh import BACKPORT_LABEL + + +class CmdBackportPrepareTest(ReleaseToolTestCase): + def setUp(self): + super().setUp() + _mock_git(self) + # Mock changelog_news and determine_next_version + self.patcher_news = patch( + "tools.private.release.backport_prepare.changelog_news" + ) + self.mock_news = self.patcher_news.start() + + self.patcher_det = patch( + "tools.private.release.backport_prepare.determine_next_version" + ) + self.mock_det = self.patcher_det.start() + + self.addCleanup(self.patcher_news.stop) + self.addCleanup(self.patcher_det.stop) + + def test_prepare_from_issue_success(self): + # Arrange + args = argparse.Namespace( + issue=123, + pr=None, + from_minor=None, + to_minor=None, + remote="my-remote", + dry_run=False, + ) + + # Setup backport issue in mock GH + backport_body = "* PR: #456\n* From version: 1.7\n* To version: 1.9\n" + self.gh.issues[123] = { + "title": "Backport: #456", + "body": backport_body, + "labels": ["type: backport-pr"], + "number": 123, + "url": "https://github.com/.../issues/123", + } + + # Setup PR info in mock GH + self.gh.prs[456] = { + "state": "MERGED", + "mergeCommit": {"oid": "pr_merge_sha_12345"}, + } + + # Mock remote branches + self.mock_git.get_remote_branches.return_value = [ + "main", + "release/1.6", + "release/1.7", + "release/1.8", + "release/1.9", + "release/2.0", + ] + self.mock_git.get_current_branch.return_value = "work-branch" + + # Mock next versions + self.mock_det.side_effect = ["1.7.2", "1.8.1", "1.9.0"] + + # Act + result = BackportPrepare(args, self.mock_git, self.gh).run() + + # Assert + self.assertEqual(result, 0) + self.mock_git.fetch.assert_called_once_with("my-remote", tags=True, force=True) + + # Verify checkouts and cherry-picks + self.mock_git.checkout.assert_has_calls( + [ + call("release/1.7", track_remote="my-remote"), + call("release/1.8", track_remote="my-remote"), + call("release/1.9", track_remote="my-remote"), + call("work-branch"), # Restored branch + ] + ) + + self.mock_git.cherry_pick.assert_has_calls( + [ + call("pr_merge_sha_12345"), + call("pr_merge_sha_12345"), + call("pr_merge_sha_12345"), + ] + ) + + # Verify changelog updates + self.mock_news.update_changelog.assert_has_calls( + [ + call("1.7.2", unittest.mock.ANY), + call("1.8.1", unittest.mock.ANY), + call("1.9.0", unittest.mock.ANY), + ] + ) + + # Verify issue body update + expected_body = ( + "* PR: #456\n" + "* From version: 1.7\n" + "* To version: 1.9\n" + "\n" + "## Tasks\n" + "\n" + "- [x] Verify apply 1.7 | status=success\n" + "- [x] Verify apply 1.8 | status=success\n" + "- [x] Verify apply 1.9 | status=success\n" + "- [ ] Track Release 1.7.2\n" + "- [ ] Track Release 1.8.1\n" + "- [ ] Track Release 1.9.0" + ) + self.assertEqual(self.gh.issues[123]["body"], expected_body) + + def test_prepare_manual_success(self): + # Arrange + args = argparse.Namespace( + issue=None, + pr="#456", + from_minor="1.7", + to_minor="1.8", + remote="my-remote", + dry_run=False, + ) + self.gh.prs[456] = { + "state": "MERGED", + "mergeCommit": {"oid": "pr_merge_sha_12345"}, + } + self.mock_git.get_remote_branches.return_value = [ + "release/1.7", + "release/1.8", + ] + self.mock_git.get_current_branch.return_value = "work-branch" + self.mock_det.side_effect = ["1.7.2", "1.8.1"] + + # Act + result = BackportPrepare(args, self.mock_git, self.gh).run() + + # Assert + self.assertEqual(result, 0) + self.assertIn(1001, self.gh.issues) + issue = self.gh.issues[1001] + self.assertEqual(issue["title"], "Backport: #456") + self.assertEqual(issue["labels"], [BACKPORT_LABEL]) + body = issue["body"] + self.assertIn("- [x] Verify apply 1.7 | status=success", body) + self.assertIn("- [x] Verify apply 1.8 | status=success", body) + self.assertIn("- [ ] Track Release 1.7.2", body) + self.assertIn("- [ ] Track Release 1.8.1", body) + + def test_prepare_verify_failed(self): + # Arrange + args = argparse.Namespace( + issue=123, + pr=None, + from_minor=None, + to_minor=None, + remote="my-remote", + dry_run=False, + ) + issue_body = "* PR: #456\n* From version: 1.7\n* To version: 1.8\n" + self.gh.issues[123] = { + "title": "Backport: #456", + "body": issue_body, + "labels": ["type: backport-pr"], + "number": 123, + "url": "https://github.com/.../issues/123", + } + self.gh.prs[456] = { + "state": "MERGED", + "mergeCommit": {"oid": "pr_merge_sha_12345"}, + } + self.mock_git.get_remote_branches.return_value = [ + "release/1.7", + "release/1.8", + ] + self.mock_git.get_current_branch.return_value = "work-branch" + self.mock_det.side_effect = ["1.7.2", "1.8.1"] + + # Mock cherry-pick failure on 1.7 and changelog failure on 1.8 + self.mock_git.cherry_pick.side_effect = [Exception("Conflict"), None] + self.mock_news.update_changelog.side_effect = [Exception("Changelog error")] + + # Act + result = BackportPrepare(args, self.mock_git, self.gh).run() + + # Assert + self.assertEqual( + result, 0 + ) # Returns 0 even if verification fails, it just updates tasks + + expected_body = ( + "* PR: #456\n" + "* From version: 1.7\n" + "* To version: 1.8\n" + "\n" + "## Tasks\n" + "\n" + "- [ ] Verify apply 1.7 | status=failed-conflict\n" + "- [ ] Verify apply 1.8 | status=failed-changelog\n" + "- [ ] Track Release 1.7.2\n" + "- [ ] Track Release 1.8.1" + ) + self.assertEqual(self.gh.issues[123]["body"], expected_body) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tools/private/release/promote_rc_test.py b/tests/tools/private/release/promote_test.py similarity index 90% rename from tests/tools/private/release/promote_rc_test.py rename to tests/tools/private/release/promote_test.py index b5dff3e478..8d1d2cfb29 100644 --- a/tests/tools/private/release/promote_rc_test.py +++ b/tests/tools/private/release/promote_test.py @@ -7,10 +7,10 @@ from tests.tools.private.release.release_test_helper import _mock_git_and_gh from tools.private.release.gh import NoTrackingIssueError -from tools.private.release.promote_rc import PromoteRc +from tools.private.release.promote import Promote -class CmdPromoteRcTest(unittest.TestCase): +class CmdPromoteTest(unittest.TestCase): def setUp(self): _mock_git_and_gh(self) self.test_dir = tempfile.TemporaryDirectory() @@ -28,7 +28,7 @@ def test_promote_rc_success(self): self.mock_gh.get_issue_body.return_value = initial_body # Act - result = PromoteRc(args, self.mock_git, self.mock_gh).run() + result = Promote(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -60,7 +60,7 @@ def test_promote_rc_success(self): "- [Github Release 2.0.0](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0)\n" "- [BCR Entry 2.0.0](https://registry.bazel.build/modules/rules_python/2.0.0)\n" "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr%20%28%22bazel-contrib/rules_python%22%20in%3Atitle%29%20%28%22%402.0.0%22%20in%3Atitle%29)\n" - "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_promote_rc.yaml)" + "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_promote.yaml)" ) self.mock_gh.post_issue_comment.assert_called_once_with(123, expected_comment) @@ -78,7 +78,7 @@ def test_promote_rc_writes_github_output(self): # Act with patch.dict("os.environ", {"GITHUB_OUTPUT": github_output_path}): - result = PromoteRc(args, self.mock_git, self.mock_gh).run() + result = Promote(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -100,7 +100,7 @@ def test_promote_rc_resolve_issue_success(self): self.mock_gh.get_issue_body.return_value = initial_body # Act - result = PromoteRc(args, self.mock_git, self.mock_gh).run() + result = Promote(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -130,24 +130,22 @@ def test_promote_rc_resolve_issue_success(self): "- [Github Release 2.0.0](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0)\n" "- [BCR Entry 2.0.0](https://registry.bazel.build/modules/rules_python/2.0.0)\n" "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr%20%28%22bazel-contrib/rules_python%22%20in%3Atitle%29%20%28%22%402.0.0%22%20in%3Atitle%29)\n" - "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_promote_rc.yaml)" + "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_promote.yaml)" ) self.mock_gh.post_issue_comment.assert_called_once_with(123, expected_comment) - def test_promote_rc_resolves_version_from_issue(self): + def test_promote_patch_success(self): # Arrange args = argparse.Namespace( - version=None, issue=123, dry_run=False, remote="my-remote" + version="2.0.1", 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 + self.mock_git.get_commit_sha.return_value = "12345678" initial_body = "- [ ] Tag Final" self.mock_gh.get_issue_body.return_value = initial_body # Act - result = PromoteRc(args, self.mock_git, self.mock_gh).run() + result = Promote(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -159,12 +157,10 @@ def test_promote_rc_resolves_version_from_issue(self): ) 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.get_remote_tags.assert_not_called() self.mock_git.checkout.assert_not_called() - self.mock_git.get_commit_sha.assert_has_calls( - [call("2.0.1-rc0"), call("my-remote/release/2.0")] - ) + self.mock_git.get_commit_sha.assert_called_once_with("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") @@ -180,7 +176,7 @@ def test_promote_rc_resolves_version_from_issue(self): "- [Github Release 2.0.1](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.1)\n" "- [BCR Entry 2.0.1](https://registry.bazel.build/modules/rules_python/2.0.1)\n" "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr%20%28%22bazel-contrib/rules_python%22%20in%3Atitle%29%20%28%22%402.0.1%22%20in%3Atitle%29)\n" - "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_promote_rc.yaml)" + "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release_promote.yaml)" ) self.mock_gh.post_issue_comment.assert_called_once_with(123, expected_comment) @@ -197,7 +193,7 @@ def test_promote_rc_dry_run_success(self, mock_print): self.mock_gh.get_issue_body.return_value = initial_body # Act - result = PromoteRc(args, self.mock_git, self.mock_gh).run() + result = Promote(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -242,7 +238,7 @@ def test_promote_rc_tag_already_exists(self): self.mock_git.tag_exists.return_value = True # Act - result = PromoteRc(args, self.mock_git, self.mock_gh).run() + result = Promote(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -264,7 +260,7 @@ def test_promote_rc_issue_not_found(self): ) # Act - result = PromoteRc(args, self.mock_git, self.mock_gh).run() + result = Promote(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -286,7 +282,7 @@ def test_promote_rc_issue_malformed(self): self.mock_gh.get_issue_body.return_value = initial_body # Act - result = PromoteRc(args, self.mock_git, self.mock_gh).run() + result = Promote(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -304,7 +300,7 @@ def test_promote_rc_no_rc_found(self): self.mock_git.get_remote_tags.return_value = [] # Act - result = PromoteRc(args, self.mock_git, self.mock_gh).run() + result = Promote(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index c7ba1b17b7..f0cdbdfe1d 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -7,9 +7,7 @@ class ReleaseCLITest(unittest.TestCase): def test_valid_version(self): # These should not raise an exception releaser.create_parser().parse_args(["prepare", "0.28.0"]) - releaser.create_parser().parse_args( - ["promote-rc", "1.0.0", "--remote", "origin"] - ) + releaser.create_parser().parse_args(["promote", "1.0.0", "--remote", "origin"]) releaser.create_parser().parse_args( ["create-release-issue", "--version", "1.2.3rc4"] ) diff --git a/tests/tools/private/release/release_test_helper.py b/tests/tools/private/release/release_test_helper.py index c35cd0fa40..12945193ef 100644 --- a/tests/tools/private/release/release_test_helper.py +++ b/tests/tools/private/release/release_test_helper.py @@ -9,30 +9,36 @@ MultipleTrackingIssuesError, NoTrackingIssueError, ) +from tools.private.release.mock_gh import MockGitHub -def _mock_git_and_gh(test_case): +def _mock_git(test_case): mock_git = MagicMock() - mock_gh = MagicMock() test_case.mock_git = mock_git - test_case.mock_gh = mock_gh # Mock Git inside utils.py since it instantiates it locally patch("tools.private.release.utils.Git", return_value=mock_git).start() - mock_gh.MultipleTrackingIssuesError = MultipleTrackingIssuesError - mock_gh.NoTrackingIssueError = NoTrackingIssueError - test_case.addCleanup(patch.stopall) # Apply safe defaults mock_git.get_current_branch.return_value = None mock_git.get_tags.return_value = [] mock_git.get_remote_tags.return_value = [] - mock_git.status.return_value = "" mock_git.branch_exists.return_value = False mock_git.tag_exists.return_value = False + return mock_git + + +def _mock_git_and_gh(test_case): + _mock_git(test_case) + mock_gh = MagicMock() + test_case.mock_gh = mock_gh + + mock_gh.MultipleTrackingIssuesError = MultipleTrackingIssuesError + mock_gh.NoTrackingIssueError = NoTrackingIssueError + mock_gh.get_release_tracking_issue.side_effect = NoTrackingIssueError("Not found") mock_gh.get_open_pr.return_value = None @@ -44,3 +50,27 @@ def setUp(self): self.addCleanup(shutil.rmtree, self.tmpdir) os.chdir(self.tmpdir) self.addCleanup(os.chdir, self.original_cwd) + + +DEFAULT_RELEASE_TEMPLATE_CONTENT = ( + "template content\n" + "- [ ] Prepare Release\n" + "- [ ] Tag RC0\n" + "- [ ] Tag Final\n" + "\n" + "## Backports\n" +) + + +class ReleaseToolTestCase(TempDirTestCase): + def setUp(self): + super().setUp() + self.gh = MockGitHub() + self.setUpReleaseTemplate() + + def setUpReleaseTemplate(self): + template_dir = self.tmpdir / ".github/ISSUE_TEMPLATE" + template_dir.mkdir(parents=True, exist_ok=True) + self.template_file = template_dir / "release_tracking_template.md" + self.template_content = DEFAULT_RELEASE_TEMPLATE_CONTENT + self.template_file.write_text(self.template_content, encoding="utf-8") diff --git a/tools/private/release/BUILD.bazel b/tools/private/release/BUILD.bazel index a63d2111f7..5f80d1130a 100644 --- a/tools/private/release/BUILD.bazel +++ b/tools/private/release/BUILD.bazel @@ -11,7 +11,10 @@ py_library( name = "release_lib", srcs = glob( ["*.py"], - exclude = ["changelog_news.py"], + exclude = [ + "changelog_news.py", + "mock_gh.py", + ], ), deps = [ ":changelog_news", @@ -19,6 +22,14 @@ py_library( ], ) +py_library( + name = "mock_gh", + srcs = ["mock_gh.py"], + deps = [ + ":release_lib", + ], +) + py_binary( name = "release", srcs = ["release.py"], diff --git a/tools/private/release/backport_create_releases.py b/tools/private/release/backport_create_releases.py new file mode 100644 index 0000000000..662ef96eb1 --- /dev/null +++ b/tools/private/release/backport_create_releases.py @@ -0,0 +1,237 @@ +"""Subcommand to initiate releases for verified backports.""" + +import argparse +import pathlib +import re +from dataclasses import dataclass + +from tools.private.release.backport_prepare import parse_backport_metadata +from tools.private.release.gh import GitHub +from tools.private.release.release_issue import ( + add_backports_to_body, + add_sync_changelog_task_to_body, + parse_metadata_line, + update_task_in_body, +) + + +@dataclass +class BackportTasksState: + # Map of minor version (e.g. "1.7") to verification status (e.g. "success", "failed-conflict") + verify: dict[str, str] + # Map of full version (e.g. "1.7.1") to tuple of (status, release_issue_number_or_None) + release: dict[str, tuple[str, str | None]] + + +def parse_backport_tasks(body) -> BackportTasksState: + """Parses tasks from backport issue body.""" + verify_tasks = {} + release_tasks = {} + + lines = body.splitlines() + for line in lines: + parsed = parse_metadata_line(line) + if not parsed: + continue + name = parsed["name"] + meta = parsed["metadata"] + + verify_match = re.match(r"Verify apply (\d+\.\d+)", name, re.IGNORECASE) + if verify_match: + minor = verify_match.group(1) + verify_tasks[minor] = meta.get("status", "pending") + continue + + release_match = re.match(r"Track Release (\d+\.\d+\.\d+)", name, re.IGNORECASE) + if release_match: + version = release_match.group(1) + status = meta.get("status", "pending") + issue_num = meta.get("release_issue") + release_tasks[version] = (status, issue_num) + + return BackportTasksState(verify=verify_tasks, release=release_tasks) + + +def is_release_eligible(version, target_minors, verify_statuses): + """Checks if a release version is eligible based on verification statuses.""" + version_minor = ".".join(version.split(".")[:2]) + v_minor_parsed = [int(x) for x in version_minor.split(".")] + + for minor in target_minors: + minor_parsed = [int(x) for x in minor.split(".")] + if minor_parsed >= v_minor_parsed: + status = verify_statuses.get(minor) + if status != "success": + return ( + False, + f"Blocked by verification failure on {minor} (status: {status})", + ) + + return True, "Eligible" + + +def _load_release_template() -> str: + """Loads the release tracking issue template.""" + template_path = pathlib.Path(".github/ISSUE_TEMPLATE/release_tracking_template.md") + if not template_path.exists(): + raise FileNotFoundError(f"Template file not found at {template_path}") + return template_path.read_text(encoding="utf-8") + + +class BackportCreateReleases: + """Class to initiate releases for verified backports.""" + + def __init__(self, args, gh: GitHub): + self._args = args + self._gh = gh + + def run(self) -> int: + """Executes the backport-create-releases subcommand.""" + args = self._args + issue_num = args.issue + if not issue_num: + raise ValueError("--issue is required.") + + print(f"Reading backport issue #{issue_num}...") + body = self._gh.get_issue_body(issue_num) + try: + metadata = parse_backport_metadata(body) + except ValueError as e: + e.add_note(f"Failed to parse backport metadata from issue #{issue_num}") + raise + pr_ref = metadata.pr + + pr_num = self._gh.resolve_pr_number(pr_ref) + + # Parse tasks + tasks_state = parse_backport_tasks(body) + verify_statuses = tasks_state.verify + release_tasks = tasks_state.release + + target_minors = sorted( + list(verify_statuses.keys()), key=lambda m: [int(x) for x in m.split(".")] + ) + + # We need the templates for release issues + template_content = _load_release_template() + + updated_body = body + changes_made = False + + for version, (status, release_issue) in release_tasks.items(): + # If status is success, we already created the issue. + # We check the actual checklist item status, but the helper returns it. + # The status in metadata is 'success' when done. + if status == "success": + print(f"Release for {version} already initiated: {release_issue}") + continue + + eligible, reason = is_release_eligible( + version, target_minors, verify_statuses + ) + + task_name = f"Track Release {version}" + + if eligible: + print(f"Initiating release for {version}...") + + # Check if release issue already exists + existing_issues = self._gh.get_open_tracking_issues(version) + if existing_issues: + new_issue_num = existing_issues[0]["number"] + print( + f"Release issue for {version} already exists: #{new_issue_num}. Reusing it." + ) + else: + # Create the issue + is_first_release = version.endswith(".0") + if is_first_release: + issue_template = template_content + else: + lines = template_content.splitlines() + lines = [ + line for line in lines if not re.search(r"Tag RC\d+", line) + ] + issue_template = "\n".join(lines) + if template_content.endswith("\n"): + issue_template += "\n" + + if args.dry_run: + print( + f"[DRY RUN] Would create release tracking issue for {version} (without RC tasks)" + ) + new_issue_num = "" + else: + new_issue_num = self._gh.create_tracking_issue( + version, issue_template + ) + print( + f"Created release tracking issue #{new_issue_num} for {version}" + ) + + # Add the backport PR to the new release issue + print( + f"Adding PR #{pr_num} to release issue #{new_issue_num} checklist..." + ) + rel_body = self._gh.get_issue_body(new_issue_num) + rel_body = add_backports_to_body( + rel_body, [{"ref": f"#{pr_num}"}] + ) + rel_body = add_sync_changelog_task_to_body(rel_body, pr_num) + self._gh.update_issue_body(new_issue_num, rel_body) + + # Update task in backport issue + metadata = {"status": "success", "release_issue": f"#{new_issue_num}"} + updated_body = update_task_in_body( + updated_body, task_name, checked=True, metadata=metadata + ) + changes_made = True + else: + print(f"Release for {version} is not eligible: {reason}") + metadata = {"status": "error-later-release-did-not-apply"} + + if status != "error-later-release-did-not-apply": + updated_body = update_task_in_body( + updated_body, task_name, checked=False, metadata=metadata + ) + changes_made = True + + if changes_made: + if args.dry_run: + print( + f"[DRY RUN] Would update backport issue #{issue_num} body:\n{updated_body}" + ) + else: + self._gh.update_issue_body(issue_num, updated_body) + print(f"Updated backport issue #{issue_num}") + else: + print("No changes needed for backport issue.") + + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for backport-create-releases subcommand.""" + parser = subparsers.add_parser( + "backport-create-releases", + help="Initiate releases for verified backports.", + ) + parser.add_argument( + "--issue", + type=int, + required=True, + help="The backport tracking issue number (required).", + ) + parser.add_argument( + "--dry-run", + action=argparse.BooleanOptionalAction, + default=True, + help="Perform a dry run (default: True). Use --no-dry-run to actually execute.", + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + gh = GitHub() + return cls(args, gh).run() diff --git a/tools/private/release/backport_prepare.py b/tools/private/release/backport_prepare.py new file mode 100644 index 0000000000..e0b8dc5b05 --- /dev/null +++ b/tools/private/release/backport_prepare.py @@ -0,0 +1,305 @@ +"""Subcommand to prepare backport tracking issue and verify cherry-picks.""" + +import argparse +import datetime +import re +from dataclasses import dataclass + +from tools.private.release import changelog_news +from tools.private.release.gh import BACKPORT_LABEL, GitHub +from tools.private.release.git import Git +from tools.private.release.utils import determine_next_version + + +@dataclass +class BackportMetadata: + pr: str + from_minor: str + to_minor: str + + +def parse_backport_metadata(body) -> BackportMetadata: + """Parses backport metadata from issue body.""" + pr_match = re.search(r"^\s*\*\s*PR:\s*(\S+)", body, re.MULTILINE | re.IGNORECASE) + from_match = re.search( + r"^\s*\*\s*From version:\s*(\S+)", body, re.MULTILINE | re.IGNORECASE + ) + to_match = re.search( + r"^\s*\*\s*To version:\s*(\S+)", body, re.MULTILINE | re.IGNORECASE + ) + + if not pr_match or not from_match or not to_match: + raise ValueError( + "Missing metadata in issue body. Need PR, From version, and To version." + ) + + return BackportMetadata( + pr=pr_match.group(1), + from_minor=from_match.group(1), + to_minor=to_match.group(1), + ) + + +def get_target_branches(git, remote, from_minor, to_minor): + """Identifies target release branches in the given range.""" + branches = git.get_remote_branches(remote) + target_branches = [] + + # Parse version strings to compare + from_v = [int(x) for x in from_minor.split(".")] + to_v = [int(x) for x in to_minor.split(".")] + + for branch in branches: + match = re.match(r"^release/(\d+)\.(\d+)$", branch) + if match: + major = int(match.group(1)) + minor = int(match.group(2)) + v = [major, minor] + if v >= from_v and v <= to_v: + target_branches.append(branch) + + # Sort branches by version + target_branches.sort(key=lambda b: [int(x) for x in b.split("/")[1].split(".")]) + return target_branches + + +def get_latest_release_branch(git, remote): + """Determines the latest release branch.""" + branches = git.get_remote_branches(remote) + release_branches = [] + for branch in branches: + match = re.match(r"^release/(\d+)\.(\d+)$", branch) + if match: + release_branches.append(branch) + if not release_branches: + return None + release_branches.sort(key=lambda b: [int(x) for x in b.split("/")[1].split(".")]) + return release_branches[-1] + + +class BackportPrepare: + """Class to prepare backport tracking issue and verify cherry-picks.""" + + def __init__(self, args, git: Git, gh: GitHub): + self.args = args + self.git = git + self.gh = gh + + def run(self) -> int: + """Executes the backport-prepare subcommand.""" + args = self.args + print("Fetching remote to verify release branches...") + self.git.fetch(args.remote, tags=True, force=True) + + issue_num = args.issue + + if issue_num: + # Triggered by comment, read from issue + print(f"Reading metadata from issue #{issue_num}...") + body = self.gh.get_issue_body(issue_num) + try: + metadata = parse_backport_metadata(body) + except ValueError as e: + e.add_note(f"Failed to parse backport metadata from issue #{issue_num}") + raise + pr_ref = metadata.pr + from_minor = metadata.from_minor + to_minor = metadata.to_minor + else: + # Triggered manually, use args + pr_ref = args.pr + from_minor = args.from_minor + to_minor = args.to_minor + + if not pr_ref or not from_minor: + raise ValueError( + "PR and From version are required if not running from an issue." + ) + + if not to_minor: + latest_branch = get_latest_release_branch(self.git, args.remote) + if not latest_branch: + raise RuntimeError( + "Could not determine latest release branch and --to-minor was not provided." + ) + to_minor = latest_branch.split("/")[1] + print(f"Auto-determined latest minor version: {to_minor}") + + # Resolve PR to merge commit + try: + pr_num = self.gh.resolve_pr_number(pr_ref) + print(f"Resolving PR #{pr_num} info...") + pr_info = self.gh.get_pr_info(pr_num) + except Exception as e: + e.add_note(f"Failed to resolve PR info for {pr_ref}") + raise + + if pr_info.get("state") != "MERGED": + raise ValueError( + f"PR #{pr_num} is not merged (state: {pr_info.get('state')})." + ) + merge_commit = pr_info.get("mergeCommit") + if not merge_commit or "oid" not in merge_commit: + raise ValueError(f"PR #{pr_num} has no merge commit SHA.") + pr_sha = merge_commit["oid"] + print(f"Resolved PR #{pr_num} to merge commit {pr_sha[:8]}") + + target_branches = get_target_branches( + self.git, args.remote, from_minor, to_minor + ) + if not target_branches: + raise ValueError( + f"No release branches found in range {from_minor} to {to_minor}" + ) + + print(f"Identified target branches: {target_branches}") + + # Verify workspace is clean + if self.git.status(): + raise RuntimeError("Workspace is dirty. Aborting.") + + current_branch = self.git.get_current_branch() + verify_results = {} # branch -> (success, reason) + version_map = {} # branch -> next_version + + try: + for branch in target_branches: + minor_ver = branch.split("/")[1] + print(f"Verifying application on {branch}...") + self.git.checkout(branch, track_remote=args.remote) + + # Determine next version + next_version = determine_next_version(branch) + version_map[branch] = next_version + + # Verify apply (cherry-pick + news) + try: + self.git.cherry_pick(pr_sha) + + # Try news update + try: + release_date = datetime.date.today().strftime("%Y-%m-%d") + changelog_news.update_changelog(next_version, release_date) + verify_results[branch] = (True, "Success") + print(f"Verification successful for {branch}") + except Exception as e: + verify_results[branch] = ( + False, + f"Changelog update failed: {e}", + ) + print( + f"Verification failed for {branch} (changelog update): {e}" + ) + except Exception as e: + verify_results[branch] = (False, f"Cherry-pick failed: {e}") + print(f"Verification failed for {branch} (cherry-pick): {e}") + finally: + # Always abort/reset + try: + self.git.cherry_pick_abort() + except Exception: + pass + self.git.reset_hard(reset_to=f"{args.remote}/{branch}") + finally: + # Restore original branch + if current_branch: + self.git.checkout(current_branch) + + # Generate issue content + body_lines = [ + f"* PR: #{pr_num}", + f"* From version: {from_minor}", + f"* To version: {to_minor}", + "", + "## Tasks", + "", + ] + + # Add Verify tasks + for branch in target_branches: + minor_ver = branch.split("/")[1] + success, reason = verify_results[branch] + if success: + body_lines.append(f"- [x] Verify apply {minor_ver} | status=success") + else: + status = "failed-conflict" + if "Changelog" in reason: + status = "failed-changelog" + body_lines.append(f"- [ ] Verify apply {minor_ver} | status={status}") + + # Add Release tasks + for branch in target_branches: + next_version = version_map[branch] + body_lines.append(f"- [ ] Track Release {next_version}") + + new_body = "\n".join(body_lines) + + if issue_num: + if args.dry_run: + print( + f"[DRY RUN] Would update issue #{issue_num} with body:\n{new_body}" + ) + else: + self.gh.update_issue_body(issue_num, new_body) + print(f"Successfully updated issue #{issue_num}") + else: + title = f"Backport: #{pr_num}" + if args.dry_run: + print( + f"[DRY RUN] Would create issue with title '{title}' and body:\n{new_body}" + ) + else: + new_issue_num = self.gh.create_issue( + title, new_body, labels=[BACKPORT_LABEL] + ) + print(f"Created backport tracking issue #{new_issue_num}") + + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for backport-prepare subcommand.""" + parser = subparsers.add_parser( + "backport-prepare", + help="Prepare backport tracking issue and verify cherry-picks.", + ) + parser.add_argument( + "--issue", + type=int, + help="The backport tracking issue number (if running from an existing issue).", + ) + parser.add_argument( + "--pr", + type=str, + help="PR reference to backport (required if not running from issue).", + ) + parser.add_argument( + "--from-minor", + type=str, + help="Oldest minor version to target (inclusive) (e.g. 1.7) (required if not running from issue).", + ) + parser.add_argument( + "--to-minor", + type=str, + help="Newest minor version to target (inclusive) (optional, defaults to latest).", + ) + parser.add_argument( + "--remote", + type=str, + default="origin", + help="The git remote (default: origin).", + ) + parser.add_argument( + "--dry-run", + action=argparse.BooleanOptionalAction, + default=True, + help="Perform a dry run (default: True). Use --no-dry-run to actually execute.", + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + git = Git(".") + gh = GitHub() + return cls(args, git, gh).run() diff --git a/tools/private/release/create_release_issue.py b/tools/private/release/create_release_issue.py index 2d00d7369a..1c28e86c7d 100644 --- a/tools/private/release/create_release_issue.py +++ b/tools/private/release/create_release_issue.py @@ -1,6 +1,7 @@ """Subcommand to create a release tracking issue.""" import pathlib +import re from tools.private.release.gh import GitHub from tools.private.release.utils import determine_next_version, semver_type @@ -34,6 +35,13 @@ def run(self) -> int: raise FileNotFoundError(f"Template file not found at {template_path}") template_content = template_path.read_text(encoding="utf-8") + is_first_release = version.endswith(".0") + if not is_first_release: + # Patch release: remove RC tasks + lines = template_content.splitlines() + lines = [line for line in lines if not re.search(r"Tag RC\d+", line)] + template_content = "\n".join(lines) + issue_num = self.gh.create_tracking_issue(version, template_content) print(f"Created tracking issue #{issue_num} for v{version}") return 0 diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py index 92cc3ca5c4..27206f1ffe 100644 --- a/tools/private/release/gh.py +++ b/tools/private/release/gh.py @@ -8,6 +8,10 @@ from tools.private.release.release_issue import BackportTask from tools.private.release.shell import run_cmd +# GitHub label types +RELEASE_LABEL = "type: release" +BACKPORT_LABEL = "type: backport-pr" + # GitHub reaction types # See: https://docs.github.com/en/rest/reactions/reactions?apiVersion=2022-11-28#about-reactions GH_REACTION_THUMBS_UP = "+1" @@ -42,7 +46,6 @@ def __init__(self, repo: str = "bazel-contrib/rules_python"): repo: The GitHub repository to operate on. """ self.repo = repo - self.label = "type: release" def _run_gh( self, *args: str, check: bool = True, capture_output: bool = True @@ -125,7 +128,7 @@ def get_open_tracking_issues(self, version: str | None = None) -> list[dict]: """ search = f'"Release {version}" in:title' if version else None return self.list_issues( - label=self.label, + label=RELEASE_LABEL, state="open", search=search, fields="number,title,url", @@ -158,13 +161,13 @@ def get_release_tracking_issue(self, version: str) -> int: if not exact_matches: raise NoTrackingIssueError( f"No open tracking issue found matching 'Release {version}' " - f"in repo {self.repo} with label '{self.label}'" + f"in repo {self.repo} with label '{RELEASE_LABEL}'" ) if len(exact_matches) > 1: urls = [issue["url"] for issue in exact_matches] raise MultipleTrackingIssuesError( f"Multiple open tracking issues found for version {version} " - f"in repo {self.repo} with label '{self.label}':\n" + "\n".join(urls) + f"in repo {self.repo} with label '{RELEASE_LABEL}':\n" + "\n".join(urls) ) return exact_matches[0]["number"] @@ -188,16 +191,15 @@ def create_tracking_issue(self, version: str, template_content: str) -> int: if len(parts) >= 3: issue_body = parts[2].strip() - # Write body to a secure temporary file to pass to the CLI - with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".md") as f: f.write(issue_body) + f.flush() temp_path = f.name - try: output = self._gh_issue( "create", f"--title=Release {version}", - f"--label={self.label}", + f"--label={RELEASE_LABEL}", f"--body-file={temp_path}", ) if not output: @@ -205,9 +207,40 @@ def create_tracking_issue(self, version: str, template_content: str) -> int: issue_url = output.strip() issue_num = int(issue_url.split("/")[-1]) return issue_num - finally: - if os.path.exists(temp_path): - os.unlink(temp_path) + + def create_issue( + self, title: str, body: str, labels: list[str] | None = None + ) -> int: + """Creates a generic issue. + + Args: + title: The title of the issue. + body: The body of the issue. + labels: Optional list of labels to add. + + Returns: + The created issue number. + """ + with tempfile.NamedTemporaryFile(mode="w", suffix=".md") as f: + f.write(body) + f.flush() + temp_path = f.name + + cmd = [ + "create", + f"--title={title}", + f"--body-file={temp_path}", + ] + if labels: + for label in labels: + cmd.append(f"--label={label}") + + output = self._gh_issue(*cmd) + if not output: + raise RuntimeError("Failed to get issue URL from gh issue create") + issue_url = output.strip() + issue_num = int(issue_url.split("/")[-1]) + return issue_num def get_issue_body(self, issue_num: int) -> str: """Fetches the body of a specific issue. diff --git a/tools/private/release/git.py b/tools/private/release/git.py index 4305de855f..3e7e3cb893 100644 --- a/tools/private/release/git.py +++ b/tools/private/release/git.py @@ -397,3 +397,25 @@ def apply_check(self, patch_file: str) -> bool: return True except subprocess.CalledProcessError: return False + + def get_remote_branches(self, remote: str = "origin") -> list[str]: + """Returns a list of remote branches. + + Args: + remote: The name of the remote. + + Returns: + A list of branch names (without the remote prefix). + """ + output = self._run_git("branch", "-r") + branches = [] + if not output: + return branches + for line in output.splitlines(): + line = line.strip() + if "->" in line: + continue + parts = line.split("/") + if len(parts) >= 2 and parts[0] == remote: + branches.append("/".join(parts[1:])) + return branches diff --git a/tools/private/release/mock_gh.py b/tools/private/release/mock_gh.py new file mode 100644 index 0000000000..3a5a8dde48 --- /dev/null +++ b/tools/private/release/mock_gh.py @@ -0,0 +1,84 @@ +"""In-memory fake for GitHub API.""" + +import re + +from tools.private.release.gh import RELEASE_LABEL + + +class MockGitHub: + def __init__(self, repo: str = "bazel-contrib/rules_python"): + self.repo = repo + self.issues = {} + self.next_issue_num = 1001 + self.prs = {} # num -> pr_info + + def create_issue( + self, title: str, body: str, labels: list[str] | None = None + ) -> int: + issue_num = self.next_issue_num + self.next_issue_num += 1 + self.issues[issue_num] = { + "title": title, + "body": body, + "labels": labels or [], + "number": issue_num, + "url": f"https://github.com/{self.repo}/issues/{issue_num}", + } + return issue_num + + def create_tracking_issue(self, version: str, template_content: str) -> int: + # Strip YAML frontmatter if present (simplified copy from gh.py) + issue_body = template_content + if template_content.startswith("---"): + parts = template_content.split("---", 2) + if len(parts) >= 3: + issue_body = parts[2].strip() + + return self.create_issue( + title=f"Release {version}", body=issue_body, labels=[RELEASE_LABEL] + ) + + def get_issue_body(self, issue_num: int) -> str: + if issue_num not in self.issues: + raise ValueError(f"Issue #{issue_num} not found in MockGitHub") + return self.issues[issue_num]["body"] + + def update_issue_body(self, issue_num: int, body: str): + if issue_num not in self.issues: + raise ValueError(f"Issue #{issue_num} not found in MockGitHub") + self.issues[issue_num]["body"] = body + + def resolve_pr_number(self, pr_ref: str) -> int: + # Real algorithm copy (doesn't require RPCs) + clean_ref = pr_ref.lstrip("#") + if clean_ref.isdigit(): + return int(clean_ref) + + if pr_ref.startswith("http"): + pattern = rf"github\.com/{re.escape(self.repo)}/pull/(\d+)(/|\?|\Z)" + match = re.search(pattern, pr_ref, re.IGNORECASE) + if match: + return int(match.group(1)) + raise ValueError( + f"URL is not for the configured repository ({self.repo}): {pr_ref}" + ) + raise ValueError(f"Could not resolve PR ref: {pr_ref}") + + def get_open_tracking_issues(self, version: str | None = None) -> list[dict]: + results = [] + for issue in self.issues.values(): + if RELEASE_LABEL in issue["labels"]: + if version: + if issue["title"] == f"Release {version}": + results.append(issue) + else: + results.append(issue) + return results + + def get_pr_info(self, pr_num: int) -> dict: + if pr_num in self.prs: + return self.prs[pr_num] + return { + "state": "MERGED", + "mergeCommit": {"oid": f"mock_merge_sha_{pr_num}"}, + } diff --git a/tools/private/release/promote_rc.py b/tools/private/release/promote.py similarity index 75% rename from tools/private/release/promote_rc.py rename to tools/private/release/promote.py index 34c8107af4..66d5b74324 100644 --- a/tools/private/release/promote_rc.py +++ b/tools/private/release/promote.py @@ -18,7 +18,7 @@ ) -class PromoteRc: +class Promote: """Class to promote a release candidate to final release.""" def __init__(self, args, git: Git, gh: GitHub): @@ -52,16 +52,13 @@ def run(self) -> int: else: version = determine_next_version() - latest_rc = get_latest_rc_tag(version, remote=args.remote) - if not latest_rc: - print(f"Error: No release candidate tags found matching {version}-rc*") - return 1 - # Verify final tag doesn't already exist if self.git.tag_exists(version): print(f"Error: Final tag {version} already exists.") return 1 + is_first_release = version.endswith(".0") + # Verify issue can be found issue_num = args.issue if not issue_num: @@ -74,14 +71,20 @@ def run(self) -> int: print(f"Error: Unexpected error finding tracking issue: {e}") return 1 - # 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) + if is_first_release: + latest_rc = get_latest_rc_tag(version, remote=args.remote) + if not latest_rc: + print(f"Error: No release candidate tags found matching {version}-rc*") + return 1 + commit_sha = self.git.get_commit_sha(latest_rc) + else: + latest_rc = None # 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 + # Determine release branch name branch_version = ".".join(version.split(".")[:2]) branch_name = f"release/{branch_version}" remote_branch = f"{args.remote}/{branch_name}" @@ -97,34 +100,39 @@ def run(self) -> int: ) 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: + if is_first_release: + if commit_sha != branch_sha: print( - f"[DRY RUN] Would update tracking issue #{issue_num} with" - f" error status." + 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]})." ) - return 1 + 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 + else: + # Patch release: tag branch head directly + commit_sha = branch_sha + latest_rc = None # 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]} @@ -137,10 +145,15 @@ def run(self) -> int: return 1 # All pre-conditions met, perform modifications + promote_source = ( + latest_rc + if is_first_release + else f"head of {branch_name} ({commit_sha[:8]})" + ) if args.dry_run: print( f"[DRY RUN] Pre-conditions passed successfully for promoting" - f" {latest_rc} to {version}." + f" {promote_source} to {version}." ) print(f"[DRY RUN] Would tag commit {commit_sha[:8]} as {version}") print(f"[DRY RUN] Would push tag {version} to {args.remote}") @@ -149,7 +162,7 @@ def run(self) -> int: return 0 print( - f"Promoting {latest_rc} to final release {version} (commit" + f"Promoting {promote_source} to final release {version} (commit" f" {commit_sha[:8]}) using tracking issue #{issue_num}..." ) @@ -177,9 +190,7 @@ def run(self) -> int: if run_id := os.environ.get("GITHUB_RUN_ID"): release_workflow_url = f"{REPO_URL}/actions/runs/{run_id}" else: - release_workflow_url = ( - f"{REPO_URL}/actions/workflows/release_promote_rc.yaml" - ) + release_workflow_url = f"{REPO_URL}/actions/workflows/release_promote.yaml" comment_body = f"""**New Release Tagged!** 🐍🌿 @@ -197,7 +208,7 @@ def run(self) -> int: def add_parser(cls, subparsers): """Adds parser for promote-rc subcommand.""" parser = subparsers.add_parser( - "promote-rc", + "promote", help="Promote the latest RC to final release.", ) parser.add_argument( diff --git a/tools/private/release/release.py b/tools/private/release/release.py index f008c093b1..72c427a392 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -5,6 +5,8 @@ import sys from tools.private.release.add_backports import AddBackports +from tools.private.release.backport_create_releases import BackportCreateReleases +from tools.private.release.backport_prepare import BackportPrepare from tools.private.release.complete_prepare import CompletePrepare from tools.private.release.complete_sync_changelog import CompleteSyncChangelog from tools.private.release.create_rc import CreateRc @@ -14,7 +16,7 @@ from tools.private.release.on_pr_merged import OnPrMerged from tools.private.release.prepare import Prepare from tools.private.release.process_backports import ProcessBackports -from tools.private.release.promote_rc import PromoteRc +from tools.private.release.promote import Promote cmds = [ DetermineNextVersion, @@ -27,7 +29,9 @@ ProcessBackports, OnPrMerged, CreateRc, - PromoteRc, + Promote, + BackportPrepare, + BackportCreateReleases, ] From 93c68284901636a6066133e33181647c642efe38 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 18 Jul 2026 02:10:07 +0000 Subject: [PATCH 2/3] style(gha): add trailing newline to on_comment.yaml --- .github/workflows/on_comment.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/on_comment.yaml b/.github/workflows/on_comment.yaml index 05074b10ee..7e4645f3fe 100644 --- a/.github/workflows/on_comment.yaml +++ b/.github/workflows/on_comment.yaml @@ -175,4 +175,4 @@ jobs: uses: ./.github/workflows/backport_create_releases.yaml with: issue: ${{ needs.parse_comment.outputs.issue_number }} - secrets: inherit \ No newline at end of file + secrets: inherit From f4920cb69cc71bce8bc587ef79d388aede850717 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Sat, 18 Jul 2026 03:13:09 +0000 Subject: [PATCH 3/3] fix(release): slice version to major/minor in backport_prepare --- .../private/release/backport_prepare_test.py | 31 +++++++++++++++++++ tools/private/release/backport_prepare.py | 6 ++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/tests/tools/private/release/backport_prepare_test.py b/tests/tools/private/release/backport_prepare_test.py index 70d074cd94..26883b78b3 100644 --- a/tests/tools/private/release/backport_prepare_test.py +++ b/tests/tools/private/release/backport_prepare_test.py @@ -156,6 +156,37 @@ def test_prepare_manual_success(self): self.assertIn("- [ ] Track Release 1.7.2", body) self.assertIn("- [ ] Track Release 1.8.1", body) + def test_prepare_manual_with_patch_versions(self): + # Arrange + args = argparse.Namespace( + issue=None, + pr="#456", + from_minor="1.7.0", + to_minor="1.8.0", + remote="my-remote", + dry_run=False, + ) + self.gh.prs[456] = { + "state": "MERGED", + "mergeCommit": {"oid": "pr_merge_sha_12345"}, + } + self.mock_git.get_remote_branches.return_value = [ + "release/1.7", + "release/1.8", + ] + self.mock_git.get_current_branch.return_value = "work-branch" + self.mock_det.side_effect = ["1.7.2", "1.8.1"] + + # Act + result = BackportPrepare(args, self.mock_git, self.gh).run() + + # Assert + self.assertEqual(result, 0) + self.assertIn(1001, self.gh.issues) + body = self.gh.issues[1001]["body"] + self.assertIn("- [x] Verify apply 1.7 | status=success", body) + self.assertIn("- [x] Verify apply 1.8 | status=success", body) + def test_prepare_verify_failed(self): # Arrange args = argparse.Namespace( diff --git a/tools/private/release/backport_prepare.py b/tools/private/release/backport_prepare.py index e0b8dc5b05..459e91f703 100644 --- a/tools/private/release/backport_prepare.py +++ b/tools/private/release/backport_prepare.py @@ -45,9 +45,9 @@ def get_target_branches(git, remote, from_minor, to_minor): branches = git.get_remote_branches(remote) target_branches = [] - # Parse version strings to compare - from_v = [int(x) for x in from_minor.split(".")] - to_v = [int(x) for x in to_minor.split(".")] + # Parse version strings to compare (only major and minor) + from_v = [int(x) for x in from_minor.split(".")][:2] + to_v = [int(x) for x in to_minor.split(".")][:2] for branch in branches: match = re.match(r"^release/(\d+)\.(\d+)$", branch)