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'; import { STAGES } from './verify-release.mjs'; // SDLC-D-034 checkout invariant: publication in .woodpecker/publish.yml is // bound to exact-commit terminal verification. This suite parses the real // pipeline files and fails red when the gate is bypassed, weakened, or drifts // out of sync with the canonical `pnpm verify:release` command. // 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'); const ciYmlPath = path.join(process.cwd(), '.woodpecker', 'ci.yml'); async function readPublishPipeline() { return parseYaml(await readFile(publishYmlPath, 'utf8')); } // A step has an external publication effect when its name starts with // `publish` or when any command pushes an image to a registry. function pushesImage(step) { return (step.commands ?? []).some((command) => /(^|\s)(\/kaniko\/executor|docker push)\b|--destination/.test(command), ); } function publishEffectSteps(pipeline) { return Object.entries(pipeline.steps ?? {}) .filter(([name, step]) => name.startsWith('publish') || pushesImage(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; } function verifyCommands(pipeline) { const verify = pipeline.steps?.verify; assert.ok(verify, 'publish pipeline must define a `verify` step'); assert.ok(Array.isArray(verify.commands), '`verify` step must have commands'); return verify.commands; } function assertCommitIdentityAssertion(commands) { const text = commands.join('\n'); assert.match( text, /CI_COMMIT_SHA/, '`verify` must compare the provider commit identity (CI_COMMIT_SHA)', ); assert.match(text, /git rev-parse HEAD/, '`verify` must compare against git rev-parse HEAD'); assert.match( text, /exit 1/, '`verify` must fail closed (exit 1) on identity mismatch or emptiness', ); } function assertCanonicalCommand(commands) { assert.ok( commands.some((command) => /^pnpm verify:release\b/.test(command.trim())), '`verify` must run the canonical terminal verification command `pnpm verify:release`', ); } function assertPublishGate(pipeline) { assert.ok(pipeline.steps, 'publish pipeline must define steps'); const commands = verifyCommands(pipeline); assertCommitIdentityAssertion(commands); assertCanonicalCommand(commands); const effects = publishEffectSteps(pipeline); assert.ok(effects.length > 0, 'publish pipeline must contain publish effect steps to guard'); for (const stepName of effects) { const step = pipeline.steps[stepName]; assert.ok( Array.isArray(step.depends_on) && step.depends_on.includes('verify'), `publish effect '${stepName}' must depend DIRECTLY on the verify step (SDLC-D-034: transitively through build is not enough)`, ); assert.ok( dependencyClosure(pipeline, stepName).has('verify'), `publish effect '${stepName}' must depend on a chain that includes verify`, ); } return effects; } test('the publish pipeline gates every publish effect behind exact-commit verification', async () => { const pipeline = await readPublishPipeline(); const effects = assertPublishGate(pipeline); assert.deepEqual(effects.sort(), [ 'build-appservice', 'build-gateway', 'build-web', 'publish-next-npm', 'publish-npm', ]); }); test('the verify step carries no path/event short-circuit of its own', async () => { const pipeline = await readPublishPipeline(); // A `when` filter on `verify` would let a publish effect fire on an event // class that skipped verification — the gate must be unconditional. assert.equal(pipeline.steps.verify.when, undefined); }); test('a publish step that bypasses verify fails the gate checker', () => { // Negative fixture: a plausible publish pipeline where `publish-npm` hangs // off `build` only and `build` never chains to `verify` — the exact bypass // class SDLC-D-034 closes. The checker must go red on it. const bypassingPipeline = ` steps: install: image: node:24-alpine commands: - pnpm install --frozen-lockfile 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 depends_on: - install build: image: node:24-alpine commands: - pnpm build depends_on: - install publish-npm: image: node:24-alpine commands: - pnpm publish depends_on: - build `; assert.throws( () => assertPublishGate(parseYaml(bypassingPipeline)), /publish-npm.*DIRECTLY.*verify/s, ); }); test('a publish step chained to verify only transitively fails the gate checker', () => { // Negative fixture: `build` depends on verify but `publish-npm` does not // carry the direct edge — weaker than SDLC-D-034 requires of the real DAG. const transitiveOnlyPipeline = ` steps: install: image: node:24-alpine commands: - pnpm install --frozen-lockfile 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 depends_on: - install build: image: node:24-alpine commands: - pnpm build depends_on: - install - verify publish-npm: image: node:24-alpine commands: - pnpm publish depends_on: - build `; assert.throws( () => assertPublishGate(parseYaml(transitiveOnlyPipeline)), /publish-npm.*DIRECTLY.*verify/s, ); }); test('a verify step without the commit-identity assertion fails the gate checker', () => { const noIdentityPipeline = ` steps: verify: image: node:24-alpine commands: - pnpm verify:release publish-npm: image: node:24-alpine commands: - pnpm publish depends_on: - verify `; assert.throws(() => assertPublishGate(parseYaml(noIdentityPipeline)), /CI_COMMIT_SHA/); }); test('the canonical verify:release stages mirror the PR CI pipeline one-for-one', async () => { const ci = parseYaml(await readFile(ciYmlPath, 'utf8')); const canonical = Object.fromEntries(STAGES.map((stage) => [stage.name, stage.commands])); // The complete mandatory set, in gate order. assert.deepEqual( STAGES.map((stage) => stage.name), ['sanitization', 'upgrade-guard', 'typecheck', 'lint', 'format', 'test', 'build'], ); // Guard stages: ci.yml commands minus its `apk add` environment prep must be // exactly the canonical stage commands (order included). for (const stageName of ['sanitization', 'upgrade-guard']) { assert.deepEqual( ci.steps[stageName].commands.filter((command) => !command.startsWith('apk add')), canonical[stageName], `canonical '${stageName}' stage must match the ci.yml step`, ); } // pnpm stages: ci.yml commands minus `corepack enable` must be exactly the // canonical stage commands. for (const stepName of ['typecheck', 'lint', 'format']) { assert.deepEqual( ci.steps[stepName].commands.filter((command) => command !== 'corepack enable'), canonical[stepName], `canonical '${stepName}' stage must match the ci.yml step`, ); } // The test stage is shared, but ci.yml wraps it in pipeline-level // prerequisites the canonical command expects its caller to provide // (SDLC-D-034): the postgres service + readiness wait + db:migrate, openssl, // and the pinned pi runtime. None of those may be dropped silently. for (const command of canonical.test) { assert.ok( ci.steps.test.commands.includes(command), `ci.yml test step must run the canonical test stage command '${command}'`, ); } for (const fragment of [ 'pg_isready -h ci-postgres', 'pnpm --filter @mosaicstack/db run db:migrate', 'npm install -g @earendil-works/pi-coding-agent@0.84.1', ]) { assert.ok( ci.steps.test.commands.some((command) => command.includes(fragment)), `ci.yml test step must keep its pipeline-level prerequisite '${fragment}'`, ); } }); test('the root package.json exposes verify:release as the canonical command', async () => { const packageJson = JSON.parse(await readFile(path.join(process.cwd(), 'package.json'), 'utf8')); assert.match(packageJson.scripts['verify:release'], /scripts\/verify-release\.mjs/); });