From a3de0fc78d867d50abc0d397eb00ffb2fbc61077 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Wed, 8 Apr 2026 10:30:48 -0400 Subject: [PATCH] ci: add automatic backport workflow (#24025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a GitHub Actions workflow that automatically cherry-picks merged PRs to the last 3 release branches when the `backport` label is applied. ## How it works 1. Add the `backport` label to any PR targeting `main` (before or after merge). 2. On merge (or on label if already merged), the workflow discovers the latest 3 `release/*` branches by semver. 3. For each branch, it cherry-picks the merge commit (`-x -m1`) and opens a PR. Created backport PRs follow existing repo conventions: - **Branch:** `backport/-to-` - **Title:** ` (#)` — e.g. `fix(site): correct button alignment (#12345)` - **Body:** links back to the original PR and merge commit If cherry-pick has conflicts, the PR is still opened with instructions for manual resolution — no conflict markers are committed. Also: - Removes `scripts/backport-pr.sh` (replaced by this workflow) - Removes `.github/cherry-pick-bot.yml` (old bot config) - Adds a section to the contributing docs explaining how to use the backport label > [!NOTE] > Generated with [Coder Agents](https://coder.com/agents) --- .github/workflows/backport.yaml | 174 ++++++++++++++++++++++++ docs/about/contributing/CONTRIBUTING.md | 22 +-- 2 files changed, 187 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/backport.yaml diff --git a/.github/workflows/backport.yaml b/.github/workflows/backport.yaml new file mode 100644 index 0000000000..89064d89fb --- /dev/null +++ b/.github/workflows/backport.yaml @@ -0,0 +1,174 @@ +# Automatically backport merged PRs to the last N release branches when the +# "backport" label is applied. Works whether the label is added before or +# after the PR is merged. +# +# Usage: +# 1. Add the "backport" label to a PR targeting main. +# 2. When the PR merges (or if already merged), the workflow detects the +# latest release/* branches and opens one cherry-pick PR per branch. +# +# The created backport PRs follow existing repo conventions: +# - Branch: backport/-to- +# - Title: (#) +# - Body: links back to the original PR and merge commit + +name: Backport +on: + pull_request_target: + branches: + - main + types: + - closed + - labeled + +permissions: + contents: write + pull-requests: write + +# Prevent duplicate runs for the same PR when both 'closed' and 'labeled' +# fire in quick succession. +concurrency: + group: backport-${{ github.event.pull_request.number }} + +jobs: + detect: + name: Detect target branches + if: > + github.event.pull_request.merged == true && + contains(github.event.pull_request.labels.*.name, 'backport') + runs-on: ubuntu-latest + outputs: + branches: ${{ steps.find.outputs.branches }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # Need all refs to discover release branches. + fetch-depth: 0 + + - name: Find latest release branches + id: find + run: | + # List remote release branches matching the exact release/2.X + # pattern (no suffixes like release/2.31_hotfix), sort by minor + # version descending, and take the top 3. + BRANCHES=$( + git branch -r \ + | grep -E '^\s*origin/release/2\.[0-9]+$' \ + | sed 's|.*origin/||' \ + | sort -t. -k2 -n -r \ + | head -3 + ) + + if [ -z "$BRANCHES" ]; then + echo "No release branches found." + echo "branches=[]" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Convert to JSON array for the matrix. + JSON=$(echo "$BRANCHES" | jq -Rnc '[inputs | select(length > 0)]') + echo "branches=$JSON" >> "$GITHUB_OUTPUT" + echo "Will backport to: $JSON" + + backport: + name: "Backport to ${{ matrix.branch }}" + needs: detect + if: needs.detect.outputs.branches != '[]' + runs-on: ubuntu-latest + strategy: + matrix: + branch: ${{ fromJson(needs.detect.outputs.branches) }} + fail-fast: false + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_URL: ${{ github.event.pull_request.html_url }} + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # Full history required for cherry-pick. + fetch-depth: 0 + + - name: Cherry-pick and open PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + RELEASE_VERSION="${{ matrix.branch }}" + # Strip the release/ prefix for naming. + VERSION="${RELEASE_VERSION#release/}" + BACKPORT_BRANCH="backport/${PR_NUMBER}-to-${VERSION}" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # Check if backport branch already exists (idempotency for re-runs). + if git ls-remote --exit-code origin "refs/heads/${BACKPORT_BRANCH}" >/dev/null 2>&1; then + echo "Backport branch ${BACKPORT_BRANCH} already exists, skipping." + exit 0 + fi + + # Create the backport branch from the target release branch. + git checkout -b "$BACKPORT_BRANCH" "origin/${RELEASE_VERSION}" + + # Cherry-pick the merge commit. Use -x to record provenance and + # -m1 to pick the first parent (the main branch side). + CONFLICTS=false + if ! git cherry-pick -x -m1 "$MERGE_SHA"; then + echo "::warning::Cherry-pick to ${RELEASE_VERSION} had conflicts." + CONFLICTS=true + + # Abort the failed cherry-pick and create an empty commit + # explaining the situation. + git cherry-pick --abort + git commit --allow-empty -m "Cherry-pick of #${PR_NUMBER} requires manual resolution + + The automatic cherry-pick of ${MERGE_SHA} to ${RELEASE_VERSION} had conflicts. + Please cherry-pick manually: + + git cherry-pick -x -m1 ${MERGE_SHA}" + fi + + git push origin "$BACKPORT_BRANCH" + + TITLE="${PR_TITLE} (#${PR_NUMBER})" + BODY=$(cat <