mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-09 14:02:56 +09:00
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:
1 file changed
+84
-1
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user