refactor: generate CI workflow from gate contracts
This commit is contained in:
+81
-329
@@ -1,217 +1,42 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
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 { validatePackageScriptGraph } from "./lib/package-script-graph.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.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 packageDocument = JSON.parse(await readFile("package.json", "utf8")) as {
|
||||
scripts?: Record<string, string>;
|
||||
};
|
||||
const contract = await loadCiGateContract(process.cwd());
|
||||
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 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)) {
|
||||
|
||||
if (!/^\d+\.\d+\.\d+$/u.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 !== 9) {
|
||||
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:",
|
||||
"immutable_build:",
|
||||
"vulnerability_provider:",
|
||||
"provenance_provider:",
|
||||
"promotion:",
|
||||
"needs: merge_gate",
|
||||
"needs: release_gate",
|
||||
"needs: production_gate",
|
||||
"needs: immutable_build",
|
||||
"needs: [immutable_build, vulnerability_provider, provenance_provider]",
|
||||
"actions/download-artifact@v4",
|
||||
"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 }}"',
|
||||
"VULNERABILITY_REPORT_PATH:",
|
||||
"PROVENANCE_ATTESTATION_PATH:",
|
||||
"CANDIDATE_LOCKFILE_PATH: pnpm-lock.yaml",
|
||||
"VULNERABILITY_PROVIDER_COMMAND:",
|
||||
"PROVENANCE_PROVIDER_COMMAND:",
|
||||
"VULNERABILITY_PUBLIC_KEY_PATH:",
|
||||
"VULNERABILITY_KEY_ID:",
|
||||
"PROVENANCE_PUBLIC_KEY_PATH:",
|
||||
"PROVENANCE_KEY_ID:",
|
||||
" pnpm-lock.yaml \\",
|
||||
"release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}",
|
||||
"corepack pnpm verify:provider-evidence",
|
||||
"corepack pnpm verify:promotion",
|
||||
]) {
|
||||
if (!workflow.includes(requiredToken)) {
|
||||
failures.push(`workflow missing ${requiredToken}`);
|
||||
}
|
||||
}
|
||||
for (const script of [
|
||||
"build:release-candidate",
|
||||
"verify:local-evidence",
|
||||
"verify:provider-evidence",
|
||||
"verify:promotion",
|
||||
"generate:ci-workflow",
|
||||
"check:ci-workflow",
|
||||
"check:ci",
|
||||
]) {
|
||||
if (!packageScripts[script]) failures.push(`package script missing ${script}`);
|
||||
}
|
||||
@@ -220,160 +45,87 @@ for (const removedScript of [
|
||||
"verify:supply-chain",
|
||||
"verify:supply-chain:promotion",
|
||||
]) {
|
||||
if (packageScripts[removedScript]) {
|
||||
failures.push(`legacy package script remains ${removedScript}`);
|
||||
}
|
||||
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");
|
||||
}
|
||||
const promotionWorkflow = workflow.match(
|
||||
/\n {2}promotion:\n(?<body>[\s\S]*?)\n {2}production_gate:/u,
|
||||
)?.groups?.body;
|
||||
if (!promotionWorkflow) {
|
||||
failures.push("workflow promotion job is missing or misplaced");
|
||||
} else if (
|
||||
/\b(?:build|build:[\w-]+|rebuild)\b/u.test(
|
||||
promotionWorkflow.replaceAll("immutable_build", ""),
|
||||
)
|
||||
) {
|
||||
failures.push("workflow promotion job must not build or rebuild candidate bytes");
|
||||
failures.push(...validatePackageScriptGraph(packageScripts, "check:ci"));
|
||||
|
||||
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 immutableGateSteps = document.gates["FE-GATE-015"]?.steps;
|
||||
if (
|
||||
JSON.stringify(immutableGateSteps) !==
|
||||
JSON.stringify([
|
||||
{ script: "build:release-candidate", expect: "pass" },
|
||||
{ script: "verify:local-evidence", expect: "pass" },
|
||||
])
|
||||
) {
|
||||
failures.push("FE-GATE-015 must build the candidate 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");
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
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 passingResults: Record<string, GateResult> = {};
|
||||
for (const gateId of expectedGateIds) passingResults[gateId] = "PASS";
|
||||
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 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;
|
||||
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");
|
||||
}
|
||||
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,
|
||||
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: configuredGateIds.length,
|
||||
noDowngrade: failures.every(
|
||||
(failure) => !failure.includes("downgrade"),
|
||||
),
|
||||
durationStatus: document.retention.durationStatus,
|
||||
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 mkdir("artifacts/quality", { recursive: true });
|
||||
await writeFile(
|
||||
"artifacts/quality/ci-contract.json",
|
||||
`${JSON.stringify(report, null, 2)}\n`,
|
||||
);
|
||||
});
|
||||
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 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));
|
||||
}
|
||||
process.stdout.write("CI contract: 26 gates, strict v2 graph and generated workflow model PASS\n");
|
||||
|
||||
Reference in New Issue
Block a user