refactor: generate CI workflow from gate contracts
This commit is contained in:
+84
-178
@@ -1,6 +1,4 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
ciCheckoutIdentityFailures,
|
||||
@@ -9,48 +7,45 @@ import {
|
||||
isValidSourceDateEpoch,
|
||||
} from "./lib/build-environment.ts";
|
||||
import { classifyGateStepResult } from "./lib/ci-step-result.ts";
|
||||
|
||||
type GateStepBase = Readonly<{
|
||||
script: string;
|
||||
args?: readonly string[];
|
||||
timeoutMs?: number;
|
||||
}>;
|
||||
type GateStep =
|
||||
| (GateStepBase & Readonly<{ expect: "pass" }>)
|
||||
| (GateStepBase &
|
||||
Readonly<{
|
||||
expect: "fail";
|
||||
expectedExitCode: number;
|
||||
expectedDiagnosticId: string;
|
||||
}>);
|
||||
type GateDefinition = Readonly<{
|
||||
name: string;
|
||||
steps: readonly GateStep[];
|
||||
logPath: string;
|
||||
evidence: readonly string[];
|
||||
retentionClass: string;
|
||||
requiresEnvironment?: readonly string[];
|
||||
}>;
|
||||
type GateDocument = Readonly<{
|
||||
gates: Readonly<Record<string, GateDefinition>>;
|
||||
}>;
|
||||
import {
|
||||
indexCiGateContract,
|
||||
loadCiGateContract,
|
||||
} from "./contracts/ci-gates.ts";
|
||||
import { validateCiArtifact } from "./lib/ci-artifact-validator.ts";
|
||||
import { writeCiGateLogAtomic } from "./lib/ci-gate-log.ts";
|
||||
|
||||
const gateId = process.argv
|
||||
.slice(2)
|
||||
.find((argument) => /^FE-GATE-\d{3}$/.test(argument));
|
||||
const document = parseGateDocument(
|
||||
JSON.parse(await readFile("config/ci/gates.json", "utf8")),
|
||||
);
|
||||
const gate = gateId ? document.gates[gateId] : undefined;
|
||||
const contract = await loadCiGateContract(process.cwd());
|
||||
const contractIndex = indexCiGateContract(contract);
|
||||
const gate = gateId ? contractIndex.gates.get(gateId) : undefined;
|
||||
if (!gateId || !gate) {
|
||||
process.stderr.write("Usage: ci:gate -- FE-GATE-001..FE-GATE-026\n");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const logArtifact = contractIndex.artifacts.get(gate.logArtifactId);
|
||||
if (!logArtifact) throw new TypeError(`CI gate log artifact disappeared: ${gate.logArtifactId}`);
|
||||
const logSchema = contractIndex.artifactSchemas.get(logArtifact.schemaId);
|
||||
if (!logSchema || logSchema.kind !== "text") {
|
||||
throw new TypeError(`CI gate log schema must be bounded text: ${logArtifact.schemaId}`);
|
||||
}
|
||||
const output: string[] = [];
|
||||
let outputBytes = 0;
|
||||
let passed = true;
|
||||
const DEFAULT_STEP_TIMEOUT_MS = 30 * 60 * 1_000;
|
||||
const MAX_STEP_OUTPUT_BYTES = 16 * 1024 * 1_024;
|
||||
const LOG_DIAGNOSTIC_RESERVE_BYTES = 4_096;
|
||||
const appendOutput = (...values: readonly string[]): boolean => {
|
||||
for (const value of values.filter(Boolean)) {
|
||||
const addedBytes = Buffer.byteLength(value, "utf8") + 1;
|
||||
if (outputBytes + addedBytes > logSchema.maxBytes) return false;
|
||||
output.push(value);
|
||||
outputBytes += addedBytes;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const gateEnvironment = { ...process.env };
|
||||
if (gateEnvironment.CI === "true") {
|
||||
@@ -68,17 +63,17 @@ if (gateEnvironment.CI === "true") {
|
||||
) {
|
||||
if (!gateEnvironment.SOURCE_DATE_EPOCH?.trim()) {
|
||||
gateEnvironment.SOURCE_DATE_EPOCH = sourceDateEpoch;
|
||||
output.push(`derived SOURCE_DATE_EPOCH=${sourceDateEpoch} from HEAD`);
|
||||
appendOutput(`derived SOURCE_DATE_EPOCH=${sourceDateEpoch} from HEAD`);
|
||||
}
|
||||
for (const failure of ciCheckoutIdentityFailures(gateEnvironment, {
|
||||
commitSha,
|
||||
sourceDateEpoch,
|
||||
})) {
|
||||
output.push(failure);
|
||||
appendOutput(failure);
|
||||
passed = false;
|
||||
}
|
||||
} else {
|
||||
output.push(
|
||||
appendOutput(
|
||||
"unable to resolve the checked-out commit identity and timestamp",
|
||||
commitMetadata.stderr,
|
||||
);
|
||||
@@ -87,19 +82,27 @@ if (gateEnvironment.CI === "true") {
|
||||
}
|
||||
|
||||
for (const failure of ciBuildEnvironmentFailures(gateEnvironment)) {
|
||||
output.push(failure);
|
||||
appendOutput(failure);
|
||||
passed = false;
|
||||
}
|
||||
|
||||
for (const variable of gate.requiresEnvironment ?? []) {
|
||||
if (!gateEnvironment[variable]) {
|
||||
output.push(`missing required environment: ${variable}`);
|
||||
appendOutput(`missing required environment: ${variable}`);
|
||||
passed = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (passed) {
|
||||
for (const step of gate.steps) {
|
||||
for (const commandId of gate.commandIds) {
|
||||
const step = contractIndex.commands.get(commandId);
|
||||
if (!step) throw new TypeError(`CI gate command disappeared after validation: ${commandId}`);
|
||||
const commandLine = `$ corepack pnpm ${step.script} ${(step.args ?? []).join(" ")}`.trim();
|
||||
if (!appendOutput(commandLine) || logSchema.maxBytes - outputBytes <= LOG_DIAGNOSTIC_RESERVE_BYTES) {
|
||||
appendOutput("gate aggregate output budget exhausted before command execution");
|
||||
passed = false;
|
||||
break;
|
||||
}
|
||||
const result = spawnSync(
|
||||
"corepack",
|
||||
["pnpm", step.script, ...(step.args ?? [])],
|
||||
@@ -107,24 +110,30 @@ if (passed) {
|
||||
encoding: "utf8",
|
||||
env: gateEnvironment,
|
||||
timeout: step.timeoutMs ?? DEFAULT_STEP_TIMEOUT_MS,
|
||||
maxBuffer: MAX_STEP_OUTPUT_BYTES,
|
||||
maxBuffer: Math.min(
|
||||
MAX_STEP_OUTPUT_BYTES,
|
||||
logSchema.maxBytes - outputBytes - LOG_DIAGNOSTIC_RESERVE_BYTES,
|
||||
),
|
||||
},
|
||||
);
|
||||
const stdout = result.stdout ?? "";
|
||||
const stderr = result.stderr ?? "";
|
||||
output.push(
|
||||
`$ corepack pnpm ${step.script} ${(step.args ?? []).join(" ")}`.trim(),
|
||||
stdout,
|
||||
stderr,
|
||||
);
|
||||
const expectation =
|
||||
step.expect === "pass"
|
||||
? ({ kind: "pass" } as const)
|
||||
: ({
|
||||
if (!appendOutput(stdout, stderr)) {
|
||||
appendOutput("gate aggregate output exceeded the bounded log schema");
|
||||
passed = false;
|
||||
break;
|
||||
}
|
||||
const expectation = step.expect === "pass"
|
||||
? ({ kind: "pass" } as const)
|
||||
: step.expectedExitCode !== undefined && step.expectedDiagnosticId !== undefined
|
||||
? ({
|
||||
kind: "fail",
|
||||
expectedExitCode: step.expectedExitCode,
|
||||
expectedDiagnosticId: step.expectedDiagnosticId,
|
||||
} as const);
|
||||
} as const)
|
||||
: (() => {
|
||||
throw new TypeError(`negative command lost its validated identity: ${step.id}`);
|
||||
})();
|
||||
const classification = classifyGateStepResult(expectation, {
|
||||
status: result.status,
|
||||
signal: result.signal,
|
||||
@@ -134,9 +143,9 @@ if (passed) {
|
||||
? { error: { code: (result.error as NodeJS.ErrnoException).code } }
|
||||
: {}),
|
||||
});
|
||||
output.push(`classification: ${classification.kind}`);
|
||||
if (!appendOutput(`classification: ${classification.kind}`)) passed = false;
|
||||
if (!classification.expectationMet) {
|
||||
output.push(
|
||||
appendOutput(
|
||||
`expectation failed: expected ${step.expect}, exit=${result.status}, signal=${result.signal ?? "none"}`,
|
||||
...(step.expect === "fail"
|
||||
? [
|
||||
@@ -153,20 +162,37 @@ if (passed) {
|
||||
}
|
||||
}
|
||||
|
||||
await mkdir(path.dirname(gate.logPath), { recursive: true });
|
||||
await writeFile(gate.logPath, `${output.filter(Boolean).join("\n")}\n`);
|
||||
await writeCiGateLogAtomic({
|
||||
root: process.cwd(),
|
||||
relativePath: logArtifact.path,
|
||||
content: `${output.filter(Boolean).join("\n")}\n`,
|
||||
maxBytes: logSchema.maxBytes,
|
||||
});
|
||||
|
||||
if (passed) {
|
||||
for (const evidencePath of gate.evidence) {
|
||||
const validationIds = [...new Set([gate.logArtifactId, ...gate.evidenceArtifactIds])];
|
||||
for (const artifactId of validationIds) {
|
||||
const artifact = contractIndex.artifacts.get(artifactId);
|
||||
if (!artifact) throw new TypeError(`CI artifact disappeared: ${artifactId}`);
|
||||
const schema = contractIndex.artifactSchemas.get(artifact.schemaId);
|
||||
if (!schema) throw new TypeError(`CI artifact schema disappeared: ${artifact.schemaId}`);
|
||||
try {
|
||||
await access(evidencePath);
|
||||
} catch {
|
||||
output.push(`missing evidence: ${evidencePath}`);
|
||||
await validateCiArtifact({ root: process.cwd(), artifact, schema });
|
||||
if (!appendOutput(`validated evidence: ${artifact.path} (${schema.id})`)) passed = false;
|
||||
} catch (error) {
|
||||
appendOutput(
|
||||
`invalid evidence: ${artifact.path}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
passed = false;
|
||||
}
|
||||
}
|
||||
if (!passed) {
|
||||
await writeFile(gate.logPath, `${output.filter(Boolean).join("\n")}\n`);
|
||||
await writeCiGateLogAtomic({
|
||||
root: process.cwd(),
|
||||
relativePath: logArtifact.path,
|
||||
content: `${output.filter(Boolean).join("\n")}\n`,
|
||||
maxBytes: logSchema.maxBytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,125 +201,5 @@ if (!passed) {
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(
|
||||
`${gateId} ${gate.name}: PASS (${gate.retentionClass})\n`,
|
||||
`${gateId} ${gate.name}: PASS (${gate.retentionClassId})\n`,
|
||||
);
|
||||
|
||||
function parseGateDocument(value: unknown): GateDocument {
|
||||
if (!isRecord(value) || !isRecord(value.gates)) {
|
||||
throw new TypeError("CI gate registry must be an object");
|
||||
}
|
||||
const gates: Record<string, GateDefinition> = {};
|
||||
for (const [gateId, candidate] of Object.entries(value.gates)) {
|
||||
if (!isRecord(candidate)) throw new TypeError(`Invalid CI gate: ${gateId}`);
|
||||
const steps = parseGateSteps(candidate.steps, gateId);
|
||||
const evidence = parseStringArray(candidate.evidence, `${gateId}.evidence`);
|
||||
const requiresEnvironment =
|
||||
candidate.requiresEnvironment === undefined
|
||||
? undefined
|
||||
: parseStringArray(
|
||||
candidate.requiresEnvironment,
|
||||
`${gateId}.requiresEnvironment`,
|
||||
);
|
||||
if (
|
||||
typeof candidate.name !== "string" ||
|
||||
typeof candidate.logPath !== "string" ||
|
||||
typeof candidate.retentionClass !== "string"
|
||||
) {
|
||||
throw new TypeError(`CI gate metadata is invalid: ${gateId}`);
|
||||
}
|
||||
gates[gateId] = {
|
||||
name: candidate.name,
|
||||
steps,
|
||||
logPath: candidate.logPath,
|
||||
evidence,
|
||||
retentionClass: candidate.retentionClass,
|
||||
...(requiresEnvironment ? { requiresEnvironment } : {}),
|
||||
};
|
||||
}
|
||||
return { gates };
|
||||
}
|
||||
|
||||
function parseGateSteps(value: unknown, gateId: string): GateStep[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new TypeError(`CI gate steps are invalid: ${gateId}`);
|
||||
}
|
||||
return value.map((candidate, index) => {
|
||||
if (
|
||||
!isRecord(candidate) ||
|
||||
typeof candidate.script !== "string" ||
|
||||
(candidate.expect !== "pass" && candidate.expect !== "fail")
|
||||
) {
|
||||
throw new TypeError(`Invalid CI gate step: ${gateId}[${index}]`);
|
||||
}
|
||||
const args =
|
||||
candidate.args === undefined
|
||||
? undefined
|
||||
: parseStringArray(candidate.args, `${gateId}[${index}].args`);
|
||||
const timeoutMs = candidate.timeoutMs;
|
||||
if (
|
||||
timeoutMs !== undefined &&
|
||||
(typeof timeoutMs !== "number" ||
|
||||
!Number.isSafeInteger(timeoutMs) ||
|
||||
timeoutMs < 1_000 ||
|
||||
timeoutMs > 3_600_000)
|
||||
) {
|
||||
throw new TypeError(`Invalid CI gate step timeout: ${gateId}[${index}]`);
|
||||
}
|
||||
const base = {
|
||||
script: candidate.script,
|
||||
...(args ? { args } : {}),
|
||||
...(typeof timeoutMs === "number" ? { timeoutMs } : {}),
|
||||
};
|
||||
if (candidate.expect === "pass") {
|
||||
if (
|
||||
candidate.expectedExitCode !== undefined ||
|
||||
candidate.expectedDiagnosticId !== undefined
|
||||
) {
|
||||
throw new TypeError(
|
||||
`Passing CI gate step cannot declare failure identity: ${gateId}[${index}]`,
|
||||
);
|
||||
}
|
||||
return { ...base, expect: "pass" as const };
|
||||
}
|
||||
if (
|
||||
typeof candidate.expectedExitCode !== "number" ||
|
||||
!Number.isSafeInteger(candidate.expectedExitCode) ||
|
||||
candidate.expectedExitCode < 1 ||
|
||||
candidate.expectedExitCode > 255
|
||||
) {
|
||||
throw new TypeError(
|
||||
`Invalid expected failure exit code: ${gateId}[${index}]`,
|
||||
);
|
||||
}
|
||||
const expectedDiagnosticId = candidate.expectedDiagnosticId;
|
||||
if (
|
||||
typeof expectedDiagnosticId !== "string" ||
|
||||
expectedDiagnosticId.trim().length === 0 ||
|
||||
expectedDiagnosticId.length > 256 ||
|
||||
["\r", "\n", "\0"].some((character) =>
|
||||
expectedDiagnosticId.includes(character),
|
||||
)
|
||||
) {
|
||||
throw new TypeError(
|
||||
`Invalid expected failure diagnostic: ${gateId}[${index}]`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
expect: "fail" as const,
|
||||
expectedExitCode: candidate.expectedExitCode,
|
||||
expectedDiagnosticId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseStringArray(value: unknown, label: string): string[] {
|
||||
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
|
||||
throw new TypeError(`${label} must be a string array`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user