From 46784c8dd4b5dca388d8d714e915b1b63c5243c3 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 17 Aug 2026 00:44:54 -0500 Subject: [PATCH] feat(ri-050): bind next publication to exact-commit terminal verification (#1275) --- .woodpecker/ci.yml | 25 ++- .woodpecker/publish.yml | 60 +++++++ package.json | 1 + scripts/verify-release.mjs | 166 +++++++++++++++++++ scripts/verify-release.test.mjs | 277 ++++++++++++++++++++++++++++++++ 5 files changed, 528 insertions(+), 1 deletion(-) create mode 100644 scripts/verify-release.mjs create mode 100644 scripts/verify-release.test.mjs diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index d0ce1c1f..35acff9e 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -30,6 +30,19 @@ steps: # the baked pnpm store. - pnpm install --frozen-lockfile --prefer-offline + # --------------------------------------------------------------------------- + # The steps below (sanitization, upgrade-guard, typecheck, lint, format, + # test) are the COMPLETE mandatory verification set. SDLC-D-034 mirrors them + # one-for-one in the canonical terminal verification command — root + # `pnpm verify:release` (scripts/verify-release.mjs) — which the publish + # pipeline (.woodpecker/publish.yml `verify` step) runs before ANY publish + # effect. These lines stay direct (not routed through the runner) because the + # #1017 test-enumeration guard audits framework tool paths through THIS + # surface; scripts/verify-release.test.mjs enforces that the runner's stage + # table keeps matching these commands exactly, so the two cannot drift. + # --------------------------------------------------------------------------- + + # Canonical verify:release stage `sanitization`. # Blocking gate: public framework package must contain no operator-specific # personal data or private $HOME defaults. Runs early (no node_modules needed). sanitization: @@ -47,6 +60,7 @@ steps: # with everything it guards; this direct line keeps one instrument running. - bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh + # Canonical verify:release stage `upgrade-guard`. # Blocking gate (#791): a framework upgrade must never write or delete an # operator-owned path. The HARD GATE proves an unanticipated operator sentinel # survives a keep-mode reseed byte-identical (with rsync present AND absent — @@ -68,6 +82,8 @@ steps: - bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh - bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh + # Canonical verify:release stage `typecheck` — the same `pnpm typecheck` + # invocation (which runs the checkout preflight first, then turbo). typecheck: image: *node_image commands: @@ -78,7 +94,8 @@ steps: - sanitization - upgrade-guard - # lint, format, and test are independent — run in parallel after typecheck + # lint, format, and test are independent — run in parallel after typecheck. + # Each runs exactly its canonical verify:release stage command. lint: image: *node_image commands: @@ -95,6 +112,12 @@ steps: depends_on: - typecheck + # Canonical verify:release stage `test` — the `pnpm test` line below is the + # shared command; everything else in this step is PIPELINE-LEVEL + # prerequisite the canonical command expects its caller to provide (SDLC-D-034): + # the ci-postgres service + pg_isready wait + db:migrate (postgres path), + # `apk add openssl`, and the pinned pi install. None of those can move into + # the runner (it must also work locally on the PGlite path with no database). test: image: *node_image environment: diff --git a/.woodpecker/publish.yml b/.woodpecker/publish.yml index cce35805..d35da3d3 100644 --- a/.woodpecker/publish.yml +++ b/.woodpecker/publish.yml @@ -1,5 +1,19 @@ # Build, publish npm packages, and push Docker images # Runs on main for stable publishes and on next for integration-line prereleases/images +# +# SDLC-D-034 publish gate: every publish effect (publish-npm, publish-next-npm, +# and every image build/push step) depends DIRECTLY on the `verify` step below. +# `verify` (a) asserts the provider's commit identity matches the actual +# checkout (CI_COMMIT_SHA == git rev-parse HEAD, fail closed on mismatch or +# emptiness) and (b) runs the canonical terminal verification command +# (`pnpm verify:release`), which mirrors the PR CI pipeline's complete +# mandatory set (sanitization, upgrade-guard, preflight+typecheck, lint, +# format:check, test, build) — see scripts/verify-release.mjs. A missing, +# failed, skipped, cancelled, or inconclusive verification therefore skips the +# dependent publish effects (fail closed). Path-filtered short-circuits may +# skip publish EFFECTS (e.g. docs-only merges) but never bypass `verify` for a +# publish that does run: `verify` itself carries no path filter. +# scripts/verify-release.test.mjs enforces this DAG invariant at checkout time. variables: # Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine + @@ -48,6 +62,45 @@ steps: # Resolve from the baked pnpm store instead of a cold network fetch. - pnpm install --frozen-lockfile --prefer-offline + # SDLC-D-034 exact-commit publish gate. No `when`/path filter on purpose: it + # runs for every event this pipeline serves so no publish effect can ever + # start without it. Fails closed on commit-identity mismatch (or either SHA + # being empty) and on any incomplete verification. + verify: + image: *node_image + commands: + - *enable_pnpm + # (a) Commit identity: the provider's claimed SHA must equal the actual + # checkout HEAD — verification of anything else must never authorize a + # publish of this commit. + - | + if [ -z "$CI_COMMIT_SHA" ]; then + echo "[verify] FATAL: CI_COMMIT_SHA is empty — cannot certify commit identity" >&2 + exit 1 + fi + CHECKOUT_SHA="$(git rev-parse HEAD 2>/dev/null || true)" + if [ -z "$CHECKOUT_SHA" ]; then + echo "[verify] FATAL: git rev-parse HEAD returned nothing — cannot certify commit identity" >&2 + exit 1 + fi + if [ "$CI_COMMIT_SHA" != "$CHECKOUT_SHA" ]; then + echo "[verify] FATAL: provider commit ($CI_COMMIT_SHA) != checkout HEAD ($CHECKOUT_SHA)" >&2 + exit 1 + fi + echo "[verify] commit identity confirmed: $CHECKOUT_SHA" + # (b) Canonical terminal verification. Caller-provided prerequisites the + # runner expects (see .woodpecker/ci.yml comments): bash/rsync for the + # guard stages, openssl + the pinned pi binary for the test stage. git is + # baked into ci-base but re-asserted here so the identity check above can + # never silently depend on a stale baked image. DATABASE_URL is + # deliberately NOT set: the canonical command must hold on the PGlite + # path too and never sets or requires a database itself. + - apk add --no-cache bash rsync openssl git + - npm install -g @earendil-works/pi-coding-agent@0.84.1 + - pnpm verify:release + depends_on: + - install + build: image: *node_image commands: @@ -55,6 +108,7 @@ steps: - pnpm build depends_on: - install + - verify publish-npm: image: *node_image @@ -114,6 +168,7 @@ steps: exit 1 depends_on: - build + - verify publish-next-npm: image: *node_image @@ -192,6 +247,7 @@ steps: echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION" depends_on: - build + - verify # TODO: Uncomment when ready to publish to npmjs.org # publish-npmjs: @@ -205,6 +261,7 @@ steps: # - bash scripts/publish-npmjs.sh # depends_on: # - build + # - verify # when: # - event: [tag] @@ -242,6 +299,7 @@ steps: /kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS depends_on: - build + - verify build-appservice: image: gcr.io/kaniko-project/executor:debug @@ -268,6 +326,7 @@ steps: /kaniko/executor --context . --dockerfile docker/appservice.Dockerfile $DESTINATIONS depends_on: - build + - verify build-web: image: gcr.io/kaniko-project/executor:debug @@ -294,3 +353,4 @@ steps: /kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS depends_on: - build + - verify diff --git a/package.json b/package.json index 602bb7ee..3ba7a059 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "preflight": "node scripts/preflight.mjs", "clean:generated": "node scripts/clean-generated.mjs", "typecheck": "pnpm preflight && turbo run typecheck", + "verify:release": "node scripts/verify-release.mjs", "test:checkout": "node --test scripts/*.test.mjs", "test": "pnpm test:checkout && turbo run test && pnpm run test:installer", "test:installer": "bash tools/install-next-lane.test.sh", diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs new file mode 100644 index 00000000..f0b518f4 --- /dev/null +++ b/scripts/verify-release.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node +// verify-release.mjs — the ONE canonical terminal verification command +// (SDLC-D-034, `pnpm verify:release`). +// +// Publication (.woodpecker/publish.yml `verify` step) is bound to terminal +// verification of the exact commit through this command, which is composed +// from the SAME commands the PR CI pipeline (.woodpecker/ci.yml) runs — CI and +// publish share one semantic checklist: +// +// stage | mirrors ci.yml step | commands +// --------------|---------------------|------------------------------------------ +// sanitization | sanitization | verify-sanitized.sh, check-resident- +// | | budget.sh (--self-test + run), +// | | check-test-enumeration.sh +// upgrade-guard | upgrade-guard | test-upgrade-manifest-guard.sh, +// | | test-upgrade-rollback.sh, +// | | test-upgrade-durable-snapshot.sh, +// | | test-install-migration.sh +// typecheck | typecheck | pnpm typecheck (runs the checkout +// | | preflight, then turbo typecheck) +// lint | lint | pnpm lint +// format | format | pnpm format:check +// test | test | pnpm test +// build | publish.yml build | pnpm build +// +// Caller-provided prerequisites (kept at the pipeline level — see the comments +// in .woodpecker/ci.yml): `bash` + `rsync` for the guard stages, `openssl` and +// the pinned @earendil-works/pi-coding-agent for the test stage, and — on the +// postgres path only — the ci-postgres service plus +// `pnpm --filter @mosaicstack/db run db:migrate` before the test stage. +// +// This command works with DATABASE_URL set (CI postgres path) or unset (local +// PGlite path); it never sets, exports, or requires a database itself. +// +// scripts/verify-release.test.mjs enforces that this stage table keeps +// matching .woodpecker/ci.yml step-for-step, so the two surfaces cannot drift +// apart silently. + +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const STAGES = [ + { + name: 'sanitization', + // Mirror of the .woodpecker/ci.yml `sanitization` step (minus its + // `apk add` environment prep). Kept as direct command strings here: the + // #1017 test-enumeration guard audits these paths through the ci.yml + // surface, so indirection from ci.yml into this file is not possible. + commands: [ + 'bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh', + 'bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test', + 'bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh', + 'bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh', + ], + }, + { + name: 'upgrade-guard', + // Mirror of the .woodpecker/ci.yml `upgrade-guard` step (minus its + // `apk add` environment prep). + commands: [ + 'bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-manifest-guard.sh', + 'bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh', + 'bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh', + 'bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh', + ], + }, + { + // `pnpm typecheck` is `pnpm preflight && turbo run typecheck`, so the + // checkout preflight (scripts/preflight.mjs) is part of this stage exactly + // as it is part of the ci.yml `typecheck` step. + name: 'typecheck', + commands: ['pnpm typecheck'], + }, + { + name: 'lint', + commands: ['pnpm lint'], + }, + { + name: 'format', + commands: ['pnpm format:check'], + }, + { + // Requires `openssl` and the pinned `pi` binary on the pipeline path; see + // the caller-provided prerequisites above. + name: 'test', + commands: ['pnpm test'], + }, + { + name: 'build', + commands: ['pnpm build'], + }, +]; + +export function stageByName(name) { + return STAGES.find((stage) => stage.name === name); +} + +function missingBinaries(bins) { + return bins.filter( + (bin) => spawnSync('sh', ['-c', `command -v ${bin} >/dev/null 2>&1`]).status !== 0, + ); +} + +function runCommand(command) { + const result = spawnSync(command, { shell: true, stdio: 'inherit' }); + if (result.error) { + console.error(`[verify:release] failed to launch '${command}': ${result.error.message}`); + return false; + } + if (result.status !== 0) { + const reason = result.signal ? `terminated by ${result.signal}` : `exited ${result.status}`; + console.error(`[verify:release] command '${command}' ${reason}`); + return false; + } + return true; +} + +// Runs the complete mandatory verification set (or, with --stage , the +// single named stage — used for wiring/smoke-testing, not for gating: only a +// run of every stage is a terminal verification). Fails fast: the first +// failing command aborts with a non-zero exit code. Returns the exit code. +export function verifyRelease({ stages = STAGES } = {}) { + const missing = missingBinaries(['bash', 'rsync']); + if (missing.length > 0) { + console.error( + `[verify:release] FATAL: required binaries missing from PATH: ${missing.join(', ')}. ` + + 'The caller provides them (ci-base bakes bash; pipelines apk add rsync).', + ); + return 1; + } + for (const stage of stages) { + console.log(`\n[verify:release] === stage: ${stage.name} ===`); + for (const command of stage.commands) { + console.log(`[verify:release] $ ${command}`); + if (!runCommand(command)) { + console.error( + `[verify:release] FATAL: stage '${stage.name}' failed — verification inconclusive`, + ); + return 1; + } + } + } + console.log(`\n[verify:release] all ${stages.length} stage(s) passed`); + return 0; +} + +function main(argv) { + const stageFlagIndex = argv.indexOf('--stage'); + if (stageFlagIndex !== -1) { + const name = argv[stageFlagIndex + 1]; + const stage = stageByName(name); + if (!stage) { + console.error( + `[verify:release] unknown stage '${name ?? ''}' — expected one of: ${STAGES.map((entry) => entry.name).join(', ')}`, + ); + process.exit(2); + } + process.exit(verifyRelease({ stages: [stage] })); + } + process.exit(verifyRelease()); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)); +} diff --git a/scripts/verify-release.test.mjs b/scripts/verify-release.test.mjs new file mode 100644 index 00000000..d83d1c18 --- /dev/null +++ b/scripts/verify-release.test.mjs @@ -0,0 +1,277 @@ +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/); +});