From 76b35edaff64dc15fded863acd80f5df6c5479f6 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Fri, 24 Jul 2026 12:13:19 -0400 Subject: [PATCH] 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` 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).
Implementation plan # 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.` per ESR entry, existence-checked and de-duplicated. 3. Add per-release `backport/v` 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.
--- *Opened by Coder Agents on behalf of @f0ssel.* --- .github/workflows/backport.yaml | 152 ++++++++++++++++------ scripts/release_channels/esr_versions.txt | 12 ++ scripts/update-release-calendar.sh | 16 ++- 3 files changed, 135 insertions(+), 45 deletions(-) create mode 100644 scripts/release_channels/esr_versions.txt diff --git a/.github/workflows/backport.yaml b/.github/workflows/backport.yaml index 934309f5be..22d4c04f93 100644 --- a/.github/workflows/backport.yaml +++ b/.github/workflows/backport.yaml @@ -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/-to- # - Title: (#) # - Body: links back to the original PR and merge commit +# - Label: backport/v 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/ 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 < \`\`\`" 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)." diff --git a/scripts/release_channels/esr_versions.txt b/scripts/release_channels/esr_versions.txt new file mode 100644 index 0000000000..0d7d1b12ee --- /dev/null +++ b/scripts/release_channels/esr_versions.txt @@ -0,0 +1,12 @@ +# Active ESR (Extended Support Release) versions. +# +# This file is the single source of truth for the active ESR set. It is +# consumed by: +# - scripts/update-release-calendar.sh (release calendar in +# docs/install/releases/index.md) +# - .github/workflows/backport.yaml (backport target branches) +# +# Update this list when a new ESR version is designated or an old one reaches +# end of life. +2.29 +2.34 diff --git a/scripts/update-release-calendar.sh b/scripts/update-release-calendar.sh index 801f5b9c70..80e862598f 100755 --- a/scripts/update-release-calendar.sh +++ b/scripts/update-release-calendar.sh @@ -8,17 +8,23 @@ set -euo pipefail # tag for each minor release. # # ESR (Extended Support Release) versions are biannually released and receive extended -# maintenance. Update the ESR_VERSIONS array below when new ESR versions are designated -# or old ones reach end of life. +# maintenance. The active ESR set is maintained in scripts/release_channels/esr_versions.txt, +# which is also consumed by .github/workflows/backport.yaml. Update that file when new ESR +# versions are designated or old ones reach end of life. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DOCS_FILE="docs/install/releases/index.md" +ESR_VERSIONS_FILE="${SCRIPT_DIR}/release_channels/esr_versions.txt" CALENDAR_START_MARKER="" CALENDAR_END_MARKER="" -# Known active ESR (Extended Support Release) minor versions. -# Update this list when new ESR versions are designated or old ones reach end of life. -ESR_VERSIONS=(29 34) +# Known active ESR (Extended Support Release) minor versions. The shared source +# of truth stores full major.minor versions; extract the minor component, since +# the calendar logic below is scoped to the 2.x line. Blank lines and '#' +# comments are ignored. +mapfile -t ESR_VERSIONS < <(grep -vE '^\s*(#|$)' "$ESR_VERSIONS_FILE" | cut -d. -f2) # Check if a minor version is a known active ESR version. is_esr_version() {