314 lines
10 KiB
TypeScript
314 lines
10 KiB
TypeScript
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
|
import {
|
|
evaluatePromotionReadiness,
|
|
PROMOTION_FORMULA,
|
|
type GateResult,
|
|
} from "../src/application/policies/promotion-readiness.ts";
|
|
|
|
type GateDefinition = Readonly<{
|
|
steps?: readonly unknown[];
|
|
evidence?: readonly string[];
|
|
retentionClass?: string;
|
|
}>;
|
|
type CiContractDocument = Readonly<{
|
|
providerAdapter: string;
|
|
stages: Readonly<Record<string, Readonly<{ gates?: readonly string[] }>>>;
|
|
gates: Readonly<Record<string, GateDefinition>>;
|
|
retention: Readonly<{ durationStatus: unknown }>;
|
|
}>;
|
|
|
|
const document = parseCiContractDocument(
|
|
JSON.parse(await readFile("config/ci/gates.json", "utf8")),
|
|
);
|
|
const workflow = await readFile(document.providerAdapter, "utf8");
|
|
const nodeVersion = (await readFile(".nvmrc", "utf8")).trim();
|
|
const gateRunner = await readFile("scripts/run-ci-gate.ts", "utf8");
|
|
const drillRunner = await readFile("scripts/drill-runbook.ts", "utf8");
|
|
const buildManifestGenerator = await readFile(
|
|
"scripts/generate-build-manifest.ts",
|
|
"utf8",
|
|
);
|
|
const failures: string[] = [];
|
|
if (!/^\d+\.\d+\.\d+$/.test(nodeVersion)) {
|
|
failures.push(".nvmrc must contain one exact Node.js semantic version");
|
|
}
|
|
const setupNodeCount =
|
|
workflow.match(/uses:\s*actions\/setup-node@v4/g)?.length ?? 0;
|
|
const nodeVersionFileCount =
|
|
workflow.match(/node-version-file:\s*\.nvmrc/g)?.length ?? 0;
|
|
if (setupNodeCount === 0 || nodeVersionFileCount !== setupNodeCount) {
|
|
failures.push("every setup-node step must use node-version-file: .nvmrc");
|
|
}
|
|
if (/node-version\s*:/.test(workflow) || /NODE_VERSION\s*:/.test(workflow)) {
|
|
failures.push("workflow must not override the exact .nvmrc Node.js pin");
|
|
}
|
|
const stageFormula: Readonly<Record<string, readonly string[]>> = {
|
|
merge: PROMOTION_FORMULA.MERGE_READY,
|
|
release: PROMOTION_FORMULA.RELEASE_READY,
|
|
production: PROMOTION_FORMULA.PROD_PROMOTION_READY,
|
|
field: PROMOTION_FORMULA.FIELD_SLO_READY,
|
|
documentation: PROMOTION_FORMULA.DOCUMENTATION_READY,
|
|
};
|
|
|
|
for (const [stage, expectedGates] of Object.entries(stageFormula)) {
|
|
const actual = document.stages[stage]?.gates;
|
|
if (JSON.stringify(actual) !== JSON.stringify(expectedGates)) {
|
|
failures.push(`${stage} gate formula drift`);
|
|
}
|
|
}
|
|
|
|
const configuredGateIds = Object.keys(document.gates).sort();
|
|
const expectedGateIds = Array.from(
|
|
{ length: 26 },
|
|
(_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`,
|
|
);
|
|
if (JSON.stringify(configuredGateIds) !== JSON.stringify(expectedGateIds)) {
|
|
failures.push("gate registry must contain FE-GATE-001..026 exactly once");
|
|
}
|
|
|
|
for (const [gateId, gate] of Object.entries(document.gates)) {
|
|
if (!gate.steps?.length || !gate.evidence?.length || !gate.retentionClass) {
|
|
failures.push(`${gateId} lacks command, evidence, or retention wiring`);
|
|
}
|
|
for (const [index, step] of (gate.steps ?? []).entries()) {
|
|
if (!isRecord(step) || (step.expect !== "pass" && step.expect !== "fail")) {
|
|
failures.push(`${gateId}[${index}] has an invalid step expectation`);
|
|
continue;
|
|
}
|
|
if (step.expect === "pass") {
|
|
if (
|
|
step.expectedExitCode !== undefined ||
|
|
step.expectedDiagnosticId !== undefined
|
|
) {
|
|
failures.push(
|
|
`${gateId}[${index}] passing step declares a negative fixture identity`,
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
if (
|
|
typeof step.expectedExitCode !== "number" ||
|
|
!Number.isSafeInteger(step.expectedExitCode) ||
|
|
step.expectedExitCode < 1 ||
|
|
step.expectedExitCode > 255
|
|
) {
|
|
failures.push(`${gateId}[${index}] lacks an exact expected exit code`);
|
|
}
|
|
const diagnosticId = step.expectedDiagnosticId;
|
|
if (
|
|
typeof diagnosticId !== "string" ||
|
|
diagnosticId.trim().length === 0 ||
|
|
diagnosticId.length > 256 ||
|
|
["\r", "\n", "\0"].some(
|
|
(character) =>
|
|
typeof diagnosticId === "string" && diagnosticId.includes(character),
|
|
)
|
|
) {
|
|
failures.push(
|
|
`${gateId}[${index}] lacks a bounded expected diagnostic identity`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const runbookGateEvidence = Object.freeze({
|
|
"FE-GATE-016": "artifacts/runbooks/FE-RB-005/record.json",
|
|
"FE-GATE-021": "artifacts/runbooks/FE-RB-001/record.json",
|
|
"FE-GATE-022": "artifacts/runbooks/FE-RB-002/record.json",
|
|
"FE-GATE-023": "artifacts/runbooks/FE-RB-003/record.json",
|
|
"FE-GATE-024": "artifacts/runbooks/FE-RB-004/record.json",
|
|
"FE-GATE-025": "artifacts/runbooks/FE-RB-005/record.json",
|
|
});
|
|
for (const [gateId, evidencePath] of Object.entries(runbookGateEvidence)) {
|
|
const evidence = document.gates[gateId]?.evidence;
|
|
if (
|
|
!Array.isArray(evidence) ||
|
|
evidence.length !== 1 ||
|
|
evidence[0] !== evidencePath
|
|
) {
|
|
failures.push(`${gateId} runbook evidence path drift`);
|
|
}
|
|
}
|
|
if (
|
|
!drillRunner.includes(
|
|
"const artifactDirectory = `artifacts/runbooks/${runbookId}`",
|
|
) ||
|
|
drillRunner.includes(
|
|
"artifacts/runbooks/${runbookId}/${release.releaseId}",
|
|
)
|
|
) {
|
|
failures.push(
|
|
"runbook evidence path must be stable while releaseId stays in the record",
|
|
);
|
|
}
|
|
|
|
const forbiddenWorkflowPatterns = [
|
|
/continue-on-error\s*:/,
|
|
/retention-days\s*:/,
|
|
/allow_failure\s*:/,
|
|
];
|
|
for (const pattern of forbiddenWorkflowPatterns) {
|
|
if (pattern.test(workflow)) {
|
|
failures.push(`workflow contains forbidden downgrade/unsupported setting ${pattern}`);
|
|
}
|
|
}
|
|
const jobTimeoutCount = workflow.match(/timeout-minutes:\s*45/g)?.length ?? 0;
|
|
if (jobTimeoutCount !== 5) {
|
|
failures.push("every CI gate job must declare timeout-minutes: 45");
|
|
}
|
|
if (/if-no-files-found:\s*warn/.test(workflow)) {
|
|
failures.push("CI evidence upload must fail when artifacts are absent");
|
|
}
|
|
for (const requiredToken of [
|
|
"merge_gate:",
|
|
"release_gate:",
|
|
"production_gate:",
|
|
"field_gate:",
|
|
"documentation_gate:",
|
|
"needs: merge_gate",
|
|
"needs: release_gate",
|
|
"needs: production_gate",
|
|
"actions/upload-artifact@v4",
|
|
"if: always()",
|
|
"permissions:",
|
|
"contents: read",
|
|
'CI: "true"',
|
|
'VITE_BUILD_ID: "gitea-${{ gitea.run_id }}-${{ gitea.run_attempt }}"',
|
|
'VITE_COMMIT_SHA: "${{ gitea.sha }}"',
|
|
'RELEASE_ID: "${{ gitea.ref }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}"',
|
|
'CI_RUNNER_IMAGE: "${{ vars.RUNNER_IMAGE_DIGEST }}"',
|
|
]) {
|
|
if (!workflow.includes(requiredToken)) {
|
|
failures.push(`workflow missing ${requiredToken}`);
|
|
}
|
|
}
|
|
for (const requiredToken of [
|
|
"ciCheckoutIdentityFailures",
|
|
"ciBuildEnvironmentFailures",
|
|
"SOURCE_DATE_EPOCH",
|
|
'"--format=%H%n%ct"',
|
|
"env: gateEnvironment",
|
|
"classifyGateStepResult",
|
|
"timeout: step.timeoutMs ?? DEFAULT_STEP_TIMEOUT_MS",
|
|
]) {
|
|
if (!gateRunner.includes(requiredToken)) {
|
|
failures.push(`CI gate runner missing ${requiredToken}`);
|
|
}
|
|
}
|
|
for (const requiredToken of [
|
|
"assertCiBuildEnvironment(process.env)",
|
|
"releaseId",
|
|
"sourceDateEpoch",
|
|
]) {
|
|
if (!buildManifestGenerator.includes(requiredToken)) {
|
|
failures.push(`build manifest generator missing ${requiredToken}`);
|
|
}
|
|
}
|
|
|
|
const passingResults: Record<string, GateResult> = {};
|
|
for (const gateId of expectedGateIds) passingResults[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 gateIds = PROMOTION_FORMULA[readiness];
|
|
const failedGate = gateIds[0];
|
|
if (!failedGate) throw new Error(`${readiness} has no configured gates`);
|
|
const result = evaluatePromotionReadiness({
|
|
...passingResults,
|
|
[failedGate]: "FAIL",
|
|
});
|
|
const passed = result[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 report = {
|
|
schemaVersion: 1,
|
|
generatedAt: new Date().toISOString(),
|
|
providerAdapter: document.providerAdapter,
|
|
nodeVersion,
|
|
gateCount: configuredGateIds.length,
|
|
noDowngrade: failures.every(
|
|
(failure) => !failure.includes("downgrade"),
|
|
),
|
|
durationStatus: document.retention.durationStatus,
|
|
negativeFixtures,
|
|
failures,
|
|
passed: failures.length === 0,
|
|
};
|
|
await mkdir("artifacts/quality", { recursive: true });
|
|
await writeFile(
|
|
"artifacts/quality/ci-contract.json",
|
|
`${JSON.stringify(report, null, 2)}\n`,
|
|
);
|
|
if (failures.length > 0) {
|
|
process.stderr.write(`CI contract failed:\n${failures.join("\n")}\n`);
|
|
process.exit(1);
|
|
}
|
|
process.stdout.write("CI contract: 26 blocking gates and 4-tier graph PASS\n");
|
|
|
|
function parseCiContractDocument(value: unknown): CiContractDocument {
|
|
if (!isRecord(value)) throw new TypeError("CI gate config must be an object");
|
|
if (
|
|
typeof value.providerAdapter !== "string" ||
|
|
!isRecord(value.stages) ||
|
|
!isRecord(value.gates) ||
|
|
!isRecord(value.retention)
|
|
) {
|
|
throw new TypeError("CI gate config is missing required registries");
|
|
}
|
|
const stages: Record<string, { gates?: readonly string[] }> = {};
|
|
for (const [stage, candidate] of Object.entries(value.stages)) {
|
|
if (!isRecord(candidate)) throw new TypeError(`Invalid CI stage: ${stage}`);
|
|
if (
|
|
candidate.gates !== undefined &&
|
|
(!Array.isArray(candidate.gates) ||
|
|
!candidate.gates.every((gate) => typeof gate === "string"))
|
|
) {
|
|
throw new TypeError(`Invalid gate list for CI stage: ${stage}`);
|
|
}
|
|
stages[stage] = {
|
|
gates: candidate.gates as readonly string[] | undefined,
|
|
};
|
|
}
|
|
const gates: Record<string, GateDefinition> = {};
|
|
for (const [gateId, candidate] of Object.entries(value.gates)) {
|
|
if (!isRecord(candidate)) throw new TypeError(`Invalid CI gate: ${gateId}`);
|
|
if (
|
|
candidate.evidence !== undefined &&
|
|
(!Array.isArray(candidate.evidence) ||
|
|
!candidate.evidence.every((path) => typeof path === "string"))
|
|
) {
|
|
throw new TypeError(`Invalid evidence list for CI gate: ${gateId}`);
|
|
}
|
|
gates[gateId] = {
|
|
steps: Array.isArray(candidate.steps) ? candidate.steps : undefined,
|
|
evidence: candidate.evidence as readonly string[] | undefined,
|
|
retentionClass:
|
|
typeof candidate.retentionClass === "string"
|
|
? candidate.retentionClass
|
|
: undefined,
|
|
};
|
|
}
|
|
return {
|
|
providerAdapter: value.providerAdapter,
|
|
stages,
|
|
gates,
|
|
retention: { durationStatus: value.retention.durationStatus },
|
|
};
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
}
|