fix(catalog): bryt stat-linje-vranglåsen mellom de to pre-flightene

Q3e innførte en pre-flight som leser den NYE ref-ens badger, mens applyRelease'
pre-flight leser den PINNEDE ref-en. De krevde motsatte verdier av samme
stat-linje, og den pinnede ref-en flyttes først av nettopp den skrivingen som
blokkeres — så ingen release som endrer et badget tall kunne fullføres. Målt på
to levende ordrer (repo-mailbox 0.35.0, llm-security 8.0.0).

Verre enn en blokk: --create-tag er med vilje ugatet på den katalog-brede
pre-flighten, så et ekte løp ville MINTET OG PUSHET taggen og deretter blokkert
— en publisert tag mot en katalog som aldri kan bumpes.

Fiks: preflightErrors tar navnet på pluginen som slippes og ser bort fra DENS
stat-funn. Ingenting blir uverifisert — (a) validerer stat-linja mot målet før
noe røres, og post-write-gaten validerer den mot den nå-pinnede nye ref-en. Kun
vinduet der den pinnede ref-en er kjent stale hoppes over.

Vakten er smal i tre ledd: kun den pluginen som slippes, kun funn med
kind='stat' (diskriminator i check-versions.mjs, aldri prosa-match), og en
ERROR uten ERROR-funn blir aldri frikjent. Hengende ref, badge-avvik, feil
README-etikett og død homepage blokkerer som før.

Valgt denne framfor å la applyRelease skrive stat-linja selv, fordi den ville
måttet innføre en maskinpolicy for badge-løse akser — og de er ved dokumentert
org-beslutning et menneskelig skjønn.

Iron Law: 5 nye tester, 2 røde før fiksen. 172/172 grønne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-18 01:05:39 +02:00
commit 5a893e3bfd
3 changed files with 109 additions and 3 deletions

View file

@ -125,8 +125,36 @@ export function reconcileReadmeLabel(readmeText, name, newRef) {
// Reads the ERROR set explicitly and NEVER `failed`/`hasWarn`: pre-bump, the plugin being
// released is SUPPOSED to be WARN (catalog ref behind plugin.json). Gating on WARN would
// brick every release.
export function preflightErrors(gateResult) {
return (gateResult?.results ?? []).filter(r => r.status === 'ERROR').map(r => r.name);
//
// Q3f — ONE exemption, for the ONE plugin being released: its STAT findings. Measured
// 2026-09-18 on two live orders (repo-mailbox 0.35.0, llm-security 8.0.0), the two
// pre-flights demanded opposite values of the same stat line and no release that changes
// a badged number could complete at all:
// (a) preflightStatMismatches (runRelease, before the tag) reads the TARGET ref -> new number
// (b) this gate -> runGate -> inspectPlugin reads the stat source at the PINNED ref -> old number
// The pinned ref is moved by exactly the write (b) blocks, so retry does not help. And
// --create-tag is deliberately ungated on (b), so a real run PUBLISHES the tag and then
// blocks here: a pushed tag against a catalog that can never be bumped.
//
// Nothing goes unverified by waiving it. The released plugin's stat line is checked at
// BOTH edges of the window where the two refs disagree: (a) validates it against the
// target before anything is touched, and the post-write check-versions run validates it
// against the now-pinned new ref. Only the middle — where the pinned ref is knowably
// stale — is skipped.
//
// The waiver is deliberately narrow in three ways: it applies to `releasingName` only,
// to `kind === 'stat'` findings only (a discriminator, never a prose match), and it
// refuses to reason about an ERROR that carries no ERROR finding — an unexplained ERROR
// blocks. A dangling ref, a badge/plugin.json disagreement, a wrong README label or a
// dead homepage on the released plugin still block it, exactly as before.
export function preflightErrors(gateResult, releasingName = null) {
return (gateResult?.results ?? []).filter(r => {
if (r.status !== 'ERROR') return false;
if (releasingName === null || r.name !== releasingName) return true;
const errs = (r.findings ?? []).filter(f => f.level === 'ERROR');
if (errs.length === 0) return true;
return errs.some(f => f.kind !== 'stat');
}).map(r => r.name);
}
// --create-tag mints AND PUSHES a tag to a public remote — the one genuinely irreversible
@ -311,7 +339,7 @@ export function pushWithToken({ cwd, home, exists, unlink, push }) {
// (which throws on exit 1), leaving a half-applied release in the working tree for a parallel
// session to carry to the public remote. `io` is injected so the ORDER is testable.
export function applyRelease({ plan, catalogDir, mktPath, readmePath }, io) {
const errors = preflightErrors(io.runGate(catalogDir));
const errors = preflightErrors(io.runGate(catalogDir), plan.name);
if (errors.length > 0) return { verdict: 'BLOCKED', preflightErrors: errors, writes: [], readme: null };
const writes = [];