#!/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)); }