143 lines
5.4 KiB
TypeScript
143 lines
5.4 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { readFile } from "node:fs/promises";
|
|
|
|
import {
|
|
evaluatePromotionReadiness,
|
|
PROMOTION_FORMULA,
|
|
type GateResult,
|
|
} from "../src/application/policies/promotion-readiness.ts";
|
|
import {
|
|
loadCiGateContract,
|
|
indexCiGateContract,
|
|
} from "./contracts/ci-gates.ts";
|
|
import { generateCiWorkflow, renderCiWorkflow } from "./generate-ci-workflow.ts";
|
|
import {
|
|
ciContractReportSchema,
|
|
} from "./lib/ci-contract-report.ts";
|
|
import {
|
|
validateInstallScriptPolicy,
|
|
validatePackageScriptGraph,
|
|
} from "./lib/package-script-graph.ts";
|
|
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
|
|
|
const removalFixtureMode = process.argv.includes("--reduced-removal-fixture");
|
|
const contract = await loadCiGateContract(process.cwd(), {
|
|
mode: removalFixtureMode ? "removal-fixture" : "canonical",
|
|
});
|
|
const index = indexCiGateContract(contract);
|
|
const [packageDocument, nodeVersion] = await Promise.all([
|
|
readFile("package.json", "utf8").then((value) => JSON.parse(value) as { scripts?: Record<string, string> }),
|
|
readFile(".nvmrc", "utf8").then((value) => value.trim()),
|
|
]);
|
|
const packageScripts = packageDocument.scripts ?? {};
|
|
const failures: string[] = [];
|
|
|
|
if (!/^\d+\.\d+\.\d+$/u.test(nodeVersion)) {
|
|
failures.push(".nvmrc must contain one exact Node.js semantic version");
|
|
}
|
|
for (const script of [
|
|
"build:release-candidate",
|
|
"verify:local-evidence",
|
|
"verify:promotion",
|
|
"generate:ci-workflow",
|
|
"check:ci-workflow",
|
|
"check:ci",
|
|
]) {
|
|
if (!packageScripts[script]) failures.push(`package script missing ${script}`);
|
|
}
|
|
for (const removedScript of [
|
|
"build:release",
|
|
"verify:supply-chain",
|
|
"verify:supply-chain:promotion",
|
|
]) {
|
|
if (packageScripts[removedScript]) failures.push(`legacy package script remains ${removedScript}`);
|
|
}
|
|
if (/\b(?:build|rebuild)(?::[\w-]+)?\b/u.test(packageScripts["verify:promotion"] ?? "")) {
|
|
failures.push("verify:promotion must not build or rebuild candidate bytes");
|
|
}
|
|
failures.push(...validatePackageScriptGraph(packageScripts, "check:ci"));
|
|
failures.push(
|
|
...validateInstallScriptPolicy(
|
|
packageScripts,
|
|
[...new Set(contract.commands.map(({ script }) => script))],
|
|
),
|
|
);
|
|
|
|
const immutable = index.gates.get("FE-GATE-015");
|
|
const immutableCommands = immutable?.commandIds.map((id) => index.commands.get(id)?.script);
|
|
if (JSON.stringify(immutableCommands) !== JSON.stringify(["build:release-candidate", "verify:local-evidence"])) {
|
|
failures.push("FE-GATE-015 must build candidate bytes once and verify local evidence only");
|
|
}
|
|
const architecture = index.gates.get("FE-GATE-010");
|
|
if (!architecture?.commandIds.some((id) => index.commands.get(id)?.script === "check:ci")) {
|
|
failures.push("a blocking gate must execute check:ci");
|
|
}
|
|
if (!architecture?.evidenceArtifactIds.some((id) => index.artifacts.get(id)?.path === "artifacts/quality/ci-contract.json")) {
|
|
failures.push("FE-GATE-010 must publish the typed CI contract report");
|
|
}
|
|
|
|
const expectedGateIds = Array.from(
|
|
{ length: 26 },
|
|
(_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`,
|
|
);
|
|
const passingResults: Record<string, GateResult> = Object.fromEntries(
|
|
expectedGateIds.map((gateId) => [gateId, "PASS"]),
|
|
);
|
|
const allPass = evaluatePromotionReadiness(passingResults);
|
|
const negativeFixtures: Array<{
|
|
readiness: keyof typeof PROMOTION_FORMULA;
|
|
failedGate: string;
|
|
passed: boolean;
|
|
}> = [];
|
|
for (const readiness of Object.keys(PROMOTION_FORMULA) as Array<keyof typeof PROMOTION_FORMULA>) {
|
|
const failedGate = PROMOTION_FORMULA[readiness][0];
|
|
if (!failedGate) throw new TypeError(`${readiness} has no configured gates`);
|
|
const evaluated = evaluatePromotionReadiness({ ...passingResults, [failedGate]: "FAIL" });
|
|
const passed = evaluated[readiness] === false;
|
|
negativeFixtures.push({ readiness, failedGate, passed });
|
|
if (!passed) failures.push(`${readiness} did not fail closed`);
|
|
}
|
|
if (!Object.values(allPass).every(Boolean)) failures.push("all-PASS formula did not produce every readiness state");
|
|
|
|
const renderedWorkflow = renderCiWorkflow(contract);
|
|
const workflowCheck = await generateCiWorkflow({
|
|
root: process.cwd(),
|
|
contract,
|
|
check: true,
|
|
});
|
|
if (!workflowCheck.matches) {
|
|
failures.push(
|
|
`generated workflow drift at byte ${workflowCheck.firstDifferenceByte ?? "missing"}, line ${workflowCheck.firstDifferenceLine ?? "missing"}`,
|
|
);
|
|
}
|
|
let checkedWorkflowBytes = Buffer.from(renderedWorkflow, "utf8");
|
|
try {
|
|
checkedWorkflowBytes = await readFile(workflowCheck.target);
|
|
} catch (error) {
|
|
failures.push(`generated workflow is unreadable: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
const report = ciContractReportSchema.parse({
|
|
schemaVersion: 2,
|
|
nodeVersion,
|
|
gateCount: contract.gates.length,
|
|
commandDefinitionCount: contract.commands.length,
|
|
commandReferenceCount: contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0),
|
|
artifactCount: contract.artifacts.length,
|
|
jobCount: contract.jobs.length,
|
|
workflowSha256: createHash("sha256").update(checkedWorkflowBytes).digest("hex"),
|
|
durationStatus: contract.retention.durationStatus,
|
|
negativeFixtures,
|
|
failures,
|
|
passed: failures.length === 0,
|
|
});
|
|
await writeValidatedJsonArtifact({
|
|
path: "artifacts/quality/ci-contract.json",
|
|
schema: ciContractReportSchema,
|
|
value: report,
|
|
});
|
|
if (failures.length > 0) {
|
|
process.stderr.write(`CI contract failed:\n${failures.join("\n")}\n`);
|
|
process.exit(1);
|
|
}
|
|
process.stdout.write("CI contract: 26 gates, strict v2 graph and generated workflow model PASS\n");
|