209 lines
5.9 KiB
TypeScript
209 lines
5.9 KiB
TypeScript
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";
|
|
|
|
type GateStep = Readonly<{
|
|
script: string;
|
|
args?: readonly string[];
|
|
expect: "pass" | "fail";
|
|
}>;
|
|
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>>;
|
|
}>;
|
|
|
|
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 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 },
|
|
);
|
|
output.push(
|
|
`$ corepack pnpm ${step.script} ${(step.args ?? []).join(" ")}`.trim(),
|
|
result.stdout,
|
|
result.stderr,
|
|
);
|
|
const exitedSuccessfully = result.status === 0;
|
|
const expectationMet =
|
|
step.expect === "pass" ? exitedSuccessfully : !exitedSuccessfully;
|
|
if (!expectationMet) {
|
|
output.push(
|
|
`expectation failed: expected ${step.expect}, exit=${result.status}`,
|
|
);
|
|
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<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`);
|
|
return {
|
|
script: candidate.script,
|
|
expect: candidate.expect,
|
|
...(args ? { args } : {}),
|
|
};
|
|
});
|
|
}
|
|
|
|
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));
|
|
}
|