ci(labeler): default a fix PR to the backport label

Forgetting the `backport` label costs a release-branch fix; removing a
label during review costs a click. So invert the default: a PR whose
Conventional Commits type is `fix` gets `backport` on arrival, and a
maintainer who decides it must not reach a release line removes it.

The removal has to stick, which is what shapes the condition. This
workflow also runs on `reopened` and `synchronize`, so re-applying the
label the way `kind/*` and `area/*` are re-applied would bring it back
with the next push and make removal fiction — worse than no default,
because it would look like it works. `kind/*` and `area/*` are
derivations from the title and a human removing one is removing a fact;
`backport` is a human decision with a default. It is therefore seeded
only on `opened`, which fires exactly once per PR, and never again.
`reopened` is excluded on the same grounds: a reopen follows a close, by
which point the label has usually been considered.

Three exclusions. A backport PR itself, reusing the `[Backport ...]`
detector already in the file rather than adding a second one — auto
labeling those is what caused recursive backports. A breaking change,
which must never default into a patch release line. And nine scopes
whose code is main-only or where a cherry-pick is meaningless: ci, e2e,
tests, build, deps, agents, release, backport, migrations. `migrations`
is preventive rather than observed — it is in no recent PR title — but a
migration backported without diffing the version slot across branches
makes the newer branch silently skip its own migration at that slot.

On a composite scope any excluded part suppresses the label, rather than
requiring all of them to be excluded: `fix(platform,ci)` is exactly the
shape that drags main-only content into a release branch.

What the default does not buy is stated in the file. backport.yaml's
`conflict_resolution: draft_commit_conflicts` covers a conflict; it
cannot cover a backport that applies cleanly and wrongly, because the
action imports whole files the target branch lacks. The reviewer still
owns whether the change belongs on the release line.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
This commit is contained in:
Myasnikov Daniil committed 2026-08-15 20:48:39 +05:00
1 parent ff3294e02d
commit 1b8b9f43ec
1 file changed
+84 -1
+84 -1
View File
@@ -216,7 +216,90 @@ jobs:
toAdd.add('area/uncategorized');
}
// 9. Additive only — never remove existing labels.
// 9. `backport` on a fix — seeded ONCE, on `opened`, and never again.
//
// Every label above is a DERIVATION from the title: it re-applies on
// every run, and that is correct, because a human removing kind/bug
// from a `fix(...)` PR is removing a fact. `backport` is a different
// kind of label — a human DECISION with a default. Forgetting it costs
// a release-branch fix, so the default is "yes"; removing it during
// review is cheap, so the escape hatch is removal.
//
// That only works if removal sticks, which is why the action is part
// of the condition. This workflow runs on `opened`, `reopened` and
// `synchronize`, so re-applying `backport` on the derivation path would
// bring it back with the next push and make removal fiction — worse
// than no default at all, because it would look like it works.
// `opened` fires exactly once per PR and is the one moment when nobody
// has had the chance to decide yet.
//
// `reopened` is excluded on purpose: a reopen follows a close, by which
// point the label has usually already been considered, and a decision
// must survive close/reopen.
//
// Seeding once has a price, and it is one-sided: a PR that becomes a
// `fix` only AFTER it is opened never gets the default. Retitled from
// `feat` to `fix`, rescoped out of an excluded scope, opened before this
// workflow existed, or opened while its own run failed — each of those
// gets the derivations re-applied on the next push and no `backport`.
// A maintainer adds the label by hand, exactly as today. Every one of
// those is a missing default, never a resurrected label, which is the
// direction this has to fail in.
//
// So do NOT "fix the inconsistency" by moving this in with kind/* and
// area/* above. The asymmetry is the feature.
const NO_BACKPORT_SCOPES = new Set([
// Main-only or meta: CI, test suites, build plumbing, dependency
// bumps (`deps` is what Renovate emits, see .github/renovate.json),
// agent instructions, and release/backport tooling. A release branch
// does not run this code, and a cherry-pick of it is noise at best.
'ci', 'e2e', 'tests', 'build', 'deps', 'agents', 'release', 'backport',
// Preventive, not observed: `migrations` appears in no recent PR
// title and has no area/* mapping. It is listed because a migration
// backported without diffing the version slot across branches makes
// the NEWER branch silently skip its own migration at that slot — see
// "Backporting code that touches monotonic counters" in
// docs/release.md. That needs a human who has read the slot state on
// both branches, never a default.
'migrations',
]);
// ANY excluded scope suppresses, rather than requiring all of them to
// be excluded. `fix(platform,ci): ...` is precisely the shape that
// drags main-only content into a release branch, and a composite scope
// is the labeler's weakest signal about what the diff really touches.
// Suppressing costs a maintainer one click to add the label back;
// defaulting costs a wrong backport that reads as reviewed.
const excludedScope = scopes.some(s => NO_BACKPORT_SCOPES.has(s));
if (
context.payload.action === 'opened' &&
type === 'fix' &&
// Not on a backport PR itself — reusing the detector from step 1
// rather than adding a second one. Auto-labeling these is what
// caused recursive backports (see docs/release.md).
!backportMatch &&
// A breaking fix must never default into a patch release line.
!breaking &&
!excludedScope
) {
toAdd.add('backport');
}
// What the default buys: a fix no longer misses a release branch
// because nobody remembered a label, and backport.yaml fires only on
// merge (`pull_request.merged == true`), so the whole review window is
// available to remove it.
//
// What it does not buy, and what a reviewer still owns: whether this
// change BELONGS on the release line, and whether the backport PR the
// merge produces contains only this change. backport.yaml's
// `conflict_resolution: draft_commit_conflicts` covers a conflict — it
// cannot cover a backport that applies CLEANLY and wrongly, because
// korthout/backport-action imports whole files the target branch lacks.
// A fix to code that does not exist on the release branch can land and
// drag unrelated content in with it; that is how the #3282 backport to
// release-1.5 pulled in a breaking change. The exclusion list narrows
// that window and does not close it.
// 10. Additive only — never remove existing labels.
const newLabels = [...toAdd].filter(l => !existing.has(l));
if (newLabels.length === 0) {
core.info('No new labels to apply');