Files
clean-architecture-frontend…/scripts/contracts/ci-gates.ts
T

1081 lines
45 KiB
TypeScript

import { readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import path from "node:path";
import { z } from "zod";
import { PROMOTION_FORMULA } from "../../src/application/policies/promotion-readiness.ts";
import {
RELEASE_CANDIDATE_EVIDENCE_PATHS,
RELEASE_CANDIDATE_MANIFEST_PATH,
} from "../lib/release-candidate.ts";
import { validatePackageScriptGraph } from "../lib/package-script-graph.ts";
import { PROMOTED_STAGING_PATHS } from "./promotion-artifacts.ts";
const ciActionRegistrationSchema = z
.object({
repository: z
.string()
.regex(
/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u,
"CI action repository must be an absolute upstream GitHub URL",
),
revision: z
.string()
.regex(/^[0-9a-f]{40}$/u, "CI action revision must be a full 40-hex commit SHA"),
version: z.string().min(1).max(64),
})
.strict()
.readonly();
const ciActionRegistrySchema = z
.object({
checkout: ciActionRegistrationSchema,
setupNode: ciActionRegistrationSchema,
uploadArtifact: ciActionRegistrationSchema,
downloadArtifact: ciActionRegistrationSchema,
})
.strict()
.readonly();
export type CiActionRegistry = z.infer<typeof ciActionRegistrySchema>;
export type CiActionId = keyof CiActionRegistry;
export function parseCiActionRegistry(value: unknown): CiActionRegistry {
const result = ciActionRegistrySchema.safeParse(value);
if (!result.success) {
const diagnostic = result.error.issues
.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`)
.join("\n");
throw new TypeError(`CI action registry invalid:\n${diagnostic}`);
}
return result.data;
}
export const CI_ACTION_REGISTRY = parseCiActionRegistry({
checkout: {
repository: "https://github.com/actions/checkout",
revision: "34e114876b0b11c390a56381ad16ebd13914f8d5",
version: "v4.3.1",
},
setupNode: {
repository: "https://github.com/actions/setup-node",
revision: "49933ea5288caeca8642d1e84afbd3f7d6820020",
version: "v4.4.0",
},
uploadArtifact: {
repository: "https://github.com/ChristopherHX/gitea-upload-artifact",
revision: "81f940d004763f986ba3582c007fd842dd5cb0d7",
version: "v4 branch",
},
downloadArtifact: {
repository: "https://github.com/ChristopherHX/gitea-download-artifact",
revision: "75635f32b4c1c41c4b3d64e8f85210112ed4c9c7",
version: "v4 branch",
},
});
const actionIdByStepKind = Object.freeze({
checkout: "checkout",
"setup-node": "setupNode",
upload: "uploadArtifact",
download: "downloadArtifact",
} as const satisfies Readonly<Record<string, CiActionId>>);
export function resolveCiActionUses(actionId: CiActionId): string {
const action = CI_ACTION_REGISTRY[actionId];
if (!action) throw new TypeError(`unknown CI action: ${String(actionId)}`);
return `${action.repository}@${action.revision}`;
}
export function resolveCiStepActionUses(stepKind: string): string | null {
const actionId = actionIdByStepKind[stepKind as keyof typeof actionIdByStepKind];
return actionId ? resolveCiActionUses(actionId) : null;
}
function hasAsciiControl(value: string): boolean {
return [...value].some((character) => {
const codePoint = character.codePointAt(0)!;
return codePoint <= 0x1f || codePoint === 0x7f;
});
}
function hasForbiddenLineOrControl(value: string): boolean {
return [...value].some((character) => {
const codePoint = character.codePointAt(0)!;
return (
codePoint <= 0x1f ||
codePoint === 0x7f ||
codePoint === 0x85 ||
codePoint === 0x2028 ||
codePoint === 0x2029
);
});
}
const nonEmpty = z
.string()
.min(1)
.max(4_096)
.refine((value) => !hasForbiddenLineOrControl(value), "control and Unicode line-break characters are forbidden")
.refine((value) => value === value.trim(), "leading/trailing whitespace is forbidden");
const id = nonEmpty.max(128).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u);
const repositoryPath = nonEmpty.superRefine((value, context) => {
if (
value.includes("\\") ||
value.includes("\0") ||
path.posix.isAbsolute(value) ||
path.posix.normalize(value) !== value ||
value === "." ||
value === ".." ||
value.startsWith("../") ||
value.includes("/../")
) {
context.addIssue({ code: "custom", message: `unsafe repository path: ${value}` });
}
});
const workflowValue = nonEmpty.refine(
(value) => !value.includes("\0") && !value.includes("\r"),
"workflow values must not contain NUL or carriage returns",
);
const workflowPath = workflowValue.superRefine((value, context) => {
if (
value.includes("\\") ||
path.posix.isAbsolute(value) ||
path.posix.normalize(value) !== value ||
value === "." ||
value === ".." ||
value.startsWith("../") ||
value.includes("/../")
) {
context.addIssue({ code: "custom", message: `unsafe workflow path: ${value}` });
}
});
const commandSchema = z
.object({
id,
script: nonEmpty.regex(/^[A-Za-z0-9:_-]+$/u),
args: z.array(z.string().max(512).refine((value) => !hasAsciiControl(value), "command arguments contain controls")).max(32).optional(),
timeoutMs: z.number().int().min(1_000).max(3_600_000).optional(),
expect: z.enum(["pass", "fail"]),
expectedExitCode: z.number().int().min(1).max(255).optional(),
expectedDiagnosticId: nonEmpty.max(256).optional(),
})
.strict()
.superRefine((command, context) => {
const hasNegativeIdentity =
command.expectedExitCode !== undefined ||
command.expectedDiagnosticId !== undefined;
if (command.expect === "pass" && hasNegativeIdentity) {
context.addIssue({
code: "custom",
message: `pass command carries negative identity: ${command.id}`,
});
}
if (
command.expect === "fail" &&
(command.expectedExitCode === undefined || !command.expectedDiagnosticId)
) {
context.addIssue({
code: "custom",
message: `negative command lacks exact identity: ${command.id}`,
});
}
if (
command.expectedDiagnosticId &&
/[\n\r\0]/u.test(command.expectedDiagnosticId)
) {
context.addIssue({
code: "custom",
message: `negative command diagnostic is unsafe: ${command.id}`,
});
}
});
const artifactSchemaSchema = z.discriminatedUnion("kind", [
z.object({ id, kind: z.literal("text"), maxBytes: z.number().int().min(1).max(268_435_456) }).strict(),
z
.object({
id,
kind: z.literal("json"),
maxBytes: z.number().int().min(1).max(268_435_456),
executableSchemaId: z.enum([
"generic-json-object",
"coverage-summary-v8",
"risk-coverage-v3",
"build-manifest",
"module-inventory",
"dependency-inventory",
"registry-snapshot",
"registry-governance-run",
"bundle-performance",
"sbom",
"provenance",
"dependency-diff",
"license-report",
"vulnerability-report",
"field-web-vitals",
"lab-performance",
"release-verification",
"runbook-record",
"supply-chain-verification",
"release-candidate",
"supply-chain-coherence",
"http-scenario-receipt",
"test-evidence-report",
"provider-vulnerability",
"provider-provenance",
"provider-verification",
"ci-contract-report",
]),
})
.strict(),
z
.object({
id,
kind: z.literal("json-schema"),
maxBytes: z.number().int().min(1).max(268_435_456),
})
.strict(),
z.object({ id, kind: z.literal("junit"), maxBytes: z.number().int().min(1).max(268_435_456) }).strict(),
z.object({ id, kind: z.literal("html"), maxBytes: z.number().int().min(1).max(268_435_456) }).strict(),
z.object({ id, kind: z.literal("markdown"), maxBytes: z.number().int().min(1).max(268_435_456) }).strict(),
z.object({ id, kind: z.literal("sarif"), maxBytes: z.number().int().min(1).max(268_435_456) }).strict(),
z
.object({ id, kind: z.literal("candidate-archive"), maxBytes: z.number().int().min(1).max(268_435_456) })
.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({
id: z.string().regex(/^FE-GATE-\d{3}$/u),
name: nonEmpty.regex(/^[a-z0-9][a-z0-9-]*$/u),
commandIds: z.array(id).min(1).max(128),
logArtifactId: id,
evidenceArtifactIds: z.array(id).min(1).max(128),
retentionClassId: id,
requiresEnvironment: z.array(z.string().max(128).regex(/^[A-Z][A-Z0-9_]*$/u)).max(32).optional(),
})
.strict();
const stageSchema = z
.object({
id,
readiness: z.enum([
"MERGE_READY",
"RELEASE_READY",
"PROD_PROMOTION_READY",
"FIELD_SLO_READY",
"DOCUMENTATION_READY",
]),
needs: z.array(id).max(16),
gateIds: z.array(id).min(1).max(64),
})
.strict();
const environmentBindingSchema = z
.object({ name: z.string().regex(/^[A-Z][A-Z0-9_]*$/u), value: workflowValue })
.strict();
const checkoutStep = z.object({ kind: z.literal("checkout") }).strict();
const setupNodeStep = z.object({ kind: z.literal("setup-node") }).strict();
const frozenInstallStep = z.object({ kind: z.literal("frozen-install") }).strict();
const browserInstallStep = z.object({ kind: z.literal("browser-install") }).strict();
const runGateStep = z.object({ kind: z.literal("run-gate") }).strict();
const archiveCandidateStep = z
.object({
kind: z.literal("archive-candidate"),
stepId: id,
archivePath: workflowPath,
members: z.array(repositoryPath).min(1).max(128),
archiveOutputName: id,
distOutputName: id,
})
.strict();
const uploadStep = z
.object({
kind: z.literal("upload"),
transferId: id,
name: workflowValue,
paths: z.array(workflowPath).min(1).max(128),
always: z.boolean().optional(),
})
.strict();
const downloadStep = z
.object({ kind: z.literal("download"), transferId: id, path: workflowPath })
.strict();
const validateCandidateArchiveStep = z
.object({
kind: z.literal("validate-candidate-archive"),
archivePath: workflowPath,
})
.strict();
const extractStep = z
.object({
kind: z.literal("extract"),
archivePath: workflowPath,
targetRoot: workflowPath,
})
.strict();
const providerStep = z
.object({ kind: z.literal("run-provider"), provider: z.enum(["vulnerability", "provenance"]) })
.strict();
const validateProviderStep = z
.object({
kind: z.literal("validate-provider-evidence"),
provider: z.enum(["vulnerability", "provenance"]),
})
.strict();
const promotionStep = z.object({ kind: z.literal("verify-promotion") }).strict();
const jobStepSchema = z.discriminatedUnion("kind", [
checkoutStep,
setupNodeStep,
frozenInstallStep,
browserInstallStep,
runGateStep,
archiveCandidateStep,
uploadStep,
downloadStep,
validateCandidateArchiveStep,
extractStep,
providerStep,
validateProviderStep,
promotionStep,
]);
const jobSchema = z
.object({
id,
displayName: workflowValue,
kind: z.enum(["gate-matrix", "gate-single", "immutable", "provider", "promotion"]),
needs: z.array(id).max(32),
condition: z.enum(["always", "merge", "release", "production", "field", "documentation"]),
timeoutMinutes: z.number().int().positive(),
gateIds: z.array(id).max(64),
browserGateIds: z.array(id).max(64),
environment: z.array(environmentBindingSchema).max(32),
steps: z.array(jobStepSchema).min(1).max(64),
})
.strict();
const retentionSchema = z
.object({
durationStatus: nonEmpty,
classes: z.array(z.object({ id, policy: nonEmpty }).strict()).min(1).max(32),
})
.strict();
const ciGateContractBaseSchema = z
.object({
schemaVersion: z.literal(2),
providerAdapter: repositoryPath,
commands: z.array(commandSchema).min(1).max(256),
artifactSchemas: z.array(artifactSchemaSchema).min(1).max(128),
artifacts: z.array(artifactSchema).min(1).max(512),
gates: z.array(gateSchema).min(1).max(64),
stages: z.array(stageSchema).min(1).max(16),
jobs: z.array(jobSchema).min(1).max(32),
retention: retentionSchema,
})
.strict();
const ciGateContractSchema = ciGateContractBaseSchema.superRefine(
(contract, context) => validateContractSemantics(contract, context),
);
export type CiGateContract = z.infer<typeof ciGateContractSchema>;
export type CiGateCommand = CiGateContract["commands"][number];
export type CiGateArtifactSchema = CiGateContract["artifactSchemas"][number];
export type CiGateArtifact = CiGateContract["artifacts"][number];
export type CiGate = CiGateContract["gates"][number];
export type CiWorkflowJob = CiGateContract["jobs"][number];
export type CiWorkflowStep = CiWorkflowJob["steps"][number];
export type CiGateContractIndex = Readonly<{
commands: ReadonlyMap<string, CiGateCommand>;
artifactSchemas: ReadonlyMap<string, CiGateArtifactSchema>;
artifacts: ReadonlyMap<string, CiGateArtifact>;
gates: ReadonlyMap<string, CiGate>;
stages: ReadonlyMap<string, CiGateContract["stages"][number]>;
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);
if (!result.success) {
const diagnostic = result.error.issues
.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`)
.join("\n");
throw new TypeError(`CI gate contract invalid:\n${diagnostic}`);
}
return result.data;
}
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()
.parse(JSON.parse(rawPackage));
const missing = contract.commands
.map((command) => command.script)
.filter((script, index, scripts) => scripts.indexOf(script) === index)
.filter((script) => !packageDocument.scripts[script]);
if (missing.length > 0) {
throw new TypeError(`CI gate contract missing package scripts: ${missing.join(", ")}`);
}
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")}`);
}
return contract;
}
export function indexCiGateContract(contract: CiGateContract): CiGateContractIndex {
return Object.freeze({
commands: new Map(contract.commands.map((entry) => [entry.id, entry])),
artifactSchemas: new Map(contract.artifactSchemas.map((entry) => [entry.id, entry])),
artifacts: new Map(contract.artifacts.map((entry) => [entry.id, entry])),
gates: new Map(contract.gates.map((entry) => [entry.id, entry])),
stages: new Map(contract.stages.map((entry) => [entry.id, entry])),
jobs: new Map(contract.jobs.map((entry) => [entry.id, entry])),
retentionClasses: new Map(contract.retention.classes.map((entry) => [entry.id, entry])),
});
}
function validateContractSemantics(
contract: z.infer<typeof ciGateContractBaseSchema>,
context: z.RefinementCtx,
): void {
const issue = (message: string, path: PropertyKey[] = []) =>
context.addIssue({ code: "custom", message, path });
if (contract.providerAdapter !== ".gitea/workflows/quality-gates.yml") {
issue("providerAdapter must target the canonical generated workflow");
}
const registries = [
["command", contract.commands],
["artifact schema", contract.artifactSchemas],
["artifact", contract.artifacts],
["gate", contract.gates],
["stage", contract.stages],
["job", contract.jobs],
["retention class", contract.retention.classes],
] as const;
for (const [label, entries] of registries) {
const seen = new Set<string>();
for (const entry of entries) {
if (seen.has(entry.id)) issue(`duplicate ${label} id: ${entry.id}`);
seen.add(entry.id);
}
}
const commandTuples = new Map<string, string>();
for (const command of contract.commands) {
const { id: _id, ...tuple } = command;
const key = JSON.stringify(tuple);
const previous = commandTuples.get(key);
if (previous) issue(`duplicate command tuple: ${previous}, ${command.id}`);
commandTuples.set(key, command.id);
}
const artifactPaths = new Map<string, string>();
for (const artifact of contract.artifacts) {
const previous = artifactPaths.get(artifact.path);
if (previous) issue(`duplicate artifact path: ${previous}, ${artifact.id}`);
artifactPaths.set(artifact.path, artifact.id);
}
const commandIds = new Set(contract.commands.map(({ id }) => id));
const schemaIds = new Set(contract.artifactSchemas.map(({ id }) => id));
const artifactIds = new Set(contract.artifacts.map(({ id }) => id));
const gateIds = new Set(contract.gates.map(({ id }) => id));
const stageIds = new Set(contract.stages.map(({ id }) => id));
const jobIds = new Set(contract.jobs.map(({ id }) => id));
const retentionIds = new Set(contract.retention.classes.map(({ id }) => id));
for (const artifact of contract.artifacts) {
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) {
issue(`duplicate command reference within gate: ${gate.id}`);
}
if (new Set(gate.evidenceArtifactIds).size !== gate.evidenceArtifactIds.length) {
issue(`duplicate artifact reference within gate: ${gate.id}`);
}
for (const commandId of gate.commandIds) {
if (!commandIds.has(commandId)) issue(`unknown command ${commandId} for ${gate.id}`);
}
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}`);
}
}
const referencedCommands = new Set(contract.gates.flatMap(({ commandIds }) => commandIds));
for (const command of contract.commands) {
if (!referencedCommands.has(command.id)) issue(`orphan command: ${command.id}`);
}
const referencedArtifacts = new Set(
contract.gates.flatMap((gate) => [gate.logArtifactId, ...gate.evidenceArtifactIds]),
);
for (const artifact of contract.artifacts) {
if (!referencedArtifacts.has(artifact.id)) issue(`orphan artifact: ${artifact.id}`);
}
const referencedSchemas = new Set(contract.artifacts.map(({ schemaId }) => schemaId));
for (const schema of contract.artifactSchemas) {
if (!referencedSchemas.has(schema.id)) issue(`orphan artifact schema: ${schema.id}`);
}
for (const stage of contract.stages) {
for (const dependency of stage.needs) {
if (dependency === stage.id) issue(`stage self dependency: ${stage.id}`);
else if (!stageIds.has(dependency)) issue(`unknown stage dependency ${dependency} for ${stage.id}`);
}
for (const gateId of stage.gateIds) {
if (!gateIds.has(gateId)) issue(`unknown gate ${gateId} for stage ${stage.id}`);
}
}
for (const cycle of findCycles(contract.stages.map((stage) => [stage.id, stage.needs] as const))) {
issue(`stage dependency cycle: ${cycle.join(" -> ")}`);
}
const expectedGateIds = Array.from(
{ length: 26 },
(_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`,
);
if (JSON.stringify(contract.gates.map(({ id }) => id)) !== JSON.stringify(expectedGateIds)) {
issue("gate registry must contain FE-GATE-001..026 in canonical order");
}
const expectedStages: ReadonlyArray<readonly [string, string, readonly string[], readonly string[]]> = [
["merge", "MERGE_READY", [], PROMOTION_FORMULA.MERGE_READY],
["release", "RELEASE_READY", ["merge"], PROMOTION_FORMULA.RELEASE_READY],
["production", "PROD_PROMOTION_READY", ["release"], PROMOTION_FORMULA.PROD_PROMOTION_READY],
["field", "FIELD_SLO_READY", ["production"], PROMOTION_FORMULA.FIELD_SLO_READY],
["documentation", "DOCUMENTATION_READY", [], PROMOTION_FORMULA.DOCUMENTATION_READY],
];
const stageShape = contract.stages.map(({ id, readiness, needs, gateIds }) => [id, readiness, needs, gateIds]);
if (JSON.stringify(stageShape) !== JSON.stringify(expectedStages)) {
issue("stage formula/order/ownership drift");
}
const stageOwners = new Map<string, string[]>();
for (const stage of contract.stages) {
for (const gateId of stage.gateIds) {
stageOwners.set(gateId, [...(stageOwners.get(gateId) ?? []), stage.id]);
}
}
for (const gateId of expectedGateIds) {
if ((stageOwners.get(gateId) ?? []).length !== 1) issue(`gate must belong to exactly one stage: ${gateId}`);
}
const owners = new Map<string, string[]>();
for (const job of contract.jobs) {
for (const [label, values] of [
["needs", job.needs],
["gateIds", job.gateIds],
["browserGateIds", job.browserGateIds],
] as const) {
if (new Set(values).size !== values.length) {
issue(`duplicate ${label} reference in job: ${job.id}`);
}
}
if (job.timeoutMinutes !== 45) issue(`job timeout must be 45 minutes: ${job.id}`);
for (const dependency of job.needs) {
if (dependency === job.id) issue(`job self dependency: ${job.id}`);
else if (!jobIds.has(dependency)) issue(`unknown job dependency ${dependency} for ${job.id}`);
}
for (const gateId of job.gateIds) {
if (!gateIds.has(gateId)) issue(`unknown gate ${gateId} for job ${job.id}`);
owners.set(gateId, [...(owners.get(gateId) ?? []), job.id]);
}
for (const browserGateId of job.browserGateIds) {
if (!job.gateIds.includes(browserGateId)) {
issue(`browser gate ${browserGateId} is not owned by ${job.id}`);
}
}
validateJobStepKinds(job, issue);
for (const step of job.steps) {
const uses = resolveCiStepActionUses(step.kind);
if (uses && !/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+@[0-9a-f]{40}$/u.test(uses)) {
issue(`CI action ref is not an absolute upstream URL pinned to a full commit SHA: ${step.kind}`);
}
}
const envNames = new Set(job.environment.map(({ name }) => name));
for (const gateId of job.gateIds) {
const gate = contract.gates.find(({ id }) => id === gateId);
for (const required of gate?.requiresEnvironment ?? []) {
if (!envNames.has(required)) issue(`job ${job.id} lacks environment ${required} for ${gateId}`);
}
}
}
for (const gate of contract.gates) {
const gateOwners = owners.get(gate.id) ?? [];
if (gateOwners.length === 0) issue(`unowned gate: ${gate.id}`);
if (gateOwners.length > 1) issue(`multiply owned gate: ${gate.id} by ${gateOwners.join(", ")}`);
}
const expectedJobOwnership: Readonly<Record<string, readonly string[]>> = {
merge_gate: ["FE-GATE-001", "FE-GATE-002", "FE-GATE-003", "FE-GATE-004", "FE-GATE-005", "FE-GATE-006", "FE-GATE-007", "FE-GATE-008", "FE-GATE-009", "FE-GATE-010", "FE-GATE-011", "FE-GATE-013", "FE-GATE-020"],
release_gate: ["FE-GATE-012", "FE-GATE-014", "FE-GATE-019", "FE-GATE-026"],
immutable_build: ["FE-GATE-015"],
vulnerability_provider: [],
provenance_provider: [],
promotion: [],
production_gate: ["FE-GATE-016", "FE-GATE-021", "FE-GATE-022", "FE-GATE-023", "FE-GATE-024", "FE-GATE-025"],
field_gate: ["FE-GATE-018"],
documentation_gate: ["FE-GATE-017"],
};
if (JSON.stringify(contract.jobs.map(({ id }) => id)) !== JSON.stringify(Object.keys(expectedJobOwnership))) {
issue("job registry must contain the exact nine canonical jobs in semantic order");
}
for (const [jobId, gateIds] of Object.entries(expectedJobOwnership)) {
const job = contract.jobs.find(({ id }) => id === jobId);
if (!job || JSON.stringify(job.gateIds) !== JSON.stringify(gateIds)) {
issue(`exact gate execution ownership drift: ${jobId}`);
}
}
const expectedJobGraph: Readonly<Record<string, readonly [CiWorkflowJob["kind"], readonly string[], CiWorkflowJob["condition"]]>> = {
merge_gate: ["gate-matrix", [], "merge"],
release_gate: ["gate-matrix", ["merge_gate"], "release"],
immutable_build: ["immutable", ["release_gate"], "release"],
vulnerability_provider: ["provider", ["immutable_build"], "always"],
provenance_provider: ["provider", ["immutable_build"], "always"],
promotion: ["promotion", ["immutable_build", "vulnerability_provider", "provenance_provider"], "always"],
production_gate: ["gate-matrix", ["promotion"], "production"],
field_gate: ["gate-single", ["production_gate"], "field"],
documentation_gate: ["gate-single", [], "documentation"],
};
for (const [jobId, [kind, needs, condition]] of Object.entries(expectedJobGraph)) {
const job = contract.jobs.find(({ id }) => id === jobId);
if (!job || job.kind !== kind || JSON.stringify(job.needs) !== JSON.stringify(needs) || job.condition !== condition) {
issue(`job graph drift: ${jobId}`);
}
}
const expectedStepKinds: Readonly<Record<string, readonly CiWorkflowStep["kind"][]>> = {
merge_gate: ["checkout", "setup-node", "frozen-install", "browser-install", "run-gate", "upload"],
release_gate: ["checkout", "setup-node", "frozen-install", "browser-install", "run-gate", "upload"],
immutable_build: ["checkout", "setup-node", "frozen-install", "run-gate", "archive-candidate", "upload"],
vulnerability_provider: ["checkout", "setup-node", "frozen-install", "download", "extract", "run-provider", "validate-provider-evidence", "upload"],
provenance_provider: ["checkout", "setup-node", "frozen-install", "download", "extract", "run-provider", "validate-provider-evidence", "upload"],
promotion: ["checkout", "setup-node", "frozen-install", "download", "download", "download", "extract", "verify-promotion", "upload"],
production_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"],
field_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"],
documentation_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"],
};
for (const [jobId, expected] of Object.entries(expectedStepKinds)) {
const actual = contract.jobs.find(({ id }) => id === jobId)?.steps.map(({ kind }) => kind);
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
issue(`canonical job step sequence drift: ${jobId}`);
}
}
const expectedEnvironmentBindings: Readonly<Record<string, readonly Readonly<{ name: string; value: string }> []>> = {
merge_gate: [],
release_gate: [{ name: "HOSTING_BASE_URL", value: "${{ vars.HOSTING_BASE_URL }}" }],
immutable_build: [],
vulnerability_provider: [
{ name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" },
{ name: "CANDIDATE_ARCHIVE_PATH", value: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" },
{ name: "CANDIDATE_DIST_SHA256", value: "${{ needs.immutable_build.outputs.dist_sha256 }}" },
{ name: "CANDIDATE_LOCKFILE_PATH", value: ".release/verified-vulnerability/pnpm-lock.yaml" },
{ name: "VULNERABILITY_PROVIDER_COMMAND", value: "${{ vars.VULNERABILITY_PROVIDER_COMMAND }}" },
{ name: "VULNERABILITY_REPORT_PATH", value: "provider-evidence/untrusted/vulnerability-report.json" },
{ name: "VALIDATED_PROVIDER_REPORT_PATH", value: "provider-evidence/vulnerability-report.json" },
],
provenance_provider: [
{ name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" },
{ name: "CANDIDATE_ARCHIVE_PATH", value: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" },
{ name: "CANDIDATE_DIST_SHA256", value: "${{ needs.immutable_build.outputs.dist_sha256 }}" },
{ name: "CANDIDATE_LOCKFILE_PATH", value: ".release/verified-provenance/pnpm-lock.yaml" },
{ name: "PROVENANCE_PROVIDER_COMMAND", value: "${{ vars.PROVENANCE_PROVIDER_COMMAND }}" },
{ name: "PROVENANCE_ATTESTATION_PATH", value: "provider-evidence/untrusted/provenance-attestation.json" },
{ name: "VALIDATED_PROVIDER_REPORT_PATH", value: "provider-evidence/provenance-attestation.json" },
],
promotion: [
{ name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" },
{ name: "CANDIDATE_ARCHIVE_PATH", value: ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" },
{ name: "CANDIDATE_ROOT", value: "${{ gitea.workspace }}/.release/verified-candidate" },
{ name: "VULNERABILITY_REPORT_PATH", value: "${{ gitea.workspace }}/.release/vulnerability/vulnerability-report.json" },
{ name: "PROVENANCE_ATTESTATION_PATH", value: "${{ gitea.workspace }}/.release/provenance/provenance-attestation.json" },
{ name: "VULNERABILITY_PUBLIC_KEY_PATH", value: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}" },
{ name: "VULNERABILITY_KEY_ID", value: "${{ vars.VULNERABILITY_KEY_ID }}" },
{ name: "PROVENANCE_PUBLIC_KEY_PATH", value: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}" },
{ name: "PROVENANCE_KEY_ID", value: "${{ vars.PROVENANCE_KEY_ID }}" },
],
production_gate: [],
field_gate: [
{ name: "FIELD_WEB_VITALS_INPUT", value: "${{ vars.FIELD_WEB_VITALS_INPUT }}" },
{ name: "MIN_ELIGIBLE_SAMPLES", value: "${{ vars.MIN_ELIGIBLE_SAMPLES }}" },
],
documentation_gate: [],
};
for (const job of contract.jobs) {
const environmentNames = job.environment.map(({ name }) => name);
if (new Set(environmentNames).size !== environmentNames.length ||
JSON.stringify(job.environment) !== JSON.stringify(expectedEnvironmentBindings[job.id])) {
issue(`job environment binding drift: ${job.id}`);
}
}
if (owners.get("FE-GATE-015")?.[0] !== "immutable_build") {
issue("FE-GATE-015 must be owned only by immutable_build");
}
for (const job of contract.jobs.filter(({ kind }) => kind === "gate-matrix")) {
if (job.gateIds.includes("FE-GATE-015")) issue(`release matrix duplicates FE-GATE-015: ${job.id}`);
}
const browserGateIds = contract.jobs.flatMap(({ browserGateIds }) => browserGateIds).sort(asciiCompare);
if (JSON.stringify(browserGateIds) !== JSON.stringify(["FE-GATE-008", "FE-GATE-009", "FE-GATE-026"])) {
issue(`browser gate set drift: ${browserGateIds.join(",")}`);
}
for (const job of contract.jobs) {
const browserSteps = job.steps.filter(({ kind }) => kind === "browser-install").length;
const expected = job.browserGateIds.length > 0 ? 1 : 0;
if (browserSteps !== expected) issue(`browser install step drift: ${job.id}`);
}
for (const cycle of findCycles(contract.jobs.map((job) => [job.id, job.needs] as const))) {
issue(`job dependency cycle: ${cycle.join(" -> ")}`);
}
const uploads = new Map<string, { producer: string; step: z.infer<typeof uploadStep> }>();
for (const job of contract.jobs) {
for (const step of job.steps) {
if (step.kind !== "upload") continue;
const previous = uploads.get(step.transferId);
if (previous) issue(`duplicate upload transfer ${step.transferId}: ${previous.producer}, ${job.id}`);
uploads.set(step.transferId, { producer: job.id, step });
}
}
const dependencies = new Map(contract.jobs.map((job) => [job.id, job.needs]));
for (const job of contract.jobs) {
for (const step of job.steps) {
if (step.kind !== "download") continue;
const upload = uploads.get(step.transferId);
if (!upload) issue(`unknown download transfer ${step.transferId} for ${job.id}`);
else if (!isDependencyReachable(job.id, upload.producer, dependencies)) {
issue(`download producer ${upload.producer} is unreachable from ${job.id}`);
}
}
}
const promotion = contract.jobs.find(({ id }) => id === "promotion");
if (promotion) {
if (promotion.kind !== "promotion") issue("promotion job kind drift");
if (promotion.gateIds.length > 0 || promotion.steps.some(({ kind }) => kind === "run-gate")) {
issue("promotion job must not own or run a gate");
}
const forbidden = promotion.steps.filter(({ kind }) =>
["archive-candidate", "run-provider"].includes(kind),
);
if (forbidden.length > 0) issue("promotion job must not build or rebuild candidate bytes");
const order = promotion.steps.map(({ kind }) => kind);
const verificationIndex = order.indexOf("verify-promotion");
const uploadIndex = order.indexOf("upload");
if (verificationIndex < 0 || uploadIndex !== verificationIndex + 1) {
issue("promotion verification and upload must be immediately adjacent");
}
const upload = promotion.steps[uploadIndex];
if (upload?.kind === "upload" && upload.always) {
issue("promotion upload must not use always");
}
if (
order.indexOf("extract") < order.lastIndexOf("download") ||
order.indexOf("verify-promotion") < order.indexOf("extract") ||
order.indexOf("upload") < order.indexOf("verify-promotion")
) {
issue("promotion formula order must download, verify, then upload");
}
}
const immutable = contract.jobs.find(({ id }) => id === "immutable_build");
const archive = immutable?.steps.find(({ kind }) => kind === "archive-candidate");
const expectedArchiveMembers = ["dist", ...RELEASE_CANDIDATE_EVIDENCE_PATHS, RELEASE_CANDIDATE_MANIFEST_PATH];
if (
!archive ||
archive.kind !== "archive-candidate" ||
JSON.stringify(archive.members) !== JSON.stringify(expectedArchiveMembers)
) {
issue("immutable candidate archive member set drift");
}
if (
archive?.kind === "archive-candidate" &&
(archive.stepId !== "candidate" ||
archive.archivePath !== ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" ||
archive.archiveOutputName !== "archive_sha256" ||
archive.distOutputName !== "dist_sha256")
) {
issue("immutable candidate output identity drift");
}
const promotionUpload = promotion?.steps.find(
(step) => step.kind === "upload" && step.transferId === "promoted-release",
);
if (!promotionUpload || promotionUpload.kind !== "upload" || JSON.stringify(promotionUpload.paths) !== JSON.stringify(PROMOTED_STAGING_PATHS)) {
issue("promotion upload bundle must contain the exact five typed paths");
}
validateCanonicalStepFields(contract, issue);
}
function validateCanonicalStepFields(
contract: z.infer<typeof ciGateContractBaseSchema>,
issue: (message: string, path?: PropertyKey[]) => void,
): void {
const immutable = contract.jobs.find(({ id }) => id === "immutable_build");
const immutableArchive = immutable?.steps.find(({ kind }) => kind === "archive-candidate");
const immutableUpload = immutable?.steps.find(
(step) => step.kind === "upload" && step.transferId === "release-candidate",
);
if (
!immutableArchive ||
immutableArchive.kind !== "archive-candidate" ||
!immutableUpload ||
immutableUpload.kind !== "upload" ||
JSON.stringify(immutableUpload.paths) !== JSON.stringify([immutableArchive.archivePath])
) {
issue("immutable archive and upload fields must remain linked");
}
const providerExpectations = {
vulnerability_provider: {
provider: "vulnerability",
downloadPath: ".release/vulnerability-candidate",
archivePath: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz",
targetRoot: ".release/verified-vulnerability",
lockfilePath: ".release/verified-vulnerability/pnpm-lock.yaml",
rawPath: "provider-evidence/untrusted/vulnerability-report.json",
rawName: "VULNERABILITY_REPORT_PATH",
sealedPath: "provider-evidence/vulnerability-report.json",
transferId: "vulnerability-provider-evidence",
},
provenance_provider: {
provider: "provenance",
downloadPath: ".release/provenance-candidate",
archivePath: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz",
targetRoot: ".release/verified-provenance",
lockfilePath: ".release/verified-provenance/pnpm-lock.yaml",
rawPath: "provider-evidence/untrusted/provenance-attestation.json",
rawName: "PROVENANCE_ATTESTATION_PATH",
sealedPath: "provider-evidence/provenance-attestation.json",
transferId: "provenance-provider-evidence",
},
} as const;
for (const [jobId, expected] of Object.entries(providerExpectations)) {
const job = contract.jobs.find(({ id }) => id === jobId);
const environment = new Map(job?.environment.map(({ name, value }) => [name, value]));
const download = job?.steps.find(({ kind }) => kind === "download");
const extract = job?.steps.find(({ kind }) => kind === "extract");
const runProvider = job?.steps.find(({ kind }) => kind === "run-provider");
const validateProvider = job?.steps.find(({ kind }) => kind === "validate-provider-evidence");
const upload = job?.steps.find(
(step) => step.kind === "upload" && step.transferId === expected.transferId,
);
if (
!download || download.kind !== "download" || download.transferId !== "release-candidate" || download.path !== expected.downloadPath ||
!extract || extract.kind !== "extract" || extract.archivePath !== expected.archivePath || extract.targetRoot !== expected.targetRoot ||
!runProvider || runProvider.kind !== "run-provider" || runProvider.provider !== expected.provider ||
!validateProvider || validateProvider.kind !== "validate-provider-evidence" || validateProvider.provider !== expected.provider ||
environment.get("CANDIDATE_ARCHIVE_PATH") !== expected.archivePath ||
environment.get("CANDIDATE_LOCKFILE_PATH") !== expected.lockfilePath ||
environment.get(expected.rawName) !== expected.rawPath ||
environment.get("VALIDATED_PROVIDER_REPORT_PATH") !== expected.sealedPath ||
!upload || upload.kind !== "upload" || JSON.stringify(upload.paths) !== JSON.stringify([expected.sealedPath])
) {
issue(`provider archive, extraction, evidence, and upload fields must remain linked: ${jobId}`);
}
}
const promotion = contract.jobs.find(({ id }) => id === "promotion");
const promotionDownloads = promotion?.steps.filter(({ kind }) => kind === "download");
const expectedDownloads = [
{ kind: "download", transferId: "release-candidate", path: ".release/candidate" },
{ kind: "download", transferId: "vulnerability-provider-evidence", path: ".release/vulnerability" },
{ kind: "download", transferId: "provenance-provider-evidence", path: ".release/provenance" },
];
const promotionExtract = promotion?.steps.find(({ kind }) => kind === "extract");
if (
JSON.stringify(promotionDownloads) !== JSON.stringify(expectedDownloads) ||
!promotionExtract ||
promotionExtract.kind !== "extract" ||
promotionExtract.archivePath !== ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" ||
promotionExtract.targetRoot !== ".release/verified-candidate"
) {
issue("promotion download and extraction fields must remain linked");
}
}
function validateJobStepKinds(
job: z.infer<typeof jobSchema>,
issue: (message: string, path?: PropertyKey[]) => void,
): void {
const allowed: Readonly<Record<z.infer<typeof jobSchema>["kind"], ReadonlySet<string>>> = {
"gate-matrix": new Set(["checkout", "setup-node", "frozen-install", "browser-install", "run-gate", "upload"]),
"gate-single": new Set(["checkout", "setup-node", "frozen-install", "run-gate", "upload"]),
immutable: new Set(["checkout", "setup-node", "frozen-install", "run-gate", "archive-candidate", "upload"]),
provider: new Set(["checkout", "setup-node", "frozen-install", "download", "validate-candidate-archive", "extract", "run-provider", "validate-provider-evidence", "upload"]),
promotion: new Set(["checkout", "setup-node", "frozen-install", "download", "validate-candidate-archive", "extract", "verify-promotion", "upload"]),
};
for (const step of job.steps) {
if (!allowed[job.kind].has(step.kind)) {
issue(`step kind ${step.kind} is forbidden for ${job.kind} job ${job.id}`);
}
}
const kinds = job.steps.map(({ kind }) => kind);
const extractIndex = kinds.indexOf("extract");
if ((job.kind === "provider" || job.kind === "promotion") && extractIndex < 0) {
issue(`verified extraction step is missing: ${job.id}`);
}
if (job.kind === "provider") {
const providerIndex = kinds.indexOf("run-provider");
const validateProviderIndex = kinds.indexOf("validate-provider-evidence");
const uploadIndex = kinds.indexOf("upload");
if (
providerIndex < extractIndex ||
validateProviderIndex < providerIndex ||
uploadIndex < validateProviderIndex
) {
issue(`provider execution/validation/upload order drift: ${job.id}`);
}
}
}
function findCycles(entries: readonly (readonly [string, readonly string[]])[]): string[][] {
const graph = new Map(entries);
const visiting = new Set<string>();
const visited = new Set<string>();
const stack: string[] = [];
const cycles: string[][] = [];
const visit = (node: string): void => {
if (visiting.has(node)) {
const start = stack.indexOf(node);
cycles.push([...stack.slice(start), node]);
return;
}
if (visited.has(node) || !graph.has(node)) return;
visiting.add(node);
stack.push(node);
for (const next of graph.get(node) ?? []) visit(next);
stack.pop();
visiting.delete(node);
visited.add(node);
};
for (const node of graph.keys()) visit(node);
return cycles;
}
function isDependencyReachable(
consumer: string,
producer: string,
graph: ReadonlyMap<string, readonly string[]>,
): boolean {
const pending = [...(graph.get(consumer) ?? [])];
const visited = new Set<string>();
while (pending.length > 0) {
const current = pending.shift()!;
if (current === producer) return true;
if (visited.has(current)) continue;
visited.add(current);
pending.push(...(graph.get(current) ?? []));
}
return false;
}
export function asciiCompare(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}