Files
clean-architecture-frontend…/scripts/check-ci-contract.ts
T
2026-08-01 19:39:59 +09:00

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));
}