ci: backport to ESR and ESR-1 release branches (#27460)

## What

Extend the backport workflow so the `backport` label fans out to **every
actively supported release channel**, not just the latest three minors.

Target branches are now the union of:

- the latest 3 `release/2.X` branches (mainline `n`, stable `n-1`,
security `n-2`), and
- the active **ESR** and **maintenance ESR (ESR-1)** branches.

The set is de-duplicated, so a branch that is both stable and ESR (today
`release/2.34`) is backported once. Dry-run against the current branch
list yields `release/2.29`, `release/2.33`, `release/2.34`,
`release/2.35`.

## Why

ESR / ESR-1 are designated biannually and can sit well below the top-3
window, so the previous `head -3` heuristic silently skipped them (e.g.
the maintenance ESR `release/2.29`). The current ESR was only covered by
coincidence when it happened to equal stable.

## Changes

- Add `scripts/release_channels/esr_versions.txt` as the single source
of truth for active ESR minors.
- `scripts/update-release-calendar.sh` now reads that file instead of a
hardcoded `ESR_VERSIONS` array (calendar output verified unchanged).
- `backport.yaml` `detect` job unions the latest 3 branches with the ESR
branches (existence-checked, warns and skips missing ones) and
de-duplicates.
- Backport PRs now get a `backport/v<version>` label, mirroring
`cherry-pick.yaml`, with `issues: write` added to create the label.

### Resilience to partial failures

Even with the independent matrix (`fail-fast: false`), a single branch's
job could previously abort without leaving anything behind, forcing the
remaining branches to be backported entirely by hand. Fixed so each
branch always ends with a PR (real or placeholder):

- Label, assignee, and reviewer are attached **after** the PR is
created, as best-effort steps. Requesting review from / assigning the PR
author is rejected by GitHub, which previously aborted `gh pr create`
under `set -e` and left no PR.
- Idempotency now keys off an existing backport **PR** rather than the
branch, and an existing backport branch is reused instead of bailing, so
a re-run recovers a branch that was pushed before its PR was opened.
- The workflow now comments on the original PR with each created
backport link, flagging conflicts that still need manual resolution.
- Conflicting cherry-picks continue to open a placeholder PR with
copy-paste resolution steps.

## Validation

- `actionlint`, `shellcheck -x`, and `zizmor` all pass.
- Re-ran `update-release-calendar.sh`; ESR statuses (`2.29 Extended
Support Release`, `2.34 Stable (ESR)`) are identical after the refactor.
- Dry-ran the detection logic against the live branch list (see set
above).

<details>
<summary>Implementation plan</summary>

# Plan: Backport to all supported release channels (mainline, stable,
security, ESR, ESR-1)

## Goal

The backport GitHub Action should open cherry-pick PRs against every
actively supported release branch:

| Channel | Meaning | Example today |

|-------------------------|-----------------------------|----------------|
| Mainline | last release (n) | `release/2.35` |
| Stable | n-1 | `release/2.34` |
| Security Support | n-2 | `release/2.33` |
| ESR | current Extended Support | `release/2.34` |
| Maintenance ESR (ESR-1) | previous ESR still patched | `release/2.29`
|

All channels map to `release/2.X` branches.

## What we targeted before

`.github/workflows/backport.yaml` took the exact `release/2.X` branches,
sorted by minor descending, and kept the top 3
(mainline/stable/security). ESR and ESR-1 are not derivable from version
ordering, so the maintenance ESR was silently skipped.

## Source of truth for ESR branches

`scripts/update-release-calendar.sh` already encoded the active ESR
minors (`ESR_VERSIONS=(29 34)`), driving the release calendar. Rather
than maintaining a second list, this list was extracted into a shared
data file consumed by both the calendar script and the workflow.

## Changes

1. Extract the ESR minors into
`scripts/release_channels/esr_versions.txt`; update
`update-release-calendar.sh` to read it.
2. Extend the `detect` job to emit the union of the top-3 branches and
one `release/2.<minor>` per ESR entry, existence-checked and
de-duplicated.
3. Add per-release `backport/v<version>` labels (with `issues: write`),
mirroring the cherry-pick workflow.

## Assumptions

- Major version is always `2` (matches existing code).
- The ESR list is maintained manually when ESR versions change.
- `cherry-pick.yaml` stays single-branch and is out of scope.
- Missing ESR branches are skipped with a warning, not a failure.

</details>

---
*Opened by Coder Agents on behalf of @f0ssel.*
This commit is contained in:
Garrett Delfosse
2026-07-24 12:13:19 -04:00
committed by GitHub
parent 0f1eafa17e
commit 76b35edaff
3 changed files with 135 additions and 45 deletions
+112 -40
View File
@@ -1,16 +1,24 @@
# 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.
# 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
# latest release/* branches and opens one cherry-pick PR per branch.
# target release/* branches and opens one cherry-pick PR per branch.
#
# The created backport 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
# - Label: backport/v<version> to identify the target release
name: Backport
on:
@@ -46,13 +54,17 @@ jobs:
fetch-depth: 0
persist-credentials: false
- name: Find latest release branches
- name: Find target release branches
id: find
env:
ESR_VERSIONS_FILE: scripts/release_channels/esr_versions.txt
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=$(
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/||' \
@@ -60,6 +72,26 @@ jobs:
| head -3
)
# ESR and ESR-1: the active ESR versions from the shared source of
# truth. Each entry maps to the release/<version> 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"
@@ -77,6 +109,8 @@ jobs:
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:
@@ -112,37 +146,58 @@ jobs:
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"
# 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."
# 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
# 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
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}"
# 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
# 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
fi
git push origin "$BACKPORT_BRANCH"
git push origin "$BACKPORT_BRANCH"
fi
TITLE="${PR_TITLE} (#${PR_NUMBER})"
BODY=$(cat <<EOF
@@ -171,18 +226,35 @@ jobs:
> \`\`\`"
fi
# Check if a PR already exists for this branch (idempotency
# for re-runs).
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
# Ensure the release-specific label exists. Best-effort: label
# problems must never prevent the PR from being opened.
gh label create "$BACKPORT_LABEL" \
--description "Backport PR targeting ${RELEASE_VERSION}" \
--color "D93F0B" \
--force || echo "::warning::Could not create label ${BACKPORT_LABEL}."
gh pr create \
--base "$RELEASE_VERSION" \
--head "$BACKPORT_BRANCH" \
--title "$TITLE" \
--body "$BODY" \
--assignee "$SENDER" \
--reviewer "$SENDER"
# Create the PR first, then attach label/assignee/reviewer
# separately. Requesting a review from (or assigning) the PR author
# is rejected by GitHub, and doing it inline with `gh pr create`
# under `set -e` would abort the job and leave no PR. Attaching them
# afterwards as best-effort guarantees the PR always gets created.
NEW_PR_URL=$(
gh pr create \
--base "$RELEASE_VERSION" \
--head "$BACKPORT_BRANCH" \
--title "$TITLE" \
--body "$BODY"
)
gh pr edit "$NEW_PR_URL" --add-label "$BACKPORT_LABEL" || echo "::warning::Could not add label ${BACKPORT_LABEL} to ${NEW_PR_URL}."
gh pr edit "$NEW_PR_URL" --add-assignee "$SENDER" || echo "::warning::Could not assign @${SENDER} to ${NEW_PR_URL}."
gh pr edit "$NEW_PR_URL" --add-reviewer "$SENDER" || echo "::warning::Could not request review from @${SENDER} on ${NEW_PR_URL}."
# Notify the requester on the original PR which backport was opened,
# flagging conflicts that still need manual resolution. Best-effort:
# don't fail the job if the original PR is locked.
COMMENT="Backport to \`${RELEASE_VERSION}\` created: ${NEW_PR_URL}"
if [ "$CONFLICTS" = true ]; then
COMMENT="${COMMENT} (:warning: conflicts need manual resolution)"
fi
gh pr comment "$PR_NUMBER" --body "$COMMENT" || echo "::warning::Failed to comment on #${PR_NUMBER} (PR may be locked)."