Backporting¶
How a merged fix gets applied to older, still-maintained tool versions — the backport <branch> label, release-X.Y maintenance branches, and what happens when a cherry-pick conflicts.
Overview¶
Backporting is how a fix that lands on main also reaches a tool version that's already shipped and still supported — the maintainer decides which versions a merged pull request should affect, by applying one backport release-X.Y label per affected line. Everything from there — cherry-picking the change, opening a pull request per target branch, and recovering when a cherry-pick doesn't apply cleanly — is automated by korthout/backport-action (.github/workflows/backport.yml), not hand-rolled scripting. This page covers the full lifecycle: how a release-X.Y branch comes to exist, the label convention, what a conflict looks like and how to resolve it, and how a maintenance branch cuts its own patch releases afterwards.
How to backport a merged pull request¶
Apply a backport release-X.Y label to the pull request — before or after it merges. One label per tool version line the fix should also reach, e.g. both backport release-1.5 and backport release-1.6 to backport to two supported lines from the same PR. Once the PR is merged, backport.yml takes over automatically:
- It confirms every
backport <branch>label actually names an existingrelease-X.Ymaintenance branch — a typo, or a label naming a line that's never had a branch created, fails loudly with a comment on the original PR rather than silently doing nothing or cherry-picking to the wrong place. - For each valid target, it cherry-picks the pull request's squash-merge commit (see CONTRIBUTING.md: Squash merge only — there's always exactly one commit to pick) onto a new branch off
release-X.Y, using-xso the resulting commit records which original commit it came from. - It opens a pull request per target branch, titled
[Backport release-X.Y] <original title>, labeledbackport, with the original author added as an assignee. - It comments back on the original pull request with the result.
Only a maintainer applies these labels — a contributor can request a backport in the PR description, but deciding which shipped versions are affected (and therefore need the fix) is a judgment call, not something to automate from the PR's own content.
Maintenance branches: release-X.Y¶
A release-X.Y branch (e.g. release-1.6) is the only valid backport target, and is the branch that line's own patch releases (v1.6.1, v1.6.2, ...) get cut from afterwards — main keeps moving forward (v1.7.0, v2.0.0, ...) and never looks at that branch again.
These branches aren't created or maintained by hand. .github/workflows/release-branches.yml watches the exact same push: tags: v* event release.yml does (see below for why that doesn't put release.yml itself at any risk) and, whenever the pushed tag is the first release of a minor/major line (patch 0, e.g. v1.6.0, not v1.6.1), it:
- creates
release-X.Ypointed at that tag's commit, if it doesn't already exist, and - creates the matching
backport release-X.Ylabel, if it doesn't already exist.
A patch tag (v1.6.1) does nothing here — its release-1.6 branch is assumed to already exist from when v1.6.0 shipped. A pre-release tag (v2.0.0-rc1, same convention docs.yml uses to decide what becomes the latest docs alias) never gets a maintenance branch either — only a real vX.Y.0 does.
In practice, this means every minor/major line this project has ever shipped becomes backport-eligible automatically the moment it ships, with zero manual setup — the label simply doesn't exist yet for a line nobody has asked to maintain further, and starts existing the instant someone does.
Resolving a backport conflict¶
Not every cherry-pick applies cleanly — a maintenance branch can diverge from main in ways that create a real textual conflict. backport.yml sets experimental.conflict_resolution: draft_commit_conflicts (rather than the action's default, which is just to fail), so a conflict never dead-ends as a failed Actions run with nothing to act on:
- The action commits the conflict as-is (conflict markers included) and opens a draft pull request against the target
release-X.Ybranch. - It comments on the original pull request with a link to that draft and instructions to continue.
- To resolve it:
git fetch origin <the backport branch the action pushed>
git checkout <that branch>
# resolve the conflict markers in the affected file(s)
git add <resolved files>
git commit # no --amend - the conflicted commit stays as the audit trail of what was attempted
git push
- Once the branch builds and passes CI (see CI on backport pull requests below), mark the draft ready for review and merge it like any other pull request.
Auto-merge is never enabled on a draft created this way (GitHub doesn't allow auto-merge on drafts), so a conflicting backport always gets a human's eyes before it merges, by construction. Once the draft (or any backport PR) is merged or closed, branch-cleanup.yml deletes its branch automatically — see CI/CD: branch-cleanup.yml.
Cutting a patch release from a maintenance branch¶
Once one or more backport pull requests merge into release-X.Y, that branch accumulates pending changelog.d/ fragments exactly like main does — each backported commit carries its original fragment file along with it (the cherry-pick reapplies that file's addition, unchanged). release-prep.yml now also runs on release-X.Y branches (not just main), and opens a ci: prepare release vX.Y.(Z+1) pull request the same way it does for main, with one important difference: the next version it computes is relative to that branch's own tag history (git describe, not a repo-wide "highest tag" search), so release-1.6 always proposes v1.6.(Z+1) regardless of how far main has moved on. It also refuses to compute a version at all if that branch's name and its own latest reachable tag ever disagree (e.g. release-1.6 whose history doesn't actually contain a v1.6.x tag) — a configuration problem on a maintenance branch should fail loudly, not quietly produce a wrong version.
From there, releasing a patch version works exactly like releasing from main: review and merge the release-prep PR, then
git checkout release-1.6
git pull
git tag v1.6.1
git push origin v1.6.1
The release job is unaffected¶
Pushing v1.6.1 from release-1.6 triggers release.yml exactly the same way pushing v1.7.0 from main does — release.yml's trigger (push: tags: v*) and every one of its jobs (images, manifests, helm-chart, release-notes) already only cares about the pushed tag and the commit it points at, never which branch it was pushed from. Nothing in this feature touches release.yml's own file or job graph at all: release-branches.yml and the release-prep.yml/ci.yml/go-quality.yml/pr-title.yml changes described on this page are each independent workflow files or additive trigger scopes, with no needs:/shared-job relationship to release.yml. See CI/CD for the full breakdown of what that workflow does.
CI on backport pull requests¶
A backport pull request goes through the same quality bar as any other, with two deliberate exceptions:
| Check | Runs on backport PRs? | Why |
|---|---|---|
ci.yml (build/test/integration), go-quality.yml (lint/fmt/vuln) |
Yes | A cherry-pick that applies cleanly textually can still fail to build or pass tests against a branch that has otherwise diverged — these need to gate a merge into release-X.Y exactly as they would into main. |
pr-title.yml (Conventional Commits title) |
No | The backport PR's title is machine-generated ([Backport release-X.Y] <original title>), which isn't itself a Conventional Commits type — and the original title already passed this check once, on main, before it was ever eligible to be backported. |
changelog-fragment-check.yml (news fragment required) |
No | The fragment was already validated on the original PR and travels with the cherry-picked commit unchanged — requiring a second one here would be redundant, and would break on the same title-prefix mismatch as pr-title.yml. |
dco.yml (sign-off) |
No (scoped to main only, unchanged) |
The original commit was already signed off; this workflow's scope wasn't widened for backporting. |
If resolving a conflict substantially changes the fix for that specific version (not just a mechanical conflict-marker resolution), nothing stops a reviewer from manually adding or adjusting a changelog.d/ fragment on the backport PR — it just isn't enforced by CI the way it is on main, since that's a judgment call about how different the backported fix really is.
Safety and governance notes¶
A few things were deliberately built in rather than left to convention:
- One source of truth for targets.
backport.yml'svalidate-targetsjob is the only place target branches are computed from labels — it disables the backport-action's own independentlabel_patternmatching (label_pattern: "") and passes its already-validated list viatarget_branchesinstead, so there's no second mechanism that could ever disagree with the first. - Existence is checked, not assumed. A
backport <branch>label that doesn't resolve to a real branch fails the run with a comment explaining exactly which label is wrong, instead of the backport-action failing deeper into its own run with a less actionable git-level error. - Maintenance branches only ever bump their own patch version.
release-prep.yml's version-bump logic takes a different path on arelease-X.Yref than onmain: it always increments the patch number there, even if a stray*.feat.mdfragment somehow ended up on that branch, because a maintenance line's entire purpose is patch releases. - Concurrency is scoped correctly in both new workflows.
backport.ymlgroups by the originating PR number (so re-triggers for the same PR queue instead of racing);release-prep.ymlnow groups bygithub.ref_name(so arelease-1.6prep PR and amainprep PR never share — or silently clobber — the same branch/PR). - Disposable branches never linger, and long-lived ones are never touched by the same mechanism.
branch-cleanup.ymldeletes every PR's head branch on close (merged or not) — including the per-target branchesbackport.ymlcreates andrelease-prep.yml's reusedrelease-prep-<ref>branch — but is hard-coded to never deletemainor anyrelease-X.Ybranch, even in the (unusual) case one of those is ever a PR's head rather than its usual role as a base.
See also¶
- CI/CD — every workflow referenced on this page, including
backport.ymlandrelease-branches.yml's own entries. - Branch Protection — whether
release-X.Ybranches should carry the same required checks asmain. - Contributing Guide — the contributor-facing summary of this page.