# Automatically backport merged PRs to every actively supported release branch # when the "backport" label is applied. Works whether the label is added before # or after the PR is merged. # # Target branches are the union of: # - the latest 3 release/2.X branches (mainline, stable, security), and # - the active ESR / ESR-1 branches listed in # scripts/release_channels/esr_versions.txt. # The set is de-duplicated, so a branch that is both stable and ESR is # backported once. # # Usage: # 1. Add the "backport" label to a PR targeting main. # 2. When the PR merges (or if already merged), the workflow detects the # target 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 # - Label: backport/v to identify the target release name: Backport on: pull_request_target: branches: - main types: - closed - labeled permissions: {} # 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 permissions: contents: read 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Need all refs to discover release branches. fetch-depth: 0 persist-credentials: false - name: Find target release branches id: find env: ESR_VERSIONS_FILE: scripts/release_channels/esr_versions.txt run: | set -euo pipefail # Mainline, stable, security: the latest 3 release/2.X branches # (exact pattern, no suffixes like release/2.31_hotfix), sorted by # minor version descending. TOP3=$( git branch -r \ | grep -E '^\s*origin/release/2\.[0-9]+$' \ | sed 's|.*origin/||' \ | sort -t. -k2 -n -r \ | head -3 ) # ESR and ESR-1: the active ESR versions from the shared source of # truth. Each entry maps to the release/ branch. Skip # entries whose branch does not exist. ESR_BRANCHES="" if [ -f "$ESR_VERSIONS_FILE" ]; then while read -r RELEASE; do BRANCH="release/${RELEASE}" if git show-ref --verify --quiet "refs/remotes/origin/${BRANCH}"; then ESR_BRANCHES="${ESR_BRANCHES}${BRANCH}"$'\n' else echo "::warning::ESR branch ${BRANCH} not found, skipping." fi done < <(grep -vE '^\s*(#|$)' "$ESR_VERSIONS_FILE") else echo "::warning::${ESR_VERSIONS_FILE} not found, backporting to latest 3 only." fi # Union the two sets and de-duplicate. BRANCHES=$(printf '%s\n%s\n' "$TOP3" "$ESR_BRANCHES" | sed '/^$/d' | sort -u) 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 permissions: contents: write pull-requests: write # Required to create the release-specific backport label if missing. issues: write 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 }} SENDER: ${{ github.event.sender.login }} BRANCH: ${{ matrix.branch }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Full history required for cherry-pick. fetch-depth: 0 persist-credentials: false - name: Cherry-pick and open PR env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail # Configure git to authenticate pushes with the job token # since persist-credentials is disabled on checkout. git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" RELEASE_VERSION="$BRANCH" # Strip the release/ prefix for naming. VERSION="${RELEASE_VERSION#release/}" BACKPORT_BRANCH="backport/${PR_NUMBER}-to-${VERSION}" # Label applied to the backport PR so PRs for a specific release can # be filtered easily (e.g. backport/v2.34). BACKPORT_LABEL="backport/v${VERSION}" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" # Idempotency: if a backport PR already exists for this branch # (open, closed, or merged), there is nothing to do. This is the # primary guard so a re-run that previously pushed a branch but # failed before opening the PR still recovers below. EXISTING_PR=$(gh pr list --head "$BACKPORT_BRANCH" --base "$RELEASE_VERSION" --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 CONFLICTS=false if git ls-remote --exit-code origin "refs/heads/${BACKPORT_BRANCH}" >/dev/null 2>&1; then # The branch exists from an earlier run that failed before opening # the PR. Reuse it as-is and fall through to PR creation rather # than starting over or bailing out. echo "Backport branch ${BACKPORT_BRANCH} already exists; reusing it to open the PR." else # 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). # # Every target branch is validated to exist in the detect job, so a # failure here is a genuine conflict, not a missing branch. Rather # than abort the whole backport, leave a placeholder commit and open # a PR with copy-paste resolution steps so it can be finished by # hand (or closed) instead of recreated from scratch. 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" fi TITLE="${PR_TITLE} (#${PR_NUMBER})" BODY=$(cat <