From d9a0480f0a4693efa4d1d26782999abbcc50b983 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Fri, 19 Jun 2026 07:44:49 +0000 Subject: [PATCH 1/4] Implement and automate rules_python release pipeline and workflows Overhauls the release pipeline into a fully automated, robust, and modular architecture: - Created utils.py, git.py, and gh.py to encapsulate shell executions, git helpers, and GitHub CLI wrappers under clean, prefix-free module namespaces. - Overhauled release.py to use these modules, return clean exit codes, and support dynamic RC tagging and checklist gating. - Overhauled cmd_prepare to make the preparation pipeline fully unconditional (cuts branch, commits, pushes, opens PR, and creates/updates tracking issue). - Integrated news processing directly into cmd_process_backports to merge and amend changelogs per backport. - Created GitHub Actions workflows to automate all release phases (prepare, branch cut, backports, RC tagging, and promotion). - Added comprehensive unit test suites in release_test.py mocking the new module-level namespace helpers. --- .agents/plans/release_automation_plan.md | 186 ++++ .../release_tracking_template.md | 26 + .github/workflows/cut_release_branch.yml | 37 + .github/workflows/generate_rc.yml | 39 + .../on_prepare_release_pr_merged.yml | 35 + .github/workflows/prepare_release.yml | 36 + .github/workflows/process_backports.yml | 40 + .github/workflows/promote_rc.yml | 39 + tests/tools/private/release/release_test.py | 40 +- tools/private/release/BUILD.bazel | 7 +- tools/private/release/gh.py | 179 ++++ tools/private/release/git.py | 126 +++ tools/private/release/release.py | 854 ++++++++++++++++-- tools/private/release/utils.py | 28 + 14 files changed, 1583 insertions(+), 89 deletions(-) create mode 100644 .agents/plans/release_automation_plan.md create mode 100644 .github/ISSUE_TEMPLATE/release_tracking_template.md create mode 100644 .github/workflows/cut_release_branch.yml create mode 100644 .github/workflows/generate_rc.yml create mode 100644 .github/workflows/on_prepare_release_pr_merged.yml create mode 100644 .github/workflows/prepare_release.yml create mode 100644 .github/workflows/process_backports.yml create mode 100644 .github/workflows/promote_rc.yml create mode 100644 tools/private/release/gh.py create mode 100644 tools/private/release/git.py create mode 100644 tools/private/release/utils.py diff --git a/.agents/plans/release_automation_plan.md b/.agents/plans/release_automation_plan.md new file mode 100644 index 0000000000..90d87bc2a3 --- /dev/null +++ b/.agents/plans/release_automation_plan.md @@ -0,0 +1,186 @@ +# Plan for Better `rules_python` Release Automation + +The current release process (as described in `RELEASING.md`) has good +automation *after* a tag is pushed (`release.yml` handles GitHub Release, BCR +PR, and PyPI publishing). However, the steps *leading up* to the tag push are +largely manual. + +This plan outlines a **code-centric, reactive architecture** to automate the +manual preparation, branching, and tagging phases. By moving all core Git and +GitHub CLI logic out of the YAML workflow files and into the Python release +tool (`release.py`), we ensure the release process is robust, locally testable, and +independent of GitHub Actions runner syntax. + +## Prerequisite: Release Tracking Issue + +Every release is tracked by a dedicated GitHub Issue titled `Release ` +(e.g., `Release 0.38.0`), which acts as the single source of truth and state +controller. Release tracking issues are uniquely identified by the +`type:release` label. + +* **Role:** Contains a checklist of completed and remaining steps. +* **Automation Hub:** Workflows reactively trigger on tracking issue modifications. +* **Template File:** The issue body structure is decoupled from the codebase and + resides in `.github/ISSUE_TEMPLATE/release_tracking_template.md`. This is a + standard GitHub issue template equipped with frontmatter so maintainers can + easily create tracking issues manually from the GitHub web UI or let the + automation generate them programmatically. +* **Available Commands:** Placed at the very end of the issue body inside a + collapsed HTML `
` section to keep the main checklist clean. It + contains direct web links to the corresponding manual GitHub Action + workflows. + +### Tracking Issue Checklist Syntax + +The checklist uses a strict, machine-readable syntax using a pipe `|` separator +to attach space-separated metadata keys to tasks. + +#### Main Tasks Checklist + +```markdown +# Release tasks +- [ ] Prepare Release | status=pending pr=#1234 +- [ ] Create Release branch +- [ ] Tag RC0 +- [ ] Tag Final +``` + +* **Prepare Release**: + * Initial: `- [ ] Prepare Release | status=awaiting-preparation` + * Phase 1 PR created: `- [ ] Prepare Release | status=pending pr=#` + * PR merged (Phase 2): `- [x] Prepare Release | status=done pr=# commit=` +* **Create Release branch**: + * Initial: `- [ ] Create Release branch` + * Created: `- [x] Create Release branch | status=done branch=release/X.Y commit=` +* **Tag RC0**: + * Initial: `- [ ] Tag RC0` + * Tagged: `- [x] Tag RC0 | status=done tag=vX.Y.Z-rc0 commit=` +* **Tag Final**: + * Initial: `- [ ] Tag Final` + * Tagged: `- [x] Tag Final | status=done tag=vX.Y.Z commit=` + +#### Backports Checklist + +Maintainers list PRs to backport under the `## Backports` section: + +```markdown +- [x] #1234 | status=done rc=rc1 commit=deadbeef +- [ ] #2345 | status=merge-conflict +- [ ] #3456 +``` + +* **Pending:** `- [ ] #3456` (or `- [ ] #3456 | status=pending`) +* **Succeeded Cherry-pick:** `- [x] #1234 | status=done rc=rc commit=` (with checkbox marked `- [x]` to show it has been successfully completed). +* **Conflicting Cherry-pick:** `- [ ] #2345 | status=merge-conflict` (Unchecked, i.e., remains `- [ ]` to indicate it is not complete. Gates subsequent RC tags until resolved or removed). +* **Unmerged PR Error:** `- [ ] #3456 | status=unmerged-pr` (Unchecked, i.e., remains `- [ ]` to indicate it is not complete. Gates subsequent RC tags until resolved or removed). + +--- + +## Release Tool Commands + +The `release.py` script contains all execution logic. + +### 1. `determine-next-version` +* **Description:** Scans git tags and placeholders to determine the next release version. +* **Inputs:** None. +* **Outputs:** Prints the version string (e.g. `0.38.0`) to stdout. + +### 2. `create-release-issue` +* **Description:** Creates the tracking issue on GitHub using `release_tracking_template.md`. Exits with code `1` and prints a list of open tracking issue titles/URLs if a release is already in progress. +* **Inputs:** `--version ` (optional). + * *Version Resolution:* If not specified, determined automatically by calling `determine_next_version()`. + +### 3. `prepare` +* **Description:** Updates the changelog and placeholders. +* **Inputs:** `[version]` (optional), `--issue ` (optional). + * *Version Resolution:* If not specified, determined automatically by calling `determine_next_version()`. +* **Pre-checks:** + * If `--automation` is set, it first fetches upstream tags/commits and verifies that the workspace has **no uncommitted local edits**, exiting with code `1` if the workspace is dirty. +* **Automation Flag (`--automation`):** If set, it pushes to branch `prepare-{version}`, opens the preparation PR, and updates the tracking issue's `Prepare Release` task to `- [ ] Prepare Release | status=pending pr=#`. + +### 4. `complete-prepare` +* **Description:** Triggered when the prep PR merges. +* **Inputs:** `--pr ` (required), `--automation`. +* **Tracking Issue Resolution:** Automatically parses the tracking issue number + directly from the PR body (which links to the tracking issue). +* **State Updates:** + * Updates `Prepare Release` to: `status=done pr=# commit=` (checked). + +### 5. `create-release-branch` +* **Description:** Cuts the release branch. +* **Inputs:** `--issue ` (required), `--automation`. +* **State Updates:** + * Reads the `commit` SHA from the `Prepare Release` task. + * Cuts and pushes the `release/X.Y` branch. + * Updates `Create Release branch` to: `status=done branch=release/X.Y commit=` (checked). + +### 6. `process-backports` +* **Description:** Cherry-picks pending, merged backports. +* **Inputs:** `--issue ` (required), `--automation`. +* **Gating:** Resolves each backport PR. Unmerged PRs are marked as `status=unmerged-pr` (remains unchecked `- [ ]`) and the loop continues. Conflicting cherry-picks are marked as `status=merge-conflict` (remains unchecked `- [ ]`). If any PR is unmerged or cherry-pick fails with a conflict, the tool exits with code `1` at the end of the run. +* **State Updates:** + * Cherry-picks each pending, merged PR using `git cherry-pick -x` in chronological order. + * *Success:** Pushes to the release branch, updates the backport line to: `status=done rc=rc commit=` (with checkbox marked `- [x]` to show it has been successfully completed). + * *Conflict:* Aborts, updates the backport line to: `status=merge-conflict` (remains unchecked `- [ ]`). + * *Unmerged:* Updates the backport line to: `status=unmerged-pr` (remains unchecked `- [ ]`). + +### 7. `create-rc` +* **Description:** Tags the next RC. +* **Inputs:** `--issue ` (required), `--automation`. +* **Gating:** Fails if `Prepare Release` or `Create Release branch` are not `status=done`. Fails if any backport in the list is unchecked (`- [ ]`) or does not have `status=done`. +* **State Updates:** + * Queries git tags, increments to the next RC (e.g. `v0.38.0-rc0`), tags, and pushes. + * If tagging `rc0`, updates `Tag RC0` to: `status=done tag=vX.Y.Z-rc0 commit=` (checked). + * Announces the tag in an issue comment. + +### 8. `promote-rc` +* **Description:** Promotes the highest RC to final. +* **Inputs:** `[version]` (optional), `--issue ` (optional), `--automation`. + * *Version Resolution:* If not specified, determined automatically by finding the next version (which resolves to the active release version if it has not yet been tagged). + * *Issue Resolution:* If `--issue` is not specified, it searches for a single open tracking issue with the `type:release` label and matching version in the title. If zero or multiple tracking issues are found, the command errors and exits with code `1`. +* **State Updates:** + * Checks out the highest RC tag, tags `vX.Y.Z`, and pushes. + * Always attempts to update the tracking issue's `Tag Final` task to: `status=done tag=vX.Y.Z commit=` (checked), outputting a warning if the issue cannot be resolved. + +--- + +## GitHub Actions Workflows + +### 1. Prepare Release (`prepare_release.yml`) +* **Trigger:** Manual (`workflow_dispatch`). +* **Role:** Runs Phase 1. +* **Command:** `bazel run //tools/private/release -- --automation prepare`. +* **State Machine:** Transition: `Prepare Release` $\rightarrow$ `status=pending pr=#`. + +### 2. On PR Merged (`on_prepare_release_pr_merged.yml`) +* **Trigger:** `pull_request: [closed]` (merged, label `release-prepared`). +* **Role:** Completes Phase 1. +* **Command:** `bazel run //tools/private/release -- --automation complete-prepare --pr `. +* **State Machine:** Transition: `Prepare Release` $\rightarrow$ `status=done pr=# commit=` (checked). + +### 3. Cut Release Branch (`cut_release_branch.yml`) +* **Trigger:** `issues: [edited]` (filtered by label `type:release`). +* **Role:** Runs Phase 2 reactively. +* **Command:** `bazel run //tools/private/release -- --automation create-release-branch --issue `. +* **State Machine:** Transition: `Create Release branch` $\rightarrow$ `status=done branch=release/X.Y commit=` (checked). + +### 4. Process Backports (`process_backports.yml`) +* **Trigger:** Manual (`workflow_dispatch`), taking the tracking issue number. +* **Role:** Runs Phase 2.5 backport processing. +* **Command:** `bazel run //tools/private/release -- --automation process-backports --issue `. +* **State Machine:** + * Cherry-pick success: Backport PR $\rightarrow$ `status=done rc=rc commit=` (checked). + * Cherry-pick conflict: Backport PR $\rightarrow$ `status=merge-conflict` (unchecked). + * Unmerged PR error: Backport PR $\rightarrow$ `status=unmerged-pr` (unchecked). + +### 5. Generate RC Tag (`generate_rc.yml`) +* **Trigger:** Manual (`workflow_dispatch`), taking the tracking issue number. +* **Role:** Runs Phase 2.5 RC tagging. +* **Command:** `bazel run //tools/private/release -- --automation create-rc --issue `. +* **State Machine:** Transition: If tagging `rc0`, `Tag RC0` $\rightarrow$ `status=done tag=vX.Y.Z-rc0 commit=` (checked). + +### 6. Promote RC to Final (`promote_rc.yml`) +* **Trigger:** Manual (`workflow_dispatch`), taking the target version. +* **Role:** Runs Phase 3. +* **Command:** `bazel run //tools/private/release -- --automation promote-rc `. +* **State Machine:** Transition: `Tag Final` $\rightarrow$ `status=done tag=vX.Y.Z commit=` (checked). diff --git a/.github/ISSUE_TEMPLATE/release_tracking_template.md b/.github/ISSUE_TEMPLATE/release_tracking_template.md new file mode 100644 index 0000000000..66beea28b7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/release_tracking_template.md @@ -0,0 +1,26 @@ +--- +name: Release Tracking Issue +about: Checklist for tracking a new release of rules_python. +title: 'Release ' +labels: ['type:release'] +--- +# Release tasks +- [ ] Prepare Release | status=awaiting-preparation +- [ ] Create Release branch +- [ ] Tag RC0 +- [ ] Tag Final + +## Backports + + +--- +*Maintainers: Automation will react to changes on this issue.* + +
+Available Commands + +Maintainers can trigger automation by running manual workflows: +- [Process Backports Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/process_backports.yml) +- [Generate RC Tag Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/generate_rc.yml) +- [Promote RC to Final Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/promote_rc.yml) +
diff --git a/.github/workflows/cut_release_branch.yml b/.github/workflows/cut_release_branch.yml new file mode 100644 index 0000000000..cf51f524fc --- /dev/null +++ b/.github/workflows/cut_release_branch.yml @@ -0,0 +1,37 @@ +name: Cut Release Branch + +on: + issues: + types: [edited] + +permissions: + contents: write + issues: write + +jobs: + cut_branch: + # Run only if the issue has the type:release label + if: contains(github.event.issue.labels.*.name, 'type:release') + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bazel + uses: bazel-contrib/setup-bazel@0.0.8 + 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: Attempt Branch Creation + run: | + bazel run //tools/private/release -- \ + create-release-branch --issue ${{ github.event.issue.number }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/generate_rc.yml b/.github/workflows/generate_rc.yml new file mode 100644 index 0000000000..376089e96c --- /dev/null +++ b/.github/workflows/generate_rc.yml @@ -0,0 +1,39 @@ +name: Generate RC Tag + +on: + workflow_dispatch: + inputs: + issue: + description: 'The Release Tracking Issue Number (e.g., 142)' + required: true + type: string + +permissions: + contents: write + issues: write + +jobs: + generate_rc: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bazel + uses: bazel-contrib/setup-bazel@0.0.8 + 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: Attempt RC Tagging + run: | + bazel run //tools/private/release -- \ + create-rc --issue ${{ inputs.issue }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/on_prepare_release_pr_merged.yml b/.github/workflows/on_prepare_release_pr_merged.yml new file mode 100644 index 0000000000..29fc29e6d2 --- /dev/null +++ b/.github/workflows/on_prepare_release_pr_merged.yml @@ -0,0 +1,35 @@ +name: On PR Merged (Release Prepared) + +on: + pull_request: + types: [closed] + +permissions: + contents: write + issues: write + +jobs: + on_pr_merged: + # Run only if the release-prepared PR was merged + if: | + github.event.pull_request.merged == true && + contains(github.event.pull_request.labels.*.name, 'release-prepared') + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bazel + uses: bazel-contrib/setup-bazel@0.0.8 + with: + bazelisk-version: 1.20.0 + + - name: Mark Prepare Release Complete + run: | + # Run the complete-prepare subcommand in the release tool to cleanly update checklist metadata + bazel run //tools/private/release -- \ + complete-prepare --pr ${{ github.event.pull_request.number }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/prepare_release.yml b/.github/workflows/prepare_release.yml new file mode 100644 index 0000000000..f09304ce05 --- /dev/null +++ b/.github/workflows/prepare_release.yml @@ -0,0 +1,36 @@ +name: Prepare Release + +on: + workflow_dispatch: + # Allow manual triggering to prepare the release immediately + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + prepare_release: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bazel + uses: bazel-contrib/setup-bazel@0.0.8 + 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 Release Preparation Pipeline + run: | + # Manual trigger: run full preparation + bazel run //tools/private/release -- prepare + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/process_backports.yml b/.github/workflows/process_backports.yml new file mode 100644 index 0000000000..5634422e43 --- /dev/null +++ b/.github/workflows/process_backports.yml @@ -0,0 +1,40 @@ +name: Process Backports + +on: + workflow_dispatch: + inputs: + issue: + description: 'The Release Tracking Issue Number (e.g., 142)' + required: true + type: string + +permissions: + contents: write + issues: write + +jobs: + process_backports: + # Always gate GHA runs to ensure we are operating on a type:release labeled issue if metadata is queried + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bazel + uses: bazel-contrib/setup-bazel@0.0.8 + 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: Process Pending Backports + run: | + bazel run //tools/private/release -- \ + process-backports --issue ${{ inputs.issue }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/promote_rc.yml b/.github/workflows/promote_rc.yml new file mode 100644 index 0000000000..54d9f87b9e --- /dev/null +++ b/.github/workflows/promote_rc.yml @@ -0,0 +1,39 @@ +name: Promote RC to Final Release + +on: + workflow_dispatch: + inputs: + version: + description: 'The final version to release (e.g., 0.38.0)' + required: true + type: string + +permissions: + contents: write + issues: write + +jobs: + promote: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bazel + uses: bazel-contrib/setup-bazel@0.0.8 + 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 Promote RC + run: | + bazel run //tools/private/release -- \ + promote-rc ${{ inputs.version }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index 3335a5f65f..55515a4bf8 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -458,24 +458,26 @@ def test_replace_version_next_excludes_bazel_dirs(self): def test_valid_version(self): # These should not raise an exception - releaser.create_parser().parse_args(["0.28.0"]) - releaser.create_parser().parse_args(["1.0.0"]) - releaser.create_parser().parse_args(["1.2.3rc4"]) + releaser.create_parser().parse_args(["prepare", "0.28.0"]) + releaser.create_parser().parse_args(["promote-rc", "1.0.0"]) + releaser.create_parser().parse_args( + ["create-release-issue", "--version", "1.2.3rc4"] + ) def test_invalid_version(self): with self.assertRaises(SystemExit): - releaser.create_parser().parse_args(["0.28"]) + releaser.create_parser().parse_args(["prepare", "0.28"]) with self.assertRaises(SystemExit): - releaser.create_parser().parse_args(["a.b.c"]) + releaser.create_parser().parse_args(["prepare", "a.b.c"]) class GetLatestVersionTest(unittest.TestCase): - @patch("tools.private.release.release._get_git_tags") + @patch("tools.private.release.release.git.get_tags") def test_get_latest_version_success(self, mock_get_tags): mock_get_tags.return_value = ["0.1.0", "1.0.0", "0.2.0"] self.assertEqual(releaser.get_latest_version(), "1.0.0") - @patch("tools.private.release.release._get_git_tags") + @patch("tools.private.release.release.git.get_tags") def test_get_latest_version_rc_is_latest(self, mock_get_tags): mock_get_tags.return_value = ["0.1.0", "1.0.0", "1.1.0rc0"] with self.assertRaisesRegex( @@ -483,7 +485,7 @@ def test_get_latest_version_rc_is_latest(self, mock_get_tags): ): releaser.get_latest_version() - @patch("tools.private.release.release._get_git_tags") + @patch("tools.private.release.release.git.get_tags") def test_get_latest_version_no_tags(self, mock_get_tags): mock_get_tags.return_value = [] with self.assertRaisesRegex( @@ -491,7 +493,7 @@ def test_get_latest_version_no_tags(self, mock_get_tags): ): releaser.get_latest_version() - @patch("tools.private.release.release._get_git_tags") + @patch("tools.private.release.release.git.get_tags") def test_get_latest_version_no_matching_tags(self, mock_get_tags): mock_get_tags.return_value = ["v1.0", "latest"] with self.assertRaisesRegex( @@ -499,7 +501,7 @@ def test_get_latest_version_no_matching_tags(self, mock_get_tags): ): releaser.get_latest_version() - @patch("tools.private.release.release._get_git_tags") + @patch("tools.private.release.release.git.get_tags") def test_get_latest_version_only_rc_tags(self, mock_get_tags): mock_get_tags.return_value = ["1.0.0rc0", "1.1.0rc0"] with self.assertRaisesRegex( @@ -564,8 +566,8 @@ def test_both_markers(self): self.assertEqual(next_version, "1.3.0") - @patch("tools.private.release.release._get_current_branch") - @patch("tools.private.release.release._get_git_tags") + @patch("tools.private.release.release.git.get_current_branch") + @patch("tools.private.release.release.git.get_tags") def test_determine_next_version_on_release_branch_with_existing_tags( self, mock_get_tags, mock_get_branch ): @@ -576,8 +578,8 @@ def test_determine_next_version_on_release_branch_with_existing_tags( self.assertEqual(next_version, "0.37.2") - @patch("tools.private.release.release._get_current_branch") - @patch("tools.private.release.release._get_git_tags") + @patch("tools.private.release.release.git.get_current_branch") + @patch("tools.private.release.release.git.get_tags") def test_determine_next_version_on_release_branch_no_tags( self, mock_get_tags, mock_get_branch ): @@ -588,8 +590,8 @@ def test_determine_next_version_on_release_branch_no_tags( self.assertEqual(next_version, "0.38.0") - @patch("tools.private.release.release._get_current_branch") - @patch("tools.private.release.release._get_git_tags") + @patch("tools.private.release.release.git.get_current_branch") + @patch("tools.private.release.release.git.get_tags") def test_determine_next_version_on_release_branch_with_active_rc( self, mock_get_tags, mock_get_branch ): @@ -602,8 +604,8 @@ def test_determine_next_version_on_release_branch_with_active_rc( # Should target 0.37.0, not 0.37.1 self.assertEqual(next_version, "0.37.0") - @patch("tools.private.release.release._get_current_branch") - @patch("tools.private.release.release._get_git_tags") + @patch("tools.private.release.release.git.get_current_branch") + @patch("tools.private.release.release.git.get_tags") def test_determine_next_version_on_release_branch_with_stable_and_active_patch_rc( self, mock_get_tags, mock_get_branch ): @@ -616,7 +618,7 @@ def test_determine_next_version_on_release_branch_with_stable_and_active_patch_r # Should target 0.37.1, not 0.37.2 self.assertEqual(next_version, "0.37.1") - @patch("tools.private.release.release._get_current_branch") + @patch("tools.private.release.release.git.get_current_branch") def test_determine_next_version_on_main_branch_fallback(self, mock_get_branch): mock_get_branch.return_value = "main" # Should fallback to default behavior (which uses mock_get_latest_version from setUp) diff --git a/tools/private/release/BUILD.bazel b/tools/private/release/BUILD.bazel index 31cc3a0239..6697b4df19 100644 --- a/tools/private/release/BUILD.bazel +++ b/tools/private/release/BUILD.bazel @@ -4,7 +4,12 @@ package(default_visibility = ["//visibility:public"]) py_binary( name = "release", - srcs = ["release.py"], + srcs = [ + "gh.py", + "git.py", + "release.py", + "utils.py", + ], main = "release.py", deps = [ "@dev_pip//packaging", diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py new file mode 100644 index 0000000000..c575ce1c16 --- /dev/null +++ b/tools/private/release/gh.py @@ -0,0 +1,179 @@ +"""GitHub CLI helper functions for the release tool.""" + +import json +import os +import tempfile + +from tools.private.release.utils import run_cmd + + +def get_open_tracking_issues(): + """Returns a list of open tracking issues with the 'type:release' label.""" + output = run_cmd( + "gh", + "issue", + "list", + "--label=type:release", + "--state=open", + "--json=number,title,url", + ) + return json.loads(output) if output else [] + + +def resolve_issue_number(version): + """Resolves the tracking issue number for a given version. + + Searches for an open issue with label 'type:release' and 'Release ' in the title. + Raises ValueError if 0 or multiple issues are found. + """ + matching_issues = [] + for issue in get_open_tracking_issues(): + if f"Release {version}" in issue["title"]: + matching_issues.append(issue) + + if not matching_issues: + raise ValueError(f"No open tracking issue found matching 'Release {version}'") + if len(matching_issues) > 1: + urls = [issue["url"] for issue in matching_issues] + raise ValueError( + f"Multiple open tracking issues found for version {version}:\n" + + "\n".join(urls) + ) + + return matching_issues[0]["number"] + + +def create_tracking_issue(version, template_content): + """Creates a new release tracking issue from template content (strips YAML frontmatter).""" + # Strip YAML frontmatter if present + issue_body = template_content + if template_content.startswith("---"): + parts = template_content.split("---", 2) + 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: + f.write(issue_body) + temp_path = f.name + + try: + output = run_cmd( + "gh", + "issue", + "create", + f"--title=Release {version}", + "--label=type:release", + f"--body-file={temp_path}", + ) + 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 get_issue_body(issue_num): + """Fetches the body of a specific issue.""" + return run_cmd( + "gh", + "issue", + "view", + str(issue_num), + "--json=body", + "--jq=.body", + ) + + +def get_issue_title(issue_num): + """Fetches the title of a specific issue.""" + output = run_cmd( + "gh", + "issue", + "view", + str(issue_num), + "--json=title", + ) + return json.loads(output)["title"] if output else "" + + +def update_issue_body(issue_num, body): + """Updates the body of a specific issue.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + f.write(body) + temp_path = f.name + try: + run_cmd( + "gh", + "issue", + "edit", + str(issue_num), + f"--body-file={temp_path}", + capture_output=False, + ) + finally: + if os.path.exists(temp_path): + os.unlink(temp_path) + + +def create_pr(version, branch, issue_num): + """Creates a pull request for release preparation.""" + return run_cmd( + "gh", + "pr", + "create", + f"--title=Prepare release v{version}", + f"--body=Work towards #{issue_num}", + f"--head={branch}", + "--base=main", + "--label=release-prepared", + ) + + +def get_pr_info(pr_num): + """Gets information about a PR, including state, merge commit, and body.""" + output = run_cmd( + "gh", + "pr", + "view", + str(pr_num), + "--json=state,mergeCommit,body", + ) + return json.loads(output) if output else {} + + +def post_issue_comment(issue_num, comment_body): + """Posts a comment to a specific issue.""" + run_cmd( + "gh", + "issue", + "comment", + str(issue_num), + f"--body={comment_body}", + capture_output=False, + ) + + +def resolve_backport_commits(pending_items): + """Resolves PR references in pending backports to their merge commit SHAs. + + Marks unmerged PRs or resolution failures with status='unmerged-pr'. + """ + resolved_items = [] + for item in pending_items: + pr_num = item["pr_ref"].lstrip("#") + print(f"Resolving PR #{pr_num} to merge commit...") + try: + pr_info = get_pr_info(pr_num) + if not pr_info or pr_info.get("state") != "MERGED": + state = pr_info.get("state", "UNKNOWN") + print(f"PR #{pr_num} is not merged (state: {state}). Gating.") + item["status"] = "unmerged-pr" + else: + item["commit"] = pr_info["mergeCommit"]["oid"] + except Exception as e: + print(f"Error resolving PR #{pr_num}: {e}. Gating.") + item["status"] = "unmerged-pr" + resolved_items.append(item) + return resolved_items diff --git a/tools/private/release/git.py b/tools/private/release/git.py new file mode 100644 index 0000000000..1bd3b055cf --- /dev/null +++ b/tools/private/release/git.py @@ -0,0 +1,126 @@ +"""Git helper functions for the release tool.""" + +import subprocess + +from tools.private.release.utils import run_cmd + + +def get_tags(): + """Returns a list of all git tags in the repository.""" + output = run_cmd("git", "tag") + return output.splitlines() if output else [] + + +def checkout(ref, create_branch=False): + """Checks out a git reference (tag, branch, or commit).""" + if create_branch: + run_cmd("git", "checkout", "-b", ref, capture_output=False) + else: + run_cmd("git", "checkout", ref, capture_output=False) + + +def add(*files): + """Stages files for commit.""" + run_cmd("git", "add", *files, capture_output=False) + + +def commit(message, amend=False, no_edit=False): + """Commits staged changes, optionally amending the previous commit.""" + cmd = ["git", "commit"] + if amend: + cmd.append("--amend") + if no_edit: + cmd.append("--no-edit") + if message: + cmd.extend(["-m", message]) + run_cmd(*cmd, capture_output=False) + + +def push(remote, ref): + """Pushes a reference to a remote repository.""" + run_cmd("git", "push", remote, ref, capture_output=False) + + +def fetch(remote="origin", tags=False, force=False): + """Fetches updates from a remote repository.""" + cmd = ["git", "fetch", remote] + if tags: + cmd.append("--tags") + if force: + cmd.append("--force") + run_cmd(*cmd, capture_output=False) + + +def merge(commit_ref, ff_only=True): + """Merges a commit into the current branch.""" + cmd = ["git", "merge", commit_ref] + if ff_only: + cmd.append("--ff-only") + run_cmd(*cmd, capture_output=False) + + +def tag(tag_name): + """Creates a local tag pointing to HEAD.""" + run_cmd("git", "tag", tag_name, capture_output=False) + + +def cherry_pick(sha): + """Cherry-picks a commit using -x to append the original commit info.""" + run_cmd("git", "cherry-pick", "-x", sha, capture_output=False) + + +def cherry_pick_abort(): + """Aborts an in-progress cherry-pick operation.""" + run_cmd("git", "cherry-pick", "--abort", capture_output=False) + + +def status(): + """Returns the output of git status.""" + return run_cmd("git", "status") + + +def get_commit_sha(ref="HEAD", short=False): + """Returns the commit SHA of a given reference.""" + cmd = ["git", "rev-parse"] + if short: + cmd.append("--short") + cmd.append(ref) + return run_cmd(*cmd) + + +def branch_exists(branch_name): + """Returns True if a local branch exists.""" + try: + run_cmd("git", "show-ref", "--verify", f"refs/heads/{branch_name}") + return True + except subprocess.CalledProcessError: + return False + + +def tag_exists(tag_name): + """Returns True if a local tag exists.""" + try: + run_cmd("git", "show-ref", "--verify", f"refs/tags/{tag_name}") + return True + except subprocess.CalledProcessError: + return False + + +def sort_commits_chronologically(shas): + """Sorts a list of commit SHAs chronologically (oldest first).""" + output = run_cmd("git", "log", "--no-walk", "--reverse", "--format=%H", *shas) + return output.splitlines() if output else [] + + +def get_tags_at_head(): + """Returns a list of tags pointing at the current HEAD commit.""" + output = run_cmd("git", "tag", "--points-at", "HEAD") + return output.splitlines() if output else [] + + +def get_current_branch(): + """Returns the current git branch name, or None if not in a git repo.""" + try: + return run_cmd("git", "rev-parse", "--abbrev-ref", "HEAD") + except subprocess.CalledProcessError: + return None diff --git a/tools/private/release/release.py b/tools/private/release/release.py index 4f956bd56c..751f6ba417 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -6,10 +6,14 @@ import os import pathlib import re -import subprocess +import sys from packaging.version import parse as parse_version +from tools.private.release import gh, git + +_REPO_URL = "https://github.com/bazel-contrib/rules_python" + _EXCLUDE_PATTERNS = [ "./.git/*", "./.github/*", @@ -43,16 +47,9 @@ def _iter_version_placeholder_files(): yield filepath -def _get_git_tags(): - """Runs a git command and returns the output.""" - return subprocess.check_output(["git", "tag"]).decode("utf-8").splitlines() - - def get_latest_version(): """Gets the latest version from git tags.""" - tags = _get_git_tags() - # The packaging module can parse PEP440 versions, including RCs. - # It has a good understanding of version precedence. + tags = git.get_tags() versions = [ (tag, parse_version(tag)) for tag in tags @@ -67,15 +64,24 @@ def get_latest_version(): if latest_version.is_prerelease: raise ValueError(f"The latest version is a pre-release version: {latest_tag}") - # After all that, we only want to consider stable versions for the release. stable_versions = [tag for tag, version in versions if not version.is_prerelease] if not stable_versions: raise ValueError("No stable git tags found matching X.Y.Z format.") - # The versions are already sorted, so the last one is the latest. return stable_versions[-1] +def get_latest_rc_tag(version): + """Queries git tags and returns the highest RC tag for the version.""" + tags = git.get_tags() + pattern = rf"^v{re.escape(version)}-rc\d+$" + rc_tags = [tag.strip() for tag in tags if re.match(pattern, tag.strip())] + if not rc_tags: + return None + rc_tags.sort(key=parse_version) + return rc_tags[-1] + + def should_increment_minor(): """Checks if the minor version should be incremented.""" for filepath in _iter_version_placeholder_files(): @@ -83,7 +89,6 @@ def should_increment_minor(): with open(filepath, "r") as f: content = f.read() except (IOError, UnicodeDecodeError): - # Ignore binary files or files with read errors continue if "VERSION_NEXT_FEATURE" in content: @@ -91,25 +96,10 @@ def should_increment_minor(): return False -def _get_current_branch(): - """Returns the current git branch name, or None if not in a git repo.""" - try: - return ( - subprocess.check_output( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], - stderr=subprocess.DEVNULL, - ) - .decode("utf-8") - .strip() - ) - except subprocess.CalledProcessError: - return None - - def determine_next_version(branch_name=None): """Determines the next version based on git tags and the current branch.""" if branch_name is None: - branch_name = _get_current_branch() + branch_name = git.get_current_branch() if branch_name: release_match = re.match(r"^release/(\d+)\.(\d+)$", branch_name) @@ -121,11 +111,7 @@ def determine_next_version(branch_name=None): f" {branch_major}.{branch_minor}.x)" ) - # Find all stable tags matching this major.minor prefix. - # Crucially, we ignore release candidates (RCs) here. If an RC is active - # (e.g. 0.37.0-rc0 exists but 0.37.0 stable does not), we want to continue - # targeting 0.37.0, NOT increment to 0.37.1. - tags = _get_git_tags() + tags = git.get_tags() matching_patches = [] for tag in tags: tag = tag.strip() @@ -143,8 +129,6 @@ def determine_next_version(branch_name=None): ) return next_version else: - # No stable tags exist yet for this release branch (preparing X.Y.0, - # even if X.Y.0-rcN tags already exist) next_version = f"{branch_major}.{branch_minor}.0" print( f"No stable tags found for {branch_major}.{branch_minor}.x." @@ -152,7 +136,6 @@ def determine_next_version(branch_name=None): ) return next_version - # Fallback to default behavior (for main branch or other development branches) latest_version = get_latest_version() major, minor, patch = [int(n) for n in latest_version.split(".")] @@ -202,7 +185,6 @@ def _parse_new_files(news_files): if not content: continue - # Format as list item if not already if not (content.startswith("* ") or content.startswith("- ")): content = f"* {content}" @@ -224,9 +206,7 @@ def generate_release_block(version, release_date, news_entries): "", ] - # Standard categories in preferred order category_order = ["removed", "changed", "fixed", "added"] - # Add any other categories found for cat in news_entries: if cat not in category_order: category_order.append(cat) @@ -236,7 +216,6 @@ def generate_release_block(version, release_date, news_entries): lines.append(f"{{#v{header_version}-{cat}}}") lines.append(f"### {cat.capitalize()}") - # Sort entries by sub-category, then by content sorted_entries = sorted( news_entries[cat], key=lambda e: (_get_sub_category(e), e) ) @@ -266,8 +245,6 @@ def _add_news_to_changelog(changelog_path, version, entries, release_date): return print(f"Version {version} already exists in changelog. Merging news entries...") - # Extract the existing version block - # Match from the version anchor to the next version anchor (or end of file) pattern = ( r"(?P\{#v" + re.escape(header_version) @@ -281,7 +258,6 @@ def _add_news_to_changelog(changelog_path, version, entries, release_date): content_block = match.group("content") - # Split content_block into header and categories category_anchor_pattern = ( r"\{#v" + re.escape(header_version) + r"-(?P[a-z]+)\}" ) @@ -294,7 +270,6 @@ def _add_news_to_changelog(changelog_path, version, entries, release_date): header_str = content_block categories_str = "" - # Parse existing categories existing_entries = {} if categories_str: cat_matches = list(re.finditer(category_anchor_pattern, categories_str)) @@ -325,14 +300,12 @@ def _add_news_to_changelog(changelog_path, version, entries, release_date): cat_entries.append("\n".join(current_entry)) existing_entries[cat] = cat_entries - # Merge news entries merged_entries = dict(existing_entries) for cat, cat_entries in entries.items(): if cat not in merged_entries: merged_entries[cat] = [] merged_entries[cat].extend(cat_entries) - # Reconstruct categories reconstructed_lines = [] category_order = ["removed", "changed", "fixed", "added"] for cat in merged_entries: @@ -357,7 +330,6 @@ def _add_news_to_changelog(changelog_path, version, entries, release_date): header_str.rstrip() + "\n\n" + new_categories_str.strip() + "\n" ) - # Replace in changelog new_content = re.sub( pattern, r"\g\n" + new_release_block.strip() + "\n", @@ -372,7 +344,6 @@ def _add_news_to_changelog(changelog_path, version, entries, release_date): f"Version {version} does not exist in changelog. Creating new" " release section from news entries..." ) - # Extract template template_match = re.search( r"BEGIN_UNRELEASED_TEMPLATE\s*\n(.*?)\n\s*END_UNRELEASED_TEMPLATE", changelog_content, @@ -388,7 +359,6 @@ def _add_news_to_changelog(changelog_path, version, entries, release_date): replacement = f"{unreleased_template}\n\n{new_release_block}\n" - # Replace the active Unreleased section pattern = r"(END_UNRELEASED_TEMPLATE\s*\n-->\s*\n)(.*?)(\n\s*\{#v(?!0-0-0)\d+-\d+-\d+\})" if not re.search(pattern, changelog_content, re.DOTALL): @@ -405,7 +375,6 @@ def _add_news_to_changelog(changelog_path, version, entries, release_date): ) changelog_path_obj.write_text(new_content, encoding="utf-8") else: - # Fallback to old behavior print( f"No news entries found and version {version} does not exist." " Falling back to manual changelog update..." @@ -443,7 +412,6 @@ def update_changelog( _add_news_to_changelog(changelog_path, version, entries, release_date) - # Delete news files after successful update for p in news_files: p.unlink() if news_files: @@ -457,7 +425,6 @@ def replace_version_next(version): with open(filepath, "r") as f: content = f.read() except (IOError, UnicodeDecodeError): - # Ignore binary files or files with read errors continue if "VERSION_NEXT_FEATURE" in content or "VERSION_NEXT_PATCH" in content: @@ -475,43 +442,792 @@ def _semver_type(value): return value +# ============================================================================== +# Checklist Parser and Formatter (Using new | key=value syntax) +# ============================================================================== + + +def parse_metadata_line(line): + """Parses a checklist line with optional | key=value metadata.""" + match = re.match(r"^\s*-\s*\[([ xX])\]\s+([^|]+)(?:\s*\|\s*(.*))?$", line) + if not match: + return None + + checked = match.group(1).lower() == "x" + name = match.group(2).strip() + metadata_str = match.group(3) + + metadata = {} + if metadata_str: + pairs = metadata_str.strip().split() + for pair in pairs: + if "=" in pair: + k, v = pair.split("=", 1) + metadata[k] = v + + return { + "checked": checked, + "name": name, + "metadata": metadata, + "original_line": line, + } + + +def format_metadata_line(checked, name, metadata): + """Formats a checklist line with space-separated key=value metadata.""" + check_str = "x" if checked else " " + if not metadata: + return f"- [{check_str}] {name}" + + metadata_str = " ".join(f"{k}={v}" for k, v in metadata.items()) + return f"- [{check_str}] {name} | {metadata_str}" + + +def update_task_in_body(body, task_name, checked, metadata): + """Updates a specific task's checked state and metadata in the issue body.""" + lines = body.splitlines() + updated_lines = [] + found = False + + for line in lines: + parsed = parse_metadata_line(line) + if parsed and parsed["name"].lower() == task_name.lower(): + updated_lines.append(format_metadata_line(checked, task_name, metadata)) + found = True + else: + updated_lines.append(line) + + if not found: + raise ValueError(f"Task '{task_name}' not found in issue body.") + + return "\n".join(updated_lines) + + +def parse_checklist_state(body): + """Parses the main checklist tasks and their metadata.""" + state = { + "prepare_release": { + "checked": False, + "status": None, + "pr": None, + "commit": None, + }, + "create_branch": { + "checked": False, + "status": None, + "branch": None, + "commit": None, + }, + "tag_final": {"checked": False, "status": None, "tag": None, "commit": None}, + "rc_tags": {}, # Dynamically mapped: int -> metadata dict + } + + lines = body.splitlines() + for line in lines: + parsed = parse_metadata_line(line) + if not parsed: + continue + + name = parsed["name"].strip() + meta = parsed["metadata"] + checked = parsed["checked"] + name_lower = name.lower() + + if "prepare release" in name_lower: + state["prepare_release"] = { + "checked": checked, + "status": meta.get("status"), + "pr": meta.get("pr"), + "commit": meta.get("commit"), + } + elif "create release branch" in name_lower: + state["create_branch"] = { + "checked": checked, + "status": meta.get("status"), + "branch": meta.get("branch"), + "commit": meta.get("commit"), + } + elif "tag final" in name_lower: + state["tag_final"] = { + "checked": checked, + "status": meta.get("status"), + "tag": meta.get("tag"), + "commit": meta.get("commit"), + } + else: + # Match Tag RC + rc_match = re.match(r"Tag RC(\d+)", name, re.IGNORECASE) + if rc_match: + rc_num = int(rc_match.group(1)) + state["rc_tags"][rc_num] = { + "checked": checked, + "status": meta.get("status"), + "tag": meta.get("tag"), + "commit": meta.get("commit"), + } + + return state + + +def parse_backports(body): + """Parses the ## Backports checklist section.""" + match = re.search( + r"## Backports\n(.*?)(?=\n##|\n---|\Z)", body, re.DOTALL | re.IGNORECASE + ) + if not match: + return [] + + section_content = match.group(1) + items = [] + lines = section_content.splitlines() + + for line in lines: + parsed = parse_metadata_line(line) + if parsed: + items.append( + { + "pr_ref": parsed["name"], + "checked": parsed["checked"], + "status": parsed["metadata"].get("status", "PENDING"), + "rc": parsed["metadata"].get("rc"), + "commit": parsed["metadata"].get("commit"), + "metadata": parsed["metadata"], + } + ) + return items + + +# ============================================================================== +# Subcommand Execution Functions +# ============================================================================== + + +def cmd_determine_next_version(args): + """Executes the determine-next-version subcommand.""" + version = determine_next_version() + print(version) + return 0 + + +def cmd_create_release_issue(args): + """Executes the create-release-issue subcommand.""" + version = args.version + if version is None: + version = determine_next_version() + + # Concurrency check + open_issues = gh.get_open_tracking_issues() + if open_issues: + print("Error: A release is already in progress. Active tracking issues:") + for issue in open_issues: + print(f"- {issue['title']}: {issue['url']}") + return 1 + + 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}") + template_content = template_path.read_text(encoding="utf-8") + + issue_num = gh.create_tracking_issue(version, template_content) + print(f"Created tracking issue #{issue_num} for v{version}") + return 0 + + +def cmd_prepare(args): + """Executes the prepare subcommand.""" + print("Fetching upstream to verify fresh release history...") + git.fetch(tags=True, force=True) + + # Run pre-check: verify there are no local edits + status = git.status() + if status: + print( + "Error: Local edits detected. Workspace must be completely clean" + " before running release preparation." + ) + for line in status: + print(f" {line}") + return 1 + print("Pre-check passed: Workspace is clean.") + + version = args.version + if version is None: + version = determine_next_version() + + print(f"Running preparation pipeline for v{version}...") + + branch_name = f"prepare-{version}" + if git.branch_exists(branch_name): + print(f"Branch {branch_name} already exists. Checking it out...") + git.checkout(branch_name) + else: + git.checkout(branch_name, create_branch=True) + + print("Updating changelog and placeholders...") + release_date = datetime.date.today().strftime("%Y-%m-%d") + update_changelog(version, release_date) + replace_version_next(version) + + modified_files = git.status() + if not modified_files: + print("No files modified by the release tool. Nothing to commit.") + return 0 + + # Stage only modified files + for line in modified_files: + file_path = line.strip().split()[-1] + git.add(file_path) + + git.commit(f"Prepare release {version}") + git.push("origin", branch_name) + + issue_num = args.issue + if not issue_num: + open_issues = gh.get_open_tracking_issues() + for issue in open_issues: + if f"Release {version}" in issue["title"]: + issue_num = issue["number"] + break + + if not issue_num: + print( + f"No active tracking issue found for v{version}. Creating a new one..." + ) + 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}") + template_content = template_path.read_text(encoding="utf-8") + issue_num = gh.create_tracking_issue(version, template_content) + + print(f"Using tracking issue #{issue_num}") + + pr_url = gh.create_pr(version, branch_name, issue_num) + pr_num = pr_url.split("/")[-1] + print(f"Created Pull Request: {pr_url} (PR #{pr_num})") + + print(f"Updating tracking issue #{issue_num} checklist status to PENDING...") + body = gh.get_issue_body(issue_num) + metadata = {"status": "pending", "pr": f"#{pr_num}"} + updated_body = update_task_in_body( + body, "Prepare Release", checked=False, metadata=metadata + ) + gh.update_issue_body(issue_num, updated_body) + print("Preparation pipeline completed successfully!") + return 0 + + +def cmd_complete_prepare(args): + """Executes the complete-prepare subcommand (Phase 2 PR merged).""" + print(f"Completing preparation for PR #{args.pr}...") + + pr_info = gh.get_pr_info(args.pr) + if not pr_info or pr_info.get("state") != "MERGED": + state = pr_info.get("state", "UNKNOWN") + print(f"Error: PR #{args.pr} is not merged yet (state: {state}).") + return 1 + + # Resolve issue number from PR body + pr_body = pr_info.get("body", "") + match = re.search(r"Work towards #(\d+)", pr_body) + if not match: + match = re.search(r"#(\d+)", pr_body) + if not match: + print( + f"Error: Could not determine tracking issue number from PR #{args.pr}" + f" body: {pr_body}" + ) + return 1 + + issue_num = int(match.group(1)) + print(f"Resolved tracking issue #{issue_num} from PR #{args.pr} body.") + + commit_sha = pr_info["mergeCommit"]["oid"] + short_commit = commit_sha[:8] + print(f"PR #{args.pr} merged at commit {commit_sha}. Updating tracking issue...") + + # Update checklist: mark Prepare Release as done (checked) and set SUCCESS + body = gh.get_issue_body(issue_num) + metadata = {"status": "done", "pr": f"#{args.pr}", "commit": short_commit} + updated_body = update_task_in_body( + body, "Prepare Release", checked=True, metadata=metadata + ) + gh.update_issue_body(issue_num, updated_body) + print("Prepare Release task marked complete successfully!") + return 0 + + +def cmd_create_release_branch(args): + """Executes the create-release-branch subcommand.""" + print(f"Evaluating branch creation for tracking issue #{args.issue}...") + body = gh.get_issue_body(args.issue) + state = parse_checklist_state(body) + + if ( + state["prepare_release"]["status"] != "done" + or not state["prepare_release"]["commit"] + ): + print( + "Error: Prepare Release task is not marked 'done' with a valid commit SHA." + ) + return 1 + + if state["create_branch"]["checked"]: + print("Release branch has already been created and checked. Skipping.") + return 0 + + # Extract version from issue title + issue_title = gh.get_issue_title(args.issue) + version_match = re.search(r"[0-9]+\.[0-9]+\.[0-9]+(-rc[0-9]+)?", issue_title) + if not version_match: + print(f"Error: Could not parse version from issue title: {issue_title}") + return 1 + + version = version_match.group(0).replace("v", "") + branch_version = ".".join(version.split(".")[:2]) + branch_name = f"release/{branch_version}" + + commit_sha = state["prepare_release"]["commit"] + print(f"Cutting branch {branch_name} from commit {commit_sha}...") + + # Create and push branch + git.fetch("origin") + git.checkout(commit_sha) + + if not git.branch_exists(branch_name): + git.checkout(branch_name, create_branch=True) + else: + git.checkout(branch_name) + git.merge(commit_sha, ff_only=True) + + git.push("origin", branch_name) + print(f"Successfully pushed branch {branch_name}") + + # Update tracking issue checklist + print("Updating tracking issue checklist...") + metadata = {"status": "done", "branch": branch_name, "commit": commit_sha[:8]} + updated_body = update_task_in_body( + body, "Create Release branch", checked=True, metadata=metadata + ) + gh.update_issue_body(args.issue, updated_body) + print("Create Release branch task marked complete successfully!") + return 0 + + +def cmd_process_backports(args): + """Executes the process-backports subcommand.""" + body = gh.get_issue_body(args.issue) + items = parse_backports(body) + + pending_items = [ + item + for item in items + if not item["checked"] and item["status"] != "merge-conflict" + ] + + if not pending_items: + print("No pending backports found.") + return 0 + + print(f"Found {len(pending_items)} pending backports to process.") + + # Determine branch name from issue title + issue_title = gh.get_issue_title(args.issue) + version_match = re.search(r"[0-9]+\.[0-9]+\.[0-9]+(-rc[0-9]+)?", issue_title) + if not version_match: + print(f"Error: Could not parse version from issue title: {issue_title}") + return 1 + + version = version_match.group(0).replace("v", "") + branch_version = ".".join(version.split(".")[:2]) + branch_name = f"release/{branch_version}" + + # Determine next RC tag to write to backport metadata + git.fetch("--tags", "--force") + latest_rc = get_latest_rc_tag(version) + if not latest_rc: + next_rc_suffix = "rc0" + else: + rc_num = int(latest_rc.split("-rc")[-1]) + next_rc_suffix = f"rc{rc_num + 1}" + + # Resolve PRs to merge commits using gh helper + resolved_items = gh.resolve_backport_commits(pending_items) + + shas = [] + sha_to_item = {} + any_failed = False + for item in resolved_items: + if item.get("commit"): + sha = item["commit"] + sha_to_item[sha] = item + shas.append(sha) + else: + any_failed = True + body = update_task_in_body( + body, + item["pr_ref"], + checked=False, + metadata={"status": item.get("status", "failed")}, + ) + gh.update_issue_body(args.issue, body) + + if not shas: + print("No valid merge commits to process.") + if any_failed: + return 1 + return 0 + + # Sort chronologically using git helper + sorted_shas = git.sort_commits_chronologically(shas) + + git.fetch("origin") + git.checkout(branch_name) + + for sha in sorted_shas: + item = sha_to_item[sha] + print(f"Cherry-picking {sha} (PR {item['pr_ref']})...") + try: + git.cherry_pick(sha) + + # Perform news processing (merging news/ files into the changelog) + print(f"Merging news fragments into changelog for PR {item['pr_ref']}...") + release_date = datetime.date.today().strftime("%Y-%m-%d") + update_changelog(version, release_date) + + # Stage changelog changes and news/ deletions + git.add("CHANGELOG.md", "news/") + + # Amend cherry-pick commit to include news merging and deletions + print(f"Amending cherry-pick commit for PR {item['pr_ref']}...") + git.commit("", amend=True, no_edit=True) + + # Push amended commit + git.push("origin", branch_name) + + new_sha = git.get_commit_sha("HEAD", short=True) + metadata = {"status": "done", "rc": next_rc_suffix, "commit": new_sha} + body = update_task_in_body( + body, item["pr_ref"], checked=True, metadata=metadata + ) + gh.update_issue_body(args.issue, body) + print(f"Applied: SUCCESS {new_sha}") + except Exception as e: + print(f"Conflict or error on {sha}: {e}. Aborting.") + try: + git.cherry_pick_abort() + except Exception: + pass + any_failed = True + + body = update_task_in_body( + body, + item["pr_ref"], + checked=False, + metadata={"status": "merge-conflict"}, + ) + gh.update_issue_body(args.issue, body) + print("Updated backport item to status=merge-conflict (unchecked)") + + if any_failed: + print("One or more cherry-picks/resolutions failed.") + return 1 + print("All backports successfully processed!") + return 0 + + +def cmd_create_rc(args): + """Executes the create-rc subcommand.""" + body = gh.get_issue_body(args.issue) + state = parse_checklist_state(body) + + if ( + state["prepare_release"]["status"] != "done" + or state["create_branch"]["status"] != "done" + ): + print( + "Error: Preconditions not met (release must be prepared and branch created)." + ) + return 1 + + # Gating: RC tagging is blocked if any backport is unchecked OR does not have status=done + backports = parse_backports(body) + conflicting_or_pending = [ + b for b in backports if not b["checked"] or b["status"] != "done" + ] + if conflicting_or_pending: + print( + f"Gating RC tagging: {len(conflicting_or_pending)} backports are still" + " unfinished, failed, or in conflict." + ) + return 1 + + # Resolve version and branch + issue_title = gh.get_issue_title(args.issue) + version_match = re.search(r"[0-9]+\.[0-9]+\.[0-9]+(-rc[0-9]+)?", issue_title) + if not version_match: + print(f"Error: Could not parse version from issue title: {issue_title}") + return 1 + + version = version_match.group(0).replace("v", "") + branch_version = ".".join(version.split(".")[:2]) + branch_name = f"release/{branch_version}" + + # Determine next RC tag + git.fetch("--tags", "--force") + latest_rc = get_latest_rc_tag(version) + + if not latest_rc: + next_rc_num = 0 + next_rc = f"v{version}-rc0" + else: + rc_num = int(latest_rc.split("-rc")[-1]) + next_rc_num = rc_num + 1 + next_rc = f"v{version}-rc{next_rc_num}" + + # Precheck: next RC number must exist and be unchecked in the checklist + rc_tags = state.get("rc_tags", {}) + if next_rc_num not in rc_tags: + print( + f"Error: Checklist is missing required task 'Tag RC{next_rc_num}'" + f" to cut v{version}-rc{next_rc_num}." + ) + return 1 + + target_rc_task = rc_tags[next_rc_num] + if target_rc_task["checked"] or target_rc_task["status"] == "done": + print( + f"Error: Task 'Tag RC{next_rc_num}' is already marked done in the checklist." + ) + return 1 + + # Verify HEAD is not already tagged + git.checkout(branch_name) + head_tags = git.get_tags_at_head() + if any(tag.startswith(f"v{version}-rc") for tag in head_tags): + print(f"HEAD of {branch_name} is already tagged with an RC. Skipping.") + return 0 + + print(f"Tagging and pushing next RC: {next_rc}...") + git.tag(next_rc) + git.push("origin", next_rc) + + commit_sha = git.get_commit_sha("HEAD") + + # Check off the appropriate "Tag RC{N}" task in the checklist + print(f"Checking off Tag RC{next_rc_num} task...") + metadata = {"status": "done", "tag": next_rc, "commit": commit_sha[:8]} + task_name = f"Tag RC{next_rc_num}" + updated_body = update_task_in_body(body, task_name, checked=True, metadata=metadata) + gh.update_issue_body(args.issue, updated_body) + + tag_url = f"{_REPO_URL}/releases/tag/{next_rc}" + bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+{version}" + comment_body = f"""🚀 **New Release Candidate Tagged!** + +Release Candidate **{next_rc}** has been successfully generated and tagged on branch `{branch_name}`. + +View Tag: [{next_rc}]({tag_url}) +Track BCR Progress: [Search BCR Pull Requests]({bcr_search_url})""" + gh.post_issue_comment(args.issue, comment_body) + print("RC creation completed successfully!") + return 0 + + +def cmd_promote_rc(args): + """Executes the promote-rc subcommand (Phase 3).""" + version = args.version + if version is None: + version = determine_next_version() + version = version.replace("v", "") + final_tag = f"v{version}" + + git.fetch("--tags", "--force") + latest_rc = get_latest_rc_tag(version) + if not latest_rc: + print(f"Error: No release candidate tags found matching v{version}-rc*") + return 1 + + print(f"Promoting {latest_rc} to final release {final_tag}...") + git.checkout(latest_rc) + + commit_sha = git.get_commit_sha("HEAD") + + if not git.tag_exists(final_tag): + git.tag(final_tag) + git.push("origin", final_tag) + else: + print(f"Final tag {final_tag} already exists.") + + # Resolve issue number + issue_num = args.issue + if not issue_num: + try: + issue_num = gh.resolve_issue_number(version) + except Exception as e: + print(f"Warning: Could not query GitHub to find tracking issue: {e}") + + if issue_num: + print(f"Updating tracking issue #{issue_num} checklist...") + body = gh.get_issue_body(issue_num) + metadata = {"status": "done", "tag": final_tag, "commit": commit_sha[:8]} + updated_body = update_task_in_body( + body, "Tag Final", checked=True, metadata=metadata + ) + gh.update_issue_body(issue_num, updated_body) + print("Checklist updated successfully.") + return 0 + else: + print( + "Error: No active tracking issue found or specified. Checklist was not updated." + ) + return 1 + + def create_parser(): - """Creates the argument parser.""" + """Creates the argument parser with subcommands.""" parser = argparse.ArgumentParser( description="Automate release steps for rules_python." ) - parser.add_argument( + + subparsers = parser.add_subparsers( + dest="command", required=True, help="Subcommands" + ) + + # Subcommand: determine-next-version + subparsers.add_parser( + "determine-next-version", + help="Determine the next version and print it, without making any changes.", + ) + + # Subcommand: create-release-issue + create_issue_parser = subparsers.add_parser( + "create-release-issue", + help="Search for open releases and create a new tracking issue.", + ) + create_issue_parser.add_argument( + "--version", + type=_semver_type, + help="The release version (e.g., 0.38.0). If not provided, determined automatically.", + ) + + # Subcommand: prepare + prepare_parser = subparsers.add_parser( + "prepare", + help="Prepare the release (updates changelog, placeholders).", + ) + prepare_parser.add_argument( "version", nargs="?", type=_semver_type, help="The new release version (e.g., 0.28.0). If not provided, " "it will be determined automatically.", ) + prepare_parser.add_argument( + "--issue", + type=int, + help="The tracking issue number (optional, triggers automated branch/PR pipeline).", + ) + + # Subcommand: complete-prepare + complete_prep_parser = subparsers.add_parser( + "complete-prepare", + help="Mark the Prepare Release task as complete in the tracking issue.", + ) + complete_prep_parser.add_argument( + "--pr", + type=int, + required=True, + help="The merged preparation PR number.", + ) + + # Subcommand: create-release-branch + create_branch_parser = subparsers.add_parser( + "create-release-branch", + help="Create the release branch pointing to the merged PR commit.", + ) + create_branch_parser.add_argument( + "--issue", + type=int, + required=True, + help="The tracking issue number (required).", + ) + + # Subcommand: process-backports + process_backports_parser = subparsers.add_parser( + "process-backports", + help="Cherry-pick pending backports listed in the tracking issue.", + ) + process_backports_parser.add_argument( + "--issue", + type=int, + required=True, + help="The tracking issue number (required).", + ) + + # Subcommand: create-rc + create_rc_parser = subparsers.add_parser( + "create-rc", + help="Tags the next RC on the release branch if no backports remain.", + ) + create_rc_parser.add_argument( + "--issue", + type=int, + required=True, + help="The tracking issue number (required).", + ) + + # Subcommand: promote-rc + promote_parser = subparsers.add_parser( + "promote-rc", + help="Promote the latest RC to final release.", + ) + promote_parser.add_argument( + "version", + nargs="?", + type=_semver_type, + help="The final version to release (e.g., 0.38.0).", + ) + promote_parser.add_argument( + "--issue", + type=int, + help="The tracking issue number (optional).", + ) + return parser def main(): - # Change to the workspace root so the script can be run using `bazel run` if "BUILD_WORKSPACE_DIRECTORY" in os.environ: os.chdir(os.environ["BUILD_WORKSPACE_DIRECTORY"]) parser = create_parser() args = parser.parse_args() - version = args.version - if version is None: - print("No version provided, determining next version automatically...") - version = determine_next_version() - print(f"Determined next version: {version}") - - print("Updating changelog ...") - release_date = datetime.date.today().strftime("%Y-%m-%d") - update_changelog(version, release_date) - - print("Replacing VERSION_NEXT placeholders ...") - replace_version_next(version) - - print("Done") + exit_code = 1 + try: + if args.command == "determine-next-version": + exit_code = cmd_determine_next_version(args) + elif args.command == "create-release-issue": + exit_code = cmd_create_release_issue(args) + elif args.command == "prepare": + exit_code = cmd_prepare(args) + elif args.command == "complete-prepare": + exit_code = cmd_complete_prepare(args) + elif args.command == "create-release-branch": + exit_code = cmd_create_release_branch(args) + elif args.command == "process-backports": + exit_code = cmd_process_backports(args) + elif args.command == "create-rc": + exit_code = cmd_create_rc(args) + elif args.command == "promote-rc": + exit_code = cmd_promote_rc(args) + except Exception as e: + print(f"Fatal error executing {args.command}: {e}", file=sys.stderr) + sys.exit(1) + + sys.exit(exit_code if exit_code is not None else 0) if __name__ == "__main__": diff --git a/tools/private/release/utils.py b/tools/private/release/utils.py new file mode 100644 index 0000000000..5a8411b657 --- /dev/null +++ b/tools/private/release/utils.py @@ -0,0 +1,28 @@ +"""Utility functions for the release tool.""" + +import subprocess + + +def run_cmd(*args, check=True, capture_output=True): + """Runs a command as a subprocess with separate arguments (prints command). + + If the command fails, it raises the CalledProcessError after attaching + a detailed note explaining the failure to preserve the stack trace. + """ + cmd = [str(arg) for arg in args] + print(f"Running: {' '.join(cmd)}") + try: + result = subprocess.run( + cmd, + check=check, + stdout=subprocess.PIPE if capture_output else None, + stderr=subprocess.PIPE if capture_output else None, + universal_newlines=True, + ) + return result.stdout.strip() if capture_output else None + except subprocess.CalledProcessError as e: + note = f"Error running command: {' '.join(cmd)}" + if capture_output: + note += f"\nStdout: {e.stdout}\nStderr: {e.stderr}" + e.add_note(note) + raise From c87ef702cc000b1f1720d3eceb7a55579d4fc5ad Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Wed, 24 Jun 2026 04:06:46 +0000 Subject: [PATCH 2/4] Address PR code review comments and align workflow dependencies This commit addresses the 12 code review comments on the release automation PR. It fixes the workspace clean checks by using porcelain git status and line splitting, defensively parses PR merge commits, normalizes line endings, compiles a common and simplified issue title version regex, and updates GitHub Action workflow versions to their latest aligned state. --- .github/workflows/cut_release_branch.yml | 4 ++-- .github/workflows/generate_rc.yml | 4 ++-- .../on_prepare_release_pr_merged.yml | 4 ++-- .github/workflows/prepare_release.yml | 4 ++-- .github/workflows/process_backports.yml | 4 ++-- .github/workflows/promote_rc.yml | 4 ++-- tools/private/release/gh.py | 7 ++++++- tools/private/release/git.py | 4 ++-- tools/private/release/release.py | 19 +++++++++++-------- 9 files changed, 31 insertions(+), 23 deletions(-) diff --git a/.github/workflows/cut_release_branch.yml b/.github/workflows/cut_release_branch.yml index cf51f524fc..136352799e 100644 --- a/.github/workflows/cut_release_branch.yml +++ b/.github/workflows/cut_release_branch.yml @@ -15,12 +15,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Setup Bazel - uses: bazel-contrib/setup-bazel@0.0.8 + uses: bazel-contrib/setup-bazel@0.19.0 with: bazelisk-version: 1.20.0 diff --git a/.github/workflows/generate_rc.yml b/.github/workflows/generate_rc.yml index 376089e96c..5b9d426730 100644 --- a/.github/workflows/generate_rc.yml +++ b/.github/workflows/generate_rc.yml @@ -17,12 +17,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Setup Bazel - uses: bazel-contrib/setup-bazel@0.0.8 + uses: bazel-contrib/setup-bazel@0.19.0 with: bazelisk-version: 1.20.0 diff --git a/.github/workflows/on_prepare_release_pr_merged.yml b/.github/workflows/on_prepare_release_pr_merged.yml index 29fc29e6d2..3d5ebd3c3c 100644 --- a/.github/workflows/on_prepare_release_pr_merged.yml +++ b/.github/workflows/on_prepare_release_pr_merged.yml @@ -17,12 +17,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Setup Bazel - uses: bazel-contrib/setup-bazel@0.0.8 + uses: bazel-contrib/setup-bazel@0.19.0 with: bazelisk-version: 1.20.0 diff --git a/.github/workflows/prepare_release.yml b/.github/workflows/prepare_release.yml index f09304ce05..d09772088f 100644 --- a/.github/workflows/prepare_release.yml +++ b/.github/workflows/prepare_release.yml @@ -14,12 +14,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Setup Bazel - uses: bazel-contrib/setup-bazel@0.0.8 + uses: bazel-contrib/setup-bazel@0.19.0 with: bazelisk-version: 1.20.0 diff --git a/.github/workflows/process_backports.yml b/.github/workflows/process_backports.yml index 5634422e43..6c0f784b43 100644 --- a/.github/workflows/process_backports.yml +++ b/.github/workflows/process_backports.yml @@ -18,12 +18,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Setup Bazel - uses: bazel-contrib/setup-bazel@0.0.8 + uses: bazel-contrib/setup-bazel@0.19.0 with: bazelisk-version: 1.20.0 diff --git a/.github/workflows/promote_rc.yml b/.github/workflows/promote_rc.yml index 54d9f87b9e..b4adb9b38d 100644 --- a/.github/workflows/promote_rc.yml +++ b/.github/workflows/promote_rc.yml @@ -17,12 +17,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Setup Bazel - uses: bazel-contrib/setup-bazel@0.0.8 + uses: bazel-contrib/setup-bazel@0.19.0 with: bazelisk-version: 1.20.0 diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py index c575ce1c16..9fa94eee20 100644 --- a/tools/private/release/gh.py +++ b/tools/private/release/gh.py @@ -171,7 +171,12 @@ def resolve_backport_commits(pending_items): print(f"PR #{pr_num} is not merged (state: {state}). Gating.") item["status"] = "unmerged-pr" else: - item["commit"] = pr_info["mergeCommit"]["oid"] + merge_commit = pr_info.get("mergeCommit") + if merge_commit and "oid" in merge_commit: + item["commit"] = merge_commit["oid"] + else: + print(f"PR #{pr_num} has no merge commit SHA. Gating.") + item["status"] = "unmerged-pr" except Exception as e: print(f"Error resolving PR #{pr_num}: {e}. Gating.") item["status"] = "unmerged-pr" diff --git a/tools/private/release/git.py b/tools/private/release/git.py index 1bd3b055cf..9bfd905109 100644 --- a/tools/private/release/git.py +++ b/tools/private/release/git.py @@ -75,8 +75,8 @@ def cherry_pick_abort(): def status(): - """Returns the output of git status.""" - return run_cmd("git", "status") + """Returns the output of git status --porcelain.""" + return run_cmd("git", "status", "--porcelain") def get_commit_sha(ref="HEAD", short=False): diff --git a/tools/private/release/release.py b/tools/private/release/release.py index 751f6ba417..8f48773ce6 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -26,6 +26,8 @@ "./tests/tools/private/release/*", ] +_RELEASE_TITLE_RE = re.compile(r"Release\s+(\d+\.\d+\.\d+)", re.IGNORECASE) + def _iter_version_placeholder_files(): for root, dirs, files in os.walk(".", topdown=True): @@ -571,6 +573,7 @@ def parse_checklist_state(body): def parse_backports(body): """Parses the ## Backports checklist section.""" + body = body.replace("\r\n", "\n") match = re.search( r"## Backports\n(.*?)(?=\n##|\n---|\Z)", body, re.DOTALL | re.IGNORECASE ) @@ -645,7 +648,7 @@ def cmd_prepare(args): "Error: Local edits detected. Workspace must be completely clean" " before running release preparation." ) - for line in status: + for line in status.splitlines(): print(f" {line}") return 1 print("Pre-check passed: Workspace is clean.") @@ -674,7 +677,7 @@ def cmd_prepare(args): return 0 # Stage only modified files - for line in modified_files: + for line in modified_files.splitlines(): file_path = line.strip().split()[-1] git.add(file_path) @@ -779,12 +782,12 @@ def cmd_create_release_branch(args): # Extract version from issue title issue_title = gh.get_issue_title(args.issue) - version_match = re.search(r"[0-9]+\.[0-9]+\.[0-9]+(-rc[0-9]+)?", issue_title) + version_match = _RELEASE_TITLE_RE.search(issue_title) if not version_match: print(f"Error: Could not parse version from issue title: {issue_title}") return 1 - version = version_match.group(0).replace("v", "") + version = version_match.group(1) branch_version = ".".join(version.split(".")[:2]) branch_name = f"release/{branch_version}" @@ -834,12 +837,12 @@ def cmd_process_backports(args): # Determine branch name from issue title issue_title = gh.get_issue_title(args.issue) - version_match = re.search(r"[0-9]+\.[0-9]+\.[0-9]+(-rc[0-9]+)?", issue_title) + version_match = _RELEASE_TITLE_RE.search(issue_title) if not version_match: print(f"Error: Could not parse version from issue title: {issue_title}") return 1 - version = version_match.group(0).replace("v", "") + version = version_match.group(1) branch_version = ".".join(version.split(".")[:2]) branch_name = f"release/{branch_version}" @@ -965,12 +968,12 @@ def cmd_create_rc(args): # Resolve version and branch issue_title = gh.get_issue_title(args.issue) - version_match = re.search(r"[0-9]+\.[0-9]+\.[0-9]+(-rc[0-9]+)?", issue_title) + version_match = _RELEASE_TITLE_RE.search(issue_title) if not version_match: print(f"Error: Could not parse version from issue title: {issue_title}") return 1 - version = version_match.group(0).replace("v", "") + version = version_match.group(1) branch_version = ".".join(version.split(".")[:2]) branch_name = f"release/{branch_version}" From ef72c54822fd8abbcb02d35a919a167f5a39bf8b Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Wed, 24 Jun 2026 04:07:53 +0000 Subject: [PATCH 3/4] Simplify release title regex spacing to match exactly one space --- tools/private/release/release.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/private/release/release.py b/tools/private/release/release.py index 8f48773ce6..d7ae61fe82 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -26,7 +26,7 @@ "./tests/tools/private/release/*", ] -_RELEASE_TITLE_RE = re.compile(r"Release\s+(\d+\.\d+\.\d+)", re.IGNORECASE) +_RELEASE_TITLE_RE = re.compile(r"Release (\d+\.\d+\.\d+)", re.IGNORECASE) def _iter_version_placeholder_files(): From 00e39e2faabd4dca262200d8d2617f826cab7b6a Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Wed, 24 Jun 2026 04:18:01 +0000 Subject: [PATCH 4/4] Upgrade workflow actions to v7 and add creation rule This commit upgrades the actions/checkout version to v7 in the newly added release automation workflow files. It also adds a new project-scoped agent rule configuration under .agents/rules/ to ensure future workflow file creation always adopts the latest action versions. --- .agents/rules/github_actions_workflows.md | 10 ++++++++++ .github/workflows/cut_release_branch.yml | 2 +- .github/workflows/generate_rc.yml | 2 +- .github/workflows/on_prepare_release_pr_merged.yml | 2 +- .github/workflows/prepare_release.yml | 2 +- .github/workflows/process_backports.yml | 2 +- .github/workflows/promote_rc.yml | 2 +- 7 files changed, 16 insertions(+), 6 deletions(-) create mode 100644 .agents/rules/github_actions_workflows.md diff --git a/.agents/rules/github_actions_workflows.md b/.agents/rules/github_actions_workflows.md new file mode 100644 index 0000000000..29bf628a24 --- /dev/null +++ b/.agents/rules/github_actions_workflows.md @@ -0,0 +1,10 @@ +--- +name: github-actions-workflows +trigger: glob +globs: [".github/workflows/*.yml", ".github/workflows/*.yaml", ".github/*.yaml"] +--- + +# GitHub Actions Workflows Rule + +* When creating files in `.github/workflows/` (such as `.yml` or `.yaml` + files), always use the latest version of the referenced GitHub Actions. diff --git a/.github/workflows/cut_release_branch.yml b/.github/workflows/cut_release_branch.yml index 136352799e..b7c7118194 100644 --- a/.github/workflows/cut_release_branch.yml +++ b/.github/workflows/cut_release_branch.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/generate_rc.yml b/.github/workflows/generate_rc.yml index 5b9d426730..26685c2f62 100644 --- a/.github/workflows/generate_rc.yml +++ b/.github/workflows/generate_rc.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/on_prepare_release_pr_merged.yml b/.github/workflows/on_prepare_release_pr_merged.yml index 3d5ebd3c3c..8ad4d1055c 100644 --- a/.github/workflows/on_prepare_release_pr_merged.yml +++ b/.github/workflows/on_prepare_release_pr_merged.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/prepare_release.yml b/.github/workflows/prepare_release.yml index d09772088f..7d5e93aa6b 100644 --- a/.github/workflows/prepare_release.yml +++ b/.github/workflows/prepare_release.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/process_backports.yml b/.github/workflows/process_backports.yml index 6c0f784b43..9a42b6c62c 100644 --- a/.github/workflows/process_backports.yml +++ b/.github/workflows/process_backports.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/promote_rc.yml b/.github/workflows/promote_rc.yml index b4adb9b38d..d5e697f6d7 100644 --- a/.github/workflows/promote_rc.yml +++ b/.github/workflows/promote_rc.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0