167 lines
6.5 KiB
JavaScript
167 lines
6.5 KiB
JavaScript
#!/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 <name>, 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));
|
|
}
|