import { spawnSync } from "node:child_process"; import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { ciCheckoutIdentityFailures, ciBuildEnvironmentFailures, isValidCommitSha, 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>; }>; 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; if (!gateId || !gate) { process.stderr.write("Usage: ci:gate -- FE-GATE-001..FE-GATE-026\n"); process.exit(2); } const output: string[] = []; let passed = true; const DEFAULT_STEP_TIMEOUT_MS = 30 * 60 * 1_000; const MAX_STEP_OUTPUT_BYTES = 16 * 1024 * 1_024; const gateEnvironment = { ...process.env }; if (gateEnvironment.CI === "true") { const commitMetadata = spawnSync( "git", ["show", "-s", "--format=%H%n%ct", "HEAD"], { encoding: "utf8" }, ); const [commitSha = "", sourceDateEpoch = ""] = commitMetadata.stdout.trim().split(/\r?\n/); if ( commitMetadata.status === 0 && isValidCommitSha(commitSha) && isValidSourceDateEpoch(sourceDateEpoch) ) { if (!gateEnvironment.SOURCE_DATE_EPOCH?.trim()) { gateEnvironment.SOURCE_DATE_EPOCH = sourceDateEpoch; output.push(`derived SOURCE_DATE_EPOCH=${sourceDateEpoch} from HEAD`); } for (const failure of ciCheckoutIdentityFailures(gateEnvironment, { commitSha, sourceDateEpoch, })) { output.push(failure); passed = false; } } else { output.push( "unable to resolve the checked-out commit identity and timestamp", commitMetadata.stderr, ); passed = false; } } for (const failure of ciBuildEnvironmentFailures(gateEnvironment)) { output.push(failure); passed = false; } for (const variable of gate.requiresEnvironment ?? []) { if (!gateEnvironment[variable]) { output.push(`missing required environment: ${variable}`); passed = false; } } if (passed) { for (const step of gate.steps) { const result = spawnSync( "corepack", ["pnpm", step.script, ...(step.args ?? [])], { encoding: "utf8", env: gateEnvironment, timeout: step.timeoutMs ?? DEFAULT_STEP_TIMEOUT_MS, maxBuffer: MAX_STEP_OUTPUT_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) : ({ kind: "fail", expectedExitCode: step.expectedExitCode, expectedDiagnosticId: step.expectedDiagnosticId, } as const); const classification = classifyGateStepResult(expectation, { status: result.status, signal: result.signal, stdout, stderr, ...(result.error ? { error: { code: (result.error as NodeJS.ErrnoException).code } } : {}), }); output.push(`classification: ${classification.kind}`); if (!classification.expectationMet) { output.push( `expectation failed: expected ${step.expect}, exit=${result.status}, signal=${result.signal ?? "none"}`, ...(step.expect === "fail" ? [ `expected negative fixture identity: exit=${step.expectedExitCode}, diagnostic=${JSON.stringify(step.expectedDiagnosticId)}`, ] : []), ...(classification.kind === "INFRASTRUCTURE_FAILURE" ? [`infrastructure failure: ${classification.detail}`] : []), ); passed = false; break; } } } await mkdir(path.dirname(gate.logPath), { recursive: true }); await writeFile(gate.logPath, `${output.filter(Boolean).join("\n")}\n`); if (passed) { for (const evidencePath of gate.evidence) { try { await access(evidencePath); } catch { output.push(`missing evidence: ${evidencePath}`); passed = false; } } if (!passed) { await writeFile(gate.logPath, `${output.filter(Boolean).join("\n")}\n`); } } if (!passed) { process.stderr.write(`${gateId} ${gate.name}: FAIL\n`); process.exit(1); } process.stdout.write( `${gateId} ${gate.name}: PASS (${gate.retentionClass})\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 = {}; 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 { return Boolean(value && typeof value === "object" && !Array.isArray(value)); }