refactor: generate CI workflow from gate contracts
This commit is contained in:
@@ -0,0 +1,993 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
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 artifactSchema = z
|
||||
.object({ id, path: repositoryPath, schemaId: id })
|
||||
.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 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()): 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 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 = "corepack pnpm check:artifact-schemas && node scripts/check-ci-contract.ts && 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 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}`);
|
||||
}
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const PROMOTED_STAGING_PATHS = Object.freeze([
|
||||
".release/promoted-staging/release-candidate.tar.gz",
|
||||
".release/promoted-staging/vulnerability-report.json",
|
||||
".release/promoted-staging/provenance-attestation.json",
|
||||
".release/promoted-staging/provider-verification.json",
|
||||
".release/promoted-staging/promotion-verification.json",
|
||||
] as const);
|
||||
Reference in New Issue
Block a user