From a9111be5d1b432ff31b9d159b5ffa4506fb66e0e Mon Sep 17 00:00:00 2001 From: KnockOutEZ Date: Sat, 29 Aug 2026 12:19:05 +0600 Subject: [PATCH 1/2] test(build): pin that the install-register classifier sees an un-suppressed root npm publish Red against the tip classifier: its alternation enumerates install verbs, so a planted un-suppressed `npm publish` at the repo root is classified as not prepare-firing and the sweep reports an empty set. The arm plants the shape in a scratch workflow directory under $TMPDIR and runs the real parser over it, because every other arm in this block can only agree with the workflows that already exist. --- tests/unit/prepare-build.test.ts | 59 ++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/tests/unit/prepare-build.test.ts b/tests/unit/prepare-build.test.ts index b7098edb..0c69c475 100644 --- a/tests/unit/prepare-build.test.ts +++ b/tests/unit/prepare-build.test.ts @@ -254,10 +254,16 @@ const WORKFLOWS = join(ROOT, '.github', 'workflows'); /** A step's effective working directory: its own, else the job's `defaults.run`, else root. */ type Step = { key: string; run: string; optedOut: boolean; atRoot: boolean }; -function steps(): Step[] { +/** + * Parses the real workflows by default. The directory is a parameter only so an arm can point the + * REAL parser and the REAL classifier at a planted shape under $TMPDIR: every other arm here can + * do no more than agree with the workflows that happen to exist, which is no evidence at all about + * a shape none of them currently gets wrong. + */ +function steps(dir: string = WORKFLOWS): Step[] { const out: Step[] = []; - for (const file of readdirSync(WORKFLOWS).filter((f) => f.endsWith('.yml')).sort()) { - const doc = parseYaml(readFileSync(join(WORKFLOWS, file), 'utf8')) as { + for (const file of readdirSync(dir).filter((f) => f.endsWith('.yml')).sort()) { + const doc = parseYaml(readFileSync(join(dir, file), 'utf8')) as { jobs?: Record; }; for (const [jobId, job] of Object.entries(doc.jobs ?? {})) { @@ -327,6 +333,53 @@ describe('the prepare opt-out across every workflow that installs at the repo ro expect(unguarded).toEqual([]); }); + it('flags an un-suppressed root `npm publish` planted in a scratch workflow', () => { + // The arm above can only ever agree with the workflows that exist: a shape the classifier + // cannot see is a shape no real workflow can red it with, so "green" says nothing about it. + // `npm publish` is exactly that shape — publish packs, packing fires the root `prepare`, and + // the alternation above enumerates install verbs. Planting it in a scratch workflow directory + // and running the real parser over it is the outside signal: the classifier must flag the + // unguarded root publish, must not flag the guarded one as unguarded, and must leave a + // sub-package publish (its own root, its own hook) off-root entirely. + const dir = mkdtempSync(join(tmpdir(), 'wigolo-prepare-workflows-')); + try { + writeFileSync( + join(dir, 'synthetic.yml'), + [ + 'jobs:', + ' ship:', + ' steps:', + ' - name: Publish the root unguarded', + ' run: npm publish --provenance --access public', + ' - name: Publish the root guarded', + ' env:', + " WIGOLO_SKIP_PREPARE: '1'", + ' run: npm publish --provenance --access public', + ' - name: Publish a sub-package from its own root', + ' working-directory: sdks/typescript', + ' run: npm publish --provenance --access public', + '', + ].join('\n'), + ); + const planted = steps(dir); + const firing = planted.filter((s) => s.atRoot && TRIGGERS_PREPARE.test(s.run)); + expect(firing.map((s) => s.key)).toEqual([ + 'synthetic.yml / ship / Publish the root unguarded', + 'synthetic.yml / ship / Publish the root guarded', + ]); + // The verdict, not just the sweep: this is the name the guard arm would print. + expect(firing.filter((s) => !s.optedOut).map((s) => s.key)).toEqual([ + 'synthetic.yml / ship / Publish the root unguarded', + ]); + // And the working-directory logic still excuses a different package's publish. + expect(planted.filter((s) => !s.atRoot).map((s) => s.key)).toEqual([ + 'synthetic.yml / ship / Publish a sub-package from its own root', + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('a job that installs somewhere other than the root is not swept in', () => { // Control. Without it the working-directory logic could be exempting everything and the // rule above would still be green. site.yml's build job sets `defaults.run.working-directory: From 7f87cc3e1d06583d46f5f621632bfd4266488654 Mon Sep 17 00:00:00 2001 From: KnockOutEZ Date: Sat, 29 Aug 2026 12:20:34 +0600 Subject: [PATCH 2/2] fix(ci): the install-register classifier and its enumeration cover npm publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm publish` packs, and packing fires the root `prepare` hook, so the release publish step is a root prepare-firing step — but the sweep's alternation listed only install verbs, so the step never entered the affected set and the list a reader takes as "every root step that fires the hook" omitted it. Adds `publish` to the alternation and the release publish step to the expected set. Deleting its `WIGOLO_SKIP_PREPARE` now reds two arms naming the step instead of one, and the enumeration no longer implies the publish path is exempt. Corrects the pack/publish block's claim that the install sweep cannot see the shape. No change to the workflow. --- tests/unit/prepare-build.test.ts | 38 ++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/tests/unit/prepare-build.test.ts b/tests/unit/prepare-build.test.ts index 0c69c475..d1a38930 100644 --- a/tests/unit/prepare-build.test.ts +++ b/tests/unit/prepare-build.test.ts @@ -291,13 +291,14 @@ function steps(dir: string = WORKFLOWS): Step[] { describe('the prepare opt-out across every workflow that installs at the repo root', () => { /** - * npm runs the root package's `prepare` on `npm ci`, on an argument-less `npm install`, and - * on `npm pack` — pack most surprisingly, since nothing in the step reads like an install. + * npm runs the root package's `prepare` on `npm ci`, on an argument-less `npm install`, on + * `npm pack` and on `npm publish` — the last two most surprisingly, since nothing in either + * step reads like an install, and publish only fires it because it packs first. * `npm i -g ` takes an argument and installs a different package, so it is not here. */ // The lookbehind matters: `npm pack` appears inside `TGZ=$(npm pack ...)`, so anchoring on a // preceding space or `&&` would miss the very step this issue is about. - const TRIGGERS_PREPARE = /(? @@ -307,7 +308,10 @@ describe('the prepare opt-out across every workflow that installs at the repo ro it('fires on exactly these steps — a new root install step must appear here', () => { // A set, not a count: adding an unguarded install step reds this with its own name in the - // diff, and deleting or renaming a guarded one reds it too. + // diff, and deleting or renaming a guarded one reds it too. The release publish step belongs + // here and not only in the pack/publish block below: this list is the enumeration a reader + // reaches for as "every root step that fires the hook", so an omission here reads as proof + // the publish path is exempt, and deleting the other block would leave no arm at all. expect(affected().map((s) => s.key)).toEqual([ 'agent-benchmark.yml / benchmark / npm ci', 'binary-build.yml / build / Install dependencies', @@ -318,6 +322,7 @@ describe('the prepare opt-out across every workflow that installs at the repo ro 'ci.yml / lint-build-unit / Install dependencies', 'extraction-benchmark.yml / benchmark / npm ci', 'release.yml / release / npm ci', + 'release.yml / release / Publish wigolo (npm)', 'scrape-quality-live.yml / live-comparison / npm ci', 'scrape-quality.yml / scrape-quality / npm ci', 'search-benchmark.yml / benchmark / npm ci', @@ -745,19 +750,24 @@ describe('everything the install hooks need is packed by `files`', () => { }); /* - * `prepare` fires on the PUBLISH path too, and that register is invisible to the install sweep - * above: `npm publish` packs before it uploads, and packing runs `prepare`. So the release leg - * lints, tests, builds an explicit `dist/`, verifies the tag — and then `npm publish` rebuilds - * `dist/` from scratch, meaning the artifact that actually reaches the registry is NOT the one - * every gate validated. A build that flakes at that point also fails the release at publish, - * after everything was green. Enumerated the same way as the install legs, because a text - * search for `npm publish` cannot tell a root publish from a sub-package's own. + * `prepare` fires on the PUBLISH path too: `npm publish` packs before it uploads, and packing + * runs `prepare`. So the release leg lints, tests, builds an explicit `dist/`, verifies the tag — + * and then `npm publish` rebuilds `dist/` from scratch, meaning the artifact that actually reaches + * the registry is NOT the one every gate validated. A build that flakes at that point also fails + * the release at publish, after everything was green. Enumerated the same way as the install legs, + * because a text search for `npm publish` cannot tell a root publish from a sub-package's own. + * + * The sweep above now classifies `npm publish` too, so the publish steps appear in both + * enumerations by design and neither block alone is load-bearing. What is only here is the + * publish-specific pair: that a sub-package's publish is a different root, and that the explicit + * root build the opt-out depends on still precedes it. */ describe('the prepare opt-out on every step that packs or publishes the ROOT package', () => { /** - * The pack-shaped register. `npm publish` runs `prepack` → `prepare` → the tarball, so it - * fires the hook exactly as `npm pack` does; `npm publish` is the one shape the install - * register's `ci|pack|install` alternation cannot see. + * The pack-shaped register: `npm publish` runs `prepack` → `prepare` → the tarball, so it fires + * the hook exactly as `npm pack` does. Narrower than the install classifier on purpose — it + * matches only the two verbs that produce a tarball, so the arms below can speak about the + * shipped bytes rather than about installs. */ const TRIGGERS_PREPARE_VIA_PACK = /(?