ci: add cherry-pick to latest release workflow (#24051)

Adds a GitHub Actions workflow that cherry-picks merged PRs to the
latest release branch when the `cherry-pick` label is applied.

## How it works

1. Add the `cherry-pick` label to any PR targeting `main` (before or
after merge).
2. On merge (or on label if already merged), the workflow detects the
latest `release/*` branch.
3. It cherry-picks the merge commit (`-x -m1`) and opens a PR.

This complements the `backport` label (see #24025) which targets the
latest **3** release branches. `cherry-pick` targets only the **latest**
one — useful for getting fixes into the current release.

Created PRs follow existing repo conventions:
- **Branch:** `backport/<pr>-to-<version>`
- **Title:** `<original PR title> (#<pr>)` — e.g. `fix(site): correct
button alignment (#12345)`
- **Body:** links back to the original PR and merge commit

If the cherry-pick encounters conflicts, the workflow aborts the
cherry-pick, creates an empty commit with resolution instructions, and
opens the PR with a `[CONFLICT]` prefix so the author can resolve
manually.

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 the `cherry-pick`
label

> [!NOTE]
> Generated with [Coder Agents](https://coder.com/agents)
This commit is contained in:
Garrett Delfosse
2026-04-08 10:22:33 -04:00
committed by GitHub
parent c5d720f73d
commit ab77154975
4 changed files with 152 additions and 138 deletions
-2
View File
@@ -1,2 +0,0 @@
enabled: true
preservePullRequestTitle: true
+139
View File
@@ -0,0 +1,139 @@
# Automatically cherry-pick merged PRs to the latest release branch when the
# "cherry-pick" label is applied. Works whether the label is added before or
# after the PR is merged.
#
# Usage:
# 1. Add the "cherry-pick" label to a PR targeting main.
# 2. When the PR merges (or if already merged), the workflow detects the
# latest release/* branch and opens a cherry-pick PR against it.
#
# The created PRs follow existing repo conventions:
# - Branch: backport/<pr>-to-<version>
# - Title: <original PR title> (#<pr>)
# - Body: links back to the original PR and merge commit
name: Cherry-pick to release
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: cherry-pick-${{ github.event.pull_request.number }}
jobs:
cherry-pick:
name: Cherry-pick to latest release
if: >
github.event.pull_request.merged == true &&
contains(github.event.pull_request.labels.*.name, 'cherry-pick')
runs-on: ubuntu-latest
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 and branch discovery.
fetch-depth: 0
- name: Cherry-pick and open PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# Find the latest release branch matching the exact release/2.X
# pattern (no suffixes like release/2.31_hotfix).
RELEASE_BRANCH=$(
git branch -r \
| grep -E '^\s*origin/release/2\.[0-9]+$' \
| sed 's|.*origin/||' \
| sort -t. -k2 -n -r \
| head -1
)
if [ -z "$RELEASE_BRANCH" ]; then
echo "::error::No release branch found."
exit 1
fi
# Strip the release/ prefix for naming.
VERSION="${RELEASE_BRANCH#release/}"
BACKPORT_BRANCH="backport/${PR_NUMBER}-to-${VERSION}"
echo "Target branch: $RELEASE_BRANCH"
echo "Backport branch: $BACKPORT_BRANCH"
# 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 "Branch ${BACKPORT_BRANCH} already exists, skipping."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# Create the backport branch from the target release branch.
git checkout -b "$BACKPORT_BRANCH" "origin/${RELEASE_BRANCH}"
# Cherry-pick the merge commit. Use -x to record provenance and
# -m1 to pick the first parent (the main branch side).
CONFLICT=false
if ! git cherry-pick -x -m1 "$MERGE_SHA"; then
CONFLICT=true
echo "::warning::Cherry-pick to ${RELEASE_BRANCH} had conflicts."
# Abort the failed cherry-pick and create an empty commit with
# instructions so the PR can still be opened.
git cherry-pick --abort
git commit --allow-empty -m "cherry-pick of #${PR_NUMBER} failed — resolve conflicts manually
Cherry-pick of ${MERGE_SHA} onto ${RELEASE_BRANCH} had conflicts.
To resolve:
git fetch origin ${BACKPORT_BRANCH}
git checkout ${BACKPORT_BRANCH}
git cherry-pick -x -m1 ${MERGE_SHA}
# resolve conflicts
git push origin ${BACKPORT_BRANCH}"
fi
git push origin "$BACKPORT_BRANCH"
BODY=$(cat <<EOF
Cherry-pick of ${PR_URL}
Original PR: #${PR_NUMBER} — ${PR_TITLE}
Merge commit: ${MERGE_SHA}
EOF
)
TITLE="${PR_TITLE} (#${PR_NUMBER})"
if [ "$CONFLICT" = true ]; then
TITLE="[CONFLICT] ${TITLE}"
fi
# Check if a PR already exists for this branch (idempotency
# for re-runs). Use --state all to catch closed/merged PRs too.
EXISTING_PR=$(gh pr list --head "$BACKPORT_BRANCH" --base "$RELEASE_BRANCH" --state all --json number --jq '.[0].number // empty')
if [ -n "$EXISTING_PR" ]; then
echo "PR #${EXISTING_PR} already exists for ${BACKPORT_BRANCH}, skipping."
exit 0
fi
gh pr create \
--base "$RELEASE_BRANCH" \
--head "$BACKPORT_BRANCH" \
--title "$TITLE" \
--body "$BODY"
+13
View File
@@ -291,6 +291,19 @@ specification, however, it's still possible to merge PRs on GitHub with a badly
formatted title. Take care when merging single-commit PRs as GitHub may prefer
to use the original commit title instead of the PR title.
### Cherry-picking to the latest release branch
When a merged PR on `main` should also ship in the current release,
add the **`cherry-pick`** label to the PR (before or after merge).
The automation detects the latest `release/*` branch, cherry-picks the
merge commit, and opens a PR for review. The PR reuses the original
title (e.g. `fix(site): correct button alignment (#12345)`) so the
change is meaningful in release notes.
If the cherry-pick encounters conflicts, the PR is still created with
instructions for manual resolution.
### Breaking changes
Breaking changes can be triggered in two ways:
-136
View File
@@ -1,136 +0,0 @@
#!/usr/bin/env bash
# Usage: ./scripts/backport-pr.sh [--dry-run] <release-version> <pr-number>
#
# Backports a merged PR to a release branch by cherry-picking its merge commit
# and opening a new PR targeting the release branch.
#
# Examples:
# ./scripts/backport-pr.sh 2.30 23969
# ./scripts/backport-pr.sh --dry-run 2.30 23969
set -euo pipefail
# shellcheck source=scripts/lib.sh
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
cdroot
dry_run=0
# Parse flags.
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run | -n)
dry_run=1
shift
;;
-*)
error "Unknown flag: $1"
;;
*)
break
;;
esac
done
if [[ $# -lt 2 ]]; then
echo "Usage: $0 [--dry-run] <release-version> <pr-number>" >&2
echo " e.g. $0 2.30 23969" >&2
exit 1
fi
release_version="$1"
pr_number="$2"
release_branch="release/${release_version}"
dependencies gh jq git
# Authenticate with GitHub.
gh_auth
# Validate that the PR exists and is merged.
log "Fetching PR #${pr_number}..."
pr_json=$(gh pr view "$pr_number" --json mergeCommit,title,number,state,headRefName,url)
pr_state=$(echo "$pr_json" | jq -r '.state')
if [[ "$pr_state" != "MERGED" ]]; then
error "PR #${pr_number} is not merged (state: ${pr_state})."
fi
merge_commit=$(echo "$pr_json" | jq -r '.mergeCommit.oid')
pr_title=$(echo "$pr_json" | jq -r '.title')
pr_url=$(echo "$pr_json" | jq -r '.url')
if [[ -z "$merge_commit" || "$merge_commit" == "null" ]]; then
error "Could not determine merge commit for PR #${pr_number}."
fi
log "PR: #${pr_number} - ${pr_title}"
log "Merge commit: ${merge_commit}"
log "Release branch: ${release_branch}"
# Make sure we have the latest refs.
maybedryrun "$dry_run" git fetch origin
# Validate the release branch exists on the remote.
if ! git rev-parse "origin/${release_branch}" >/dev/null 2>&1; then
error "Release branch '${release_branch}' does not exist on origin."
fi
backport_branch="backport/${pr_number}-to-${release_version}"
log "Backport branch: ${backport_branch}"
if [[ "$dry_run" == 1 ]]; then
log ""
log "DRYRUN: Would cherry-pick ${merge_commit} onto ${release_branch} via branch ${backport_branch}"
log "DRYRUN: Would create PR targeting ${release_branch}"
exit 0
fi
# Check for uncommitted changes that would block checkout.
if ! git diff-index --quiet HEAD --; then
error "You have uncommitted changes. Please commit or stash them first."
fi
# Create the backport branch from the release branch.
log "Creating branch ${backport_branch} from origin/${release_branch}..."
git checkout -b "$backport_branch" "origin/${release_branch}"
# Cherry-pick the merge commit.
log "Cherry-picking ${merge_commit}..."
if ! git cherry-pick -x "$merge_commit"; then
log ""
log "Cherry-pick failed due to conflicts."
log "Resolve the conflicts, then run:"
log " git cherry-pick --continue"
log " git push origin ${backport_branch}"
log " gh pr create --base ${release_branch} --head ${backport_branch} --title \"chore: backport #${pr_number} to ${release_version}\" --body \"Backport of ${pr_url}\""
log ""
log "Or abort with: git cherry-pick --abort && git checkout - && git branch -D ${backport_branch}"
exit 1
fi
# Push the backport branch.
log "Pushing ${backport_branch}..."
git push origin "$backport_branch"
# Create the PR.
log "Creating PR..."
backport_pr_url=$(gh pr create \
--draft \
--label "cherry-pick/v${release_version}" \
--base "$release_branch" \
--head "$backport_branch" \
--title "chore: backport #${pr_number} to ${release_version}" \
--body "$(
cat <<EOF
Backport of ${pr_url}
Original PR: #${pr_number} — ${pr_title}
Merge commit: ${merge_commit}
EOF
)")
log ""
log "Backport PR created: ${backport_pr_url}"
# Return to previous branch.
git checkout -