256 lines
8.5 KiB
TypeScript
256 lines
8.5 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 {
|
|
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 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 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`,
|
|
);
|