Files
tech-log-frontend/scripts/run-ci-gate.ts
T

252 lines
8.3 KiB
TypeScript

import { spawnSync } from "node:child_process";
import { lstat } 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";
import { withCiGatePreflight } from "./contracts/ci-gates.ts";
import { validateCiArtifact } from "./lib/ci-artifact-validator.ts";
import { writeCiGateLogAtomic } from "./lib/ci-gate-log.ts";
const requestedGateId = process.argv
.slice(2)
.find((argument) => /^FE-GATE-\d{3}$/.test(argument));
await withCiGatePreflight(
process.cwd(),
requestedGateId,
async ({ contractIndex, gateId, gate }) => {
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 freshlyProducedArtifactIds = new Set<string>();
const commandGeneratedEvidence = gate.evidenceArtifactIds
.map((artifactId) => contractIndex.artifacts.get(artifactId))
.filter((artifact) => artifact?.production === "command-generated");
async function observeArtifactGeneration(relativePath: string): Promise<string> {
try {
const metadata = await lstat(path.join(process.cwd(), relativePath), {
bigint: true,
});
return [
metadata.dev,
metadata.ino,
metadata.size,
metadata.mtimeNs,
metadata.ctimeNs,
].join(":");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing";
throw error;
}
}
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") {
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;
appendOutput(`derived SOURCE_DATE_EPOCH=${sourceDateEpoch} from HEAD`);
}
for (const failure of ciCheckoutIdentityFailures(gateEnvironment, {
commitSha,
sourceDateEpoch,
})) {
appendOutput(failure);
passed = false;
}
} else {
appendOutput(
"unable to resolve the checked-out commit identity and timestamp",
commitMetadata.stderr,
);
passed = false;
}
}
for (const failure of ciBuildEnvironmentFailures(gateEnvironment)) {
appendOutput(failure);
passed = false;
}
for (const variable of gate.requiresEnvironment ?? []) {
if (!gateEnvironment[variable]) {
appendOutput(`missing required environment: ${variable}`);
passed = false;
}
}
if (passed) {
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 producedArtifacts = commandGeneratedEvidence.filter((artifact) =>
artifact.producerCommandIds.includes(commandId)
);
const generationBefore = new Map(
await Promise.all(
producedArtifacts.map(async (artifact) => [
artifact.id,
await observeArtifactGeneration(artifact.path),
] as const),
),
);
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 ?? [])],
{
encoding: "utf8",
env: gateEnvironment,
timeout: step.timeoutMs ?? DEFAULT_STEP_TIMEOUT_MS,
maxBuffer: Math.min(
MAX_STEP_OUTPUT_BYTES,
logSchema.maxBytes - outputBytes - LOG_DIAGNOSTIC_RESERVE_BYTES,
),
},
);
const stdout = result.stdout ?? "";
const stderr = result.stderr ?? "";
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)
: (() => {
throw new TypeError(`negative command lost its validated identity: ${step.id}`);
})();
const classification = classifyGateStepResult(expectation, {
status: result.status,
signal: result.signal,
stdout,
stderr,
...(result.error
? { error: { code: (result.error as NodeJS.ErrnoException).code } }
: {}),
});
if (!appendOutput(`classification: ${classification.kind}`)) passed = false;
if (!classification.expectationMet) {
appendOutput(
`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;
}
for (const artifact of producedArtifacts) {
const generationAfter = await observeArtifactGeneration(artifact.path);
if (generationAfter !== generationBefore.get(artifact.id)) {
freshlyProducedArtifactIds.add(artifact.id);
}
}
}
}
if (passed) {
for (const artifact of commandGeneratedEvidence) {
if (!freshlyProducedArtifactIds.has(artifact.id)) {
appendOutput(`command-generated evidence was not freshly produced: ${artifact.path}`);
passed = false;
}
}
}
await writeCiGateLogAtomic({
root: process.cwd(),
relativePath: logArtifact.path,
content: `${output.filter(Boolean).join("\n")}\n`,
maxBytes: logSchema.maxBytes,
});
if (passed) {
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 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 writeCiGateLogAtomic({
root: process.cwd(),
relativePath: logArtifact.path,
content: `${output.filter(Boolean).join("\n")}\n`,
maxBytes: logSchema.maxBytes,
});
}
}
if (!passed) {
process.stderr.write(`${gateId} ${gate.name}: FAIL\n`);
process.exit(1);
}
process.stdout.write(
`${gateId} ${gate.name}: PASS (${gate.retentionClassId})\n`,
);
},
);