fix: harden CI evidence and removal contracts

This commit is contained in:
DongHyeonka
2026-08-02 14:48:04 +09:00
parent 1bb2cc4a20
commit f49d147b01
20 changed files with 1175 additions and 767 deletions
+92 -5
View File
@@ -1,4 +1,5 @@
import { readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import path from "node:path";
import { z } from "zod";
@@ -246,9 +247,18 @@ const artifactSchemaSchema = z.discriminatedUnion("kind", [
.strict(),
]);
const artifactSchema = z
.object({ id, path: repositoryPath, schemaId: id })
.strict();
const artifactBaseShape = { id, path: repositoryPath, schemaId: id } as const;
const artifactSchema = z.discriminatedUnion("production", [
z.object({ ...artifactBaseShape, production: z.literal("source-controlled") }).strict(),
z
.object({
...artifactBaseShape,
production: z.literal("command-generated"),
producerCommandIds: z.array(id).min(1).max(32),
})
.strict(),
z.object({ ...artifactBaseShape, production: z.literal("runner-generated") }).strict(),
]);
const gateSchema = z
.object({
@@ -405,6 +415,35 @@ export type CiGateContractIndex = Readonly<{
jobs: ReadonlyMap<string, CiWorkflowJob>;
retentionClasses: ReadonlyMap<string, CiGateContract["retention"]["classes"][number]>;
}>;
export type LoadCiGateContractOptions = Readonly<{
mode?: "canonical" | "removal-fixture";
}>;
const CANONICAL_GATE_SHAPE_SHA256 =
"a4a963d0b9deffb7a0a3d755bbbcb979d72610eb74751c3a2e5eca55251e12d4";
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
const normalized = gates.map(
({
id,
name,
commandIds,
logArtifactId,
evidenceArtifactIds,
retentionClassId,
requiresEnvironment,
}) => ({
id,
name,
commandIds,
logArtifactId,
evidenceArtifactIds,
retentionClassId,
requiresEnvironment: requiresEnvironment ?? [],
}),
);
return createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
}
export function parseCiGateContract(value: unknown): CiGateContract {
const result = ciGateContractSchema.safeParse(value);
@@ -417,12 +456,22 @@ export function parseCiGateContract(value: unknown): CiGateContract {
return result.data;
}
export async function loadCiGateContract(root = process.cwd()): Promise<CiGateContract> {
export async function loadCiGateContract(
root = process.cwd(),
options: LoadCiGateContractOptions = {},
): Promise<CiGateContract> {
const [rawContract, rawPackage] = await Promise.all([
readFile(path.join(root, "config/ci/gates.json"), "utf8"),
readFile(path.join(root, "package.json"), "utf8"),
]);
const contract = parseCiGateContract(JSON.parse(rawContract));
const mode = options.mode ?? "canonical";
if (
mode === "canonical" &&
canonicalGateShapeSha256(contract.gates) !== CANONICAL_GATE_SHAPE_SHA256
) {
throw new TypeError("CI gate contract canonical gate semantic shape drift");
}
const packageDocument = z
.object({ scripts: z.record(z.string(), z.string()).default({}) })
.passthrough()
@@ -434,10 +483,23 @@ export async function loadCiGateContract(root = process.cwd()): Promise<CiGateCo
if (missing.length > 0) {
throw new TypeError(`CI gate contract missing package scripts: ${missing.join(", ")}`);
}
const expectedCheckCi = "corepack pnpm check:artifact-schemas && node scripts/check-ci-contract.ts && corepack pnpm check:ci-workflow";
const expectedCheckCi = mode === "canonical"
? "corepack pnpm check:artifact-schemas && node scripts/check-ci-contract.ts && corepack pnpm check:ci-workflow"
: "corepack pnpm check:artifact-schemas && node scripts/check-ci-contract.ts --reduced-removal-fixture && corepack pnpm check:ci-workflow";
if (packageDocument.scripts["check:ci"] !== expectedCheckCi) {
throw new TypeError("check:ci must use the exact canonical non-recursive orchestration");
}
const canonicalCheckCiDependencies = {
"check:artifact-schemas": "node scripts/generate-artifact-schemas.ts --check",
"check:ci-workflow": mode === "canonical"
? "node scripts/generate-ci-workflow.ts --check"
: "node scripts/generate-ci-workflow.ts --check --reduced-removal-fixture",
} as const;
for (const [script, expected] of Object.entries(canonicalCheckCiDependencies)) {
if (packageDocument.scripts[script] !== expected) {
throw new TypeError(`canonical check:ci dependency drift: ${script}`);
}
}
const graphFailures = validatePackageScriptGraph(packageDocument.scripts, "check:ci");
if (graphFailures.length > 0) {
throw new TypeError(`CI package script graph invalid:\n${graphFailures.join("\n")}`);
@@ -510,6 +572,16 @@ function validateContractSemantics(
if (!schemaIds.has(artifact.schemaId)) {
issue(`unknown artifact schema ${artifact.schemaId} for ${artifact.id}`);
}
if (artifact.production === "command-generated") {
if (new Set(artifact.producerCommandIds).size !== artifact.producerCommandIds.length) {
issue(`duplicate producer command reference for artifact: ${artifact.id}`);
}
for (const producerCommandId of artifact.producerCommandIds) {
if (!commandIds.has(producerCommandId)) {
issue(`unknown producer command ${producerCommandId} for ${artifact.id}`);
}
}
}
}
for (const gate of contract.gates) {
if (new Set(gate.commandIds).size !== gate.commandIds.length) {
@@ -524,6 +596,21 @@ function validateContractSemantics(
for (const artifactId of [gate.logArtifactId, ...gate.evidenceArtifactIds]) {
if (!artifactIds.has(artifactId)) issue(`unknown artifact ${artifactId} for ${gate.id}`);
}
const logArtifact = contract.artifacts.find(({ id }) => id === gate.logArtifactId);
if (logArtifact && logArtifact.production !== "runner-generated") {
issue(`gate log must be runner-generated: ${gate.id}`);
}
for (const artifactId of gate.evidenceArtifactIds) {
const artifact = contract.artifacts.find(({ id }) => id === artifactId);
if (
artifact?.production === "command-generated" &&
!artifact.producerCommandIds.some((producerCommandId) =>
gate.commandIds.includes(producerCommandId)
)
) {
issue(`gate lacks a bound producer command for ${artifact.id}: ${gate.id}`);
}
}
if (!retentionIds.has(gate.retentionClassId)) {
issue(`unknown retention class ${gate.retentionClassId} for ${gate.id}`);
}