import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import path from 'node:path'; import test from 'node:test'; // RI-1-002 / RI-N1 publish-gate NEGATIVE CONTROLS (SDLC-D-034). // // scripts/verify-release.test.mjs pins the POSITIVE structure of the publish // gate: every publish effect declares a direct `depends_on: verify` edge and // the verify step asserts commit identity + runs the canonical command. This // suite is the negative-control set: each test feeds a structural gate // checker a pipeline in which the gate is bypassed by ONE specific shape and // asserts the checker goes RED. The controls prove from the pipeline FILE — // never by executing Woodpecker — that a verify step that FAILS (nonzero // exit) blocks every publish effect. // // Woodpecker semantics these controls rely on: // - A step that exits nonzero FAILS, and every step that transitively // depends on a failed step is SKIPPED — never run. That skip is the only // thing standing between a failed mandatory check and a publish effect. // - `detach: true` removes the step from the wait graph: the pipeline does // not wait for detached steps, so their failure can never block anything. // - `failure: ignore` reports a failed step as success to the DAG. // - `success: [codes...]` overrides which exit codes count as success; // admitting any nonzero code launders a failed verification into green. // - `when` on the verify step would skip verification entirely on some // event/path classes while publish effects still run. // // Bypass shapes covered (one negative-control test each): // S1 Missing edge — a publish effect whose dependency closure does not // contain `verify` (a refactor drops the depends_on entry). // S2 Hidden effect — a step whose NAME does not start with `publish` but // whose COMMANDS publish npm packages or push images. Effects are // classified by commands, so renaming a step cannot un-gate it. // S3 Detached verify — `verify: { detach: true }`: publish steps no longer // wait for verify, so the depends_on edge is decorative. // S4 Always-pass verify — `failure: ignore`, or a `success` override // admitting nonzero exit codes: verify fails, the DAG sees success. // S5 Conditional verify — a `when`/path filter on verify itself. // S6 Exact-commit drift — a HEAD-moving step (git checkout/switch/reset/ // clean/pull/clone/fetch) ordered between `verify` and a publish // effect: the verified commit would not be the published commit. A // LEGITIMATE re-checkout is allowed only when `verify` itself runs // after it — positive control included. // S7 Gate removal — the verify step deleted or renamed away entirely. // Reuse the monorepo's existing YAML parser (@mosaicstack/mosaic's direct // dependency) instead of adding a root dependency or vendoring a parser. const mosaicRequire = createRequire( path.resolve(process.cwd(), 'packages', 'mosaic', 'package.json'), ); const { parse: parseYaml } = mosaicRequire('yaml'); const publishYmlPath = path.join(process.cwd(), '.woodpecker', 'publish.yml'); async function readPublishPipeline() { return parseYaml(await readFile(publishYmlPath, 'utf8')); } // A command has a publish EFFECT when it publishes npm packages (`publish` // anywhere after a package-manager token — `pnpm --filter "@x/*" publish` // puts flags and quoted filters between the binary and the subcommand) or // pushes an image (kaniko, docker push, or a registry --destination). // Deliberately over-broad: a false positive forces justification, a false // negative is the actual hazard. function isPublishCommand(command) { return ( /(^|\s)\/kaniko\/executor\b/.test(command) || /(^|\s)docker\s+push\b/.test(command) || /(^|\s)--destination(\s|=)/.test(command) || (/\bpublish\b/.test(command) && /(^|\s)(npm|pnpm|yarn)(\s|$)/.test(command)) ); } function hasPublishEffect(step) { return (step.commands ?? []).some(isPublishCommand); } // A step is a publish effect when its name says so OR (S2) when any of its // commands does — classification must not depend on the name alone. function publishEffectSteps(pipeline) { return Object.entries(pipeline.steps ?? {}) .filter(([name, step]) => name.startsWith('publish') || hasPublishEffect(step)) .map(([name]) => name); } // Transitive closure of a step's depends_on graph. function dependencyClosure(pipeline, stepName, seen = new Set()) { const dependencies = pipeline.steps?.[stepName]?.depends_on ?? []; for (const dependency of dependencies) { if (seen.has(dependency)) continue; seen.add(dependency); dependencyClosure(pipeline, dependency, seen); } return seen; } // Deliberately over-broad: `git fetch` alone does not move HEAD, but the // classic re-checkout pair is `git fetch && git reset --hard `; a // fetch step sitting between verify and a publish effect deserves scrutiny, // so the gate fails closed on it. function movesHead(step) { return (step.commands ?? []).some((command) => /(^|\s)git\s+(checkout|switch|reset|clean|pull|clone|fetch)\b/.test(command), ); } // The structural gate checker: green only when a failed (nonzero-exit) // verify provably blocks every publish effect on the same commit. function assertPublishGateBlocksOnVerify(pipeline) { assert.ok(pipeline.steps, 'publish pipeline must define steps'); const verify = pipeline.steps.verify; assert.ok(verify, 'publish pipeline must define a `verify` step (S7)'); // S5: a skipped verification authorizes publishes exactly as much as a // failed one — verify must be unconditional. assert.equal(verify.when, undefined, '`verify` must not carry a when/path filter (S5)'); // S3/S4: the depends_on edges are only meaningful if verify's own failure // is both awaited and terminal for the DAG. assert.equal(verify.detach, undefined, '`verify` must not be detached (S3)'); assert.equal( verify.failure, undefined, '`verify` must not tolerate its own failure (S4: failure: ignore launders a failed gate into success)', ); assert.equal( verify.success, undefined, '`verify` must not override success exit codes (S4: nonzero codes would make failed verification pass)', ); const effects = publishEffectSteps(pipeline); assert.ok(effects.length > 0, 'publish pipeline must contain publish effect steps to guard'); const verifyClosure = dependencyClosure(pipeline, 'verify'); for (const stepName of effects) { // S1: only the failure-skip semantics of the DAG stand between a failed // verify and this effect — the verify edge in its closure is the proof. const closure = dependencyClosure(pipeline, stepName); assert.ok( closure.has('verify'), `publish effect '${stepName}' must transitively depend on verify (S1) — a failed verify must skip it`, ); // S6: any step ordered after verify (outside its closure) but inside the // effect's chain must not be able to move HEAD. If the pipeline // legitimately re-checks-out, verify must run after the re-checkout. for (const chainStep of closure) { if (chainStep === 'verify' || verifyClosure.has(chainStep)) continue; assert.ok( !movesHead(pipeline.steps[chainStep]), `step '${chainStep}' sits between verify and publish effect '${stepName}' and can move HEAD (S6)` + ' — verify must re-run after any re-checkout', ); } } return effects; } // A minimal but healthy gate used as the base for every negative-control // mutation: verify (identity + canonical command) → build → publish-npm, // with the publish effect blocked by verify both directly and through build. const HEALTHY_GATE_YAML = ` steps: verify: image: node:24-alpine commands: - | if [ -z "$CI_COMMIT_SHA" ] || [ "$CI_COMMIT_SHA" != "$(git rev-parse HEAD)" ]; then echo "identity mismatch" >&2 exit 1 fi - pnpm verify:release build: image: node:24-alpine commands: - pnpm build depends_on: - verify publish-npm: image: node:24-alpine commands: - npm publish depends_on: - build - verify `; // Fresh parse per call so every negative control mutates its own object. function healthyPipeline() { return parseYaml(HEALTHY_GATE_YAML); } test('the real publish pipeline: a failed verify provably blocks every publish effect', async () => { const pipeline = await readPublishPipeline(); const effects = assertPublishGateBlocksOnVerify(pipeline); assert.deepEqual(effects.sort(), [ 'build-appservice', 'build-gateway', 'build-web', 'publish-next-npm', 'publish-npm', ]); }); test('fixture sanity: the healthy gate base passes the checker unmutated', () => { assertPublishGateBlocksOnVerify(healthyPipeline()); }); test('S1 negative control: a publish effect with no verify edge fails the checker', () => { const pipeline = healthyPipeline(); pipeline.steps['publish-npm'].depends_on = ['build']; pipeline.steps.build.depends_on = []; assert.throws( () => assertPublishGateBlocksOnVerify(pipeline), /publish-npm.*must transitively depend on verify/s, ); }); test('S2 negative control: an npm publish hidden behind a non-publish step name fails the checker', () => { const pipeline = healthyPipeline(); delete pipeline.steps['publish-npm']; pipeline.steps.build.depends_on = []; pipeline.steps.deploy = { image: 'node:24-alpine', commands: ['npm publish'], depends_on: ['build'], }; // Detection must be by COMMAND: the name says "deploy", the commands say // publish — an un-gated effect under either reading. assert.throws( () => assertPublishGateBlocksOnVerify(pipeline), /deploy.*must transitively depend on verify/s, ); }); test('S2 negative control: a kaniko image push under a build-* name fails the checker when ungated', () => { const pipeline = healthyPipeline(); delete pipeline.steps['publish-npm']; pipeline.steps.build.depends_on = []; pipeline.steps['push-platform-image'] = { image: 'gcr.io/kaniko-project/executor:debug', commands: ['/kaniko/executor --context . --destination reg.example/img:latest'], depends_on: ['build'], }; assert.throws( () => assertPublishGateBlocksOnVerify(pipeline), /push-platform-image.*must transitively depend on verify/s, ); }); test('S3 negative control: a detached verify fails the checker', () => { const pipeline = healthyPipeline(); pipeline.steps.verify.detach = true; assert.throws(() => assertPublishGateBlocksOnVerify(pipeline), /detached \(S3\)/); }); test('S4 negative control: failure: ignore on verify fails the checker', () => { const pipeline = healthyPipeline(); pipeline.steps.verify.failure = 'ignore'; assert.throws(() => assertPublishGateBlocksOnVerify(pipeline), /tolerate its own failure/); }); test('S4 negative control: a success override admitting nonzero exit codes fails the checker', () => { const pipeline = healthyPipeline(); pipeline.steps.verify.success = [0, 1]; assert.throws(() => assertPublishGateBlocksOnVerify(pipeline), /success exit codes/); }); test('S5 negative control: a when filter on verify fails the checker', () => { const pipeline = healthyPipeline(); pipeline.steps.verify.when = [{ event: 'push' }]; assert.throws(() => assertPublishGateBlocksOnVerify(pipeline), /when\/path filter \(S5\)/); }); test('S6 negative control: a HEAD-moving step between verify and publish fails the checker', () => { const pipeline = healthyPipeline(); pipeline.steps.resync = { image: 'node:24-alpine', commands: ['git fetch origin', 'git reset --hard origin/main'], depends_on: [], }; pipeline.steps.build.depends_on = ['verify', 'resync']; // resync sits AFTER verify in the publish chain (verify does not depend on // it), so the verified commit could be replaced before publishing. assert.throws(() => assertPublishGateBlocksOnVerify(pipeline), /resync.*can move HEAD/s); }); test('S6 positive control: a legitimate re-checkout passes when verify re-runs after it', () => { const pipeline = healthyPipeline(); pipeline.steps.resync = { image: 'node:24-alpine', commands: ['git fetch origin', 'git reset --hard origin/main'], depends_on: [], }; pipeline.steps.verify.depends_on = ['resync']; pipeline.steps.build.depends_on = ['verify']; // resync precedes verify in the chain, so verification covers the // re-checked-out HEAD — the exact-commit contract holds. assertPublishGateBlocksOnVerify(pipeline); }); test('S7 negative control: deleting the verify step entirely fails the checker', () => { const pipeline = healthyPipeline(); delete pipeline.steps.verify; pipeline.steps['publish-npm'].depends_on = ['build']; assert.throws(() => assertPublishGateBlocksOnVerify(pipeline), /`verify` step/); });