feat(ri-050): bind next publication to exact-commit terminal verification (#1275)
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
This commit is contained in:
@@ -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/[email protected]',
|
||||
]) {
|
||||
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/);
|
||||
});
|
||||
Reference in New Issue
Block a user