Skip to content

chore(ci): run release verification in-band and record release-provenance ADRs #53

chore(ci): run release verification in-band and record release-provenance ADRs

chore(ci): run release verification in-band and record release-provenance ADRs #53

Workflow file for this run

name: Backport
# Label-triggered cherry-pick automation for maintenance branches (ADR 011,
# rules 3-4: fixes land on main first, then move down to release/vX.Y).
#
# Label convention
# Label a PR targeting main with one or more labels of the form
# backport release/vX.Y e.g. backport release/v2.4
# When the PR merges, this workflow opens one cherry-pick PR per labeled
# target branch carrying the same commits. Multiple labels fan out to
# multiple branches independently. The labels themselves are provisioned
# automatically when a maintenance branch is opened
# (maintenance-label.yml), so cutting release/vX.Y makes its label usable.
#
# Behavior
# - Missing target: maintenance branches are cut lazily, so when a label
# names a branch that does not exist yet, the workflow skips it with a
# ::notice:: instead of failing the run.
# - Conflicts: the action still opens the backport PR as a draft holding
# the first encountered conflict, and posts resolution instructions on
# the source PR. Expect import-suffix conflicts (/vN+1 -> /vN) and
# resolve them by hand; nothing fails silently.
#
# Manual fallback period
# This automation is new. Until it has proven itself over a few releases,
# maintainers may still cherry-pick manually per the contributor playbook
# (website/docs/contributing/release-branches.md); the label scheme is the
# same, so you can perform a missed automated backport by hand.
# After a proving period the manual path becomes the exception, not the
# procedure.
#
# Implementation choice: korthout/backport-action over a hand-rolled script.
# Its default label_pattern (^backport ([^ ]+)$) already implements our
# multi-label fan-out; it handles every merge method (squash included),
# cherry-picks with `-x` for an audit trail, and its
# `conflict_resolution: draft_commit_conflicts` mode provides exactly the
# conflicted-but-opened-PR UX required above — recreating that (push a
# conflict-marker branch, open a draft PR, comment on the source PR) in
# bash would be real surface area to own and test. One deviation: the
# action treats a missing target branch as a hard failure, while our
# maintenance branches are cut lazily, so we pre-compute the target list
# ourselves (skipping absent branches with a notice) and pass it via
# `target_branches` with `label_pattern` disabled. The disable uses '^$'
# (matches nothing — GitHub forbids empty label names) rather than the
# empty string, so no upstream change could silently reinterpret '' as a
# match-everything regex and turn arbitrary labels into cherry-pick
# targets.
#
# Runs on pull_request_target so GITHUB_TOKEN holds write access even when
# the merged PR came from a fork. Only pinned actions plus a read-only
# label-parsing step (event data passed via env, never interpolated into the
# script) execute here; this workflow checks out and runs nothing from the
# PR's ref.
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] -- pull_request_target is required so GITHUB_TOKEN has write for fork PRs; workflow never checks out or executes PR code, only reads PR metadata via env (see header comment)
types:
- closed
branches:
- main
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
jobs:
backport:
name: Cherry-pick merged PR to release/vX.Y
# Closed without merging is not a backport candidate; the '"backport '
# probe cheaply short-circuits runs with no backport label at all.
if: >-
github.event.pull_request.merged &&
contains(toJSON(github.event.pull_request.labels.*.name), '"backport ')
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write # push backport branches
pull-requests: write # open backport PRs, comment on the source PR
steps:
- name: Checkout repository history
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# zizmor: ignore[artipacked] -- backport job must push cherry-pick branches; credential persistence is required and no artifact is uploaded
with:
# Full history so cherry-picks across main and release branches
# always find their parent commits.
fetch-depth: 0
- name: Select existing target branches
id: select
env:
LABELS_JSON: ${{ toJSON(github.event.pull_request.labels.*.name) }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
# Only `backport release/vX.Y` (two numeric components, the ADR 011
# scheme) selects a target; near-misses get a pointed warning so
# typos surface.
PATTERN='^backport[[:space:]](release/v[0-9]+\.[0-9]+)$'
existing=""
while IFS= read -r label; do
if [[ ! "$label" =~ $PATTERN ]]; then
if [[ "$label" == "backport "* ]]; then
echo "::warning::Label '${label}' looks like a backport request but does not match 'backport release/vX.Y'; ignoring."
fi
continue
fi
branch="${BASH_REMATCH[1]}"
rc=0
# Maintenance branches are cut lazily; absence is expected and
# skipped with a notice, while any other ls-remote failure is real.
git ls-remote --exit-code --heads origin "refs/heads/${branch}" >/dev/null 2>&1 || rc=$?
if [ "$rc" -eq 0 ]; then
echo "::notice::PR #${PR_NUMBER} will be backported to ${branch}."
existing="${existing}${branch} "
elif [ "$rc" -eq 2 ]; then
echo "::notice::No branch named '${branch}' exists (maintenance branches are cut lazily, so absence is normal — cut it per ADR 011 rule 4 and re-add the label)."
else
echo "::error::git ls-remote failed while checking '${branch}' (exit ${rc}); cannot safely proceed."
exit "$rc"
fi
done < <(jq -r '.[]' <<< "$LABELS_JSON")
# Trailing space stripped; empty stays empty and disables the next step.
echo "existing=${existing% }" >> "$GITHUB_OUTPUT"
- name: Open cherry-pick PRs
id: backport
if: steps.select.outputs.existing != ''
uses: korthout/backport-action@2e830a1d0b8269505846ddd407a70876913ad1f8 # v4.6.0
with:
# Disabled: targets were resolved (and filtered for existence) above.
# Passing `target_branches` directly keeps lazy-cut branches from
# reading as hard failures inside the action. Verified against the
# pinned SHA: main.ts maps an empty input to `undefined`, and
# findTargetBranchesFromLabels returns [] for that — label scanning
# is fully off, not "matches everything".
label_pattern: '^$' # matches nothing; see implementation-choice note above
target_branches: ${{ steps.select.outputs.existing }}
# On conflict, still open a draft PR holding the first conflict and
# post resolution instructions on the source PR, instead of failing.
experimental: |
{
"conflict_resolution": "draft_commit_conflicts"
}