Files
clean-architecture-frontend…/scripts/run-ci-gate.ts
T
2026-08-01 19:39:59 +09:00

300 lines
8.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";
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>>;
}>;
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<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));
}