From 1b8b9f43ec7933272ec3e30df26c12a18c104fda Mon Sep 17 00:00:00 2001 From: Myasnikov Daniil Date: Fri, 14 Aug 2026 13:48:49 +0500 Subject: [PATCH] ci(labeler): default a fix PR to the backport label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: Myasnikov Daniil --- .github/workflows/pr-labeler.yaml | 85 ++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-labeler.yaml b/.github/workflows/pr-labeler.yaml index e3337ee51..8c876138f 100644 --- a/.github/workflows/pr-labeler.yaml +++ b/.github/workflows/pr-labeler.yaml @@ -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');