fix: harden CI evidence and removal contracts
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user