The provider sandbox never ran. bubblewrap 0.9.0 stops parsing an `--args` file at the first non-option and never hands the remainder back, so the command written into that file was silently dropped: bwrap printed its usage text, exited 1, and the provider produced no evidence at all. The options still travel in the args file — that is what keeps host paths and credentials out of `/proc/<pid>/cmdline` — but the command now rides on real argv, and `encodeProviderBwrapInput` refuses a `--` so the drop cannot come back. The scope wrapper then could not exit. It read the supervisor's liveness pipe through `fs`, which runs a blocking `read(2)` on a threadpool thread; the supervisor holds that pipe open for the scope's whole life, so the read never returned and closing the descriptor did not interrupt it. Once bubblewrap finished the wrapper deadlocked in `process.exit`, the scope outlived the provider, and a completed run was reported as a timeout kill. The channel is now read through the event loop, so teardown is observable and terminal. Creation modes were left to the ambient umask. `mkdir(mode)` and `open(mode)` are requests the kernel subtracts the umask from, so a runner exporting a restrictive umask produced directories it could not enter and handed `tar` a file it could not re-open. Private modes are pinned instead of inherited. Promotion cleanup deleted before it checked. Removals run through a pinned descriptor, so a leaf substituted after validation had this promotion's exact five destroyed first and the substitution reported afterwards, leaving a half-emptied directory a retry could not tell from a completed one. The name is re-bound to the inode before anything is removed, so the failure is total. Separately, release coherence proved the artifacts agreed with each other but never that they belonged where they were going: a build whose runtime document said `APP_ENV: local`, `AUTH_MODE: demo` and a loopback API is coherent with itself and passed every gate. `public/` is copied verbatim into `dist/`, so that local document shipped with every build regardless of what the build was for. Runtime configuration now comes from a declared profile, and FE-GATE-027 refuses to admit an artifact to an environment it does not match — including refusing an undeclared destination, so nothing is admitted by omission. `REQUEST_TIMEOUT_MS` and `VITE_ROUTER_BASE_PATH` were validated and then dropped: the V3 executor ran every operation on its contract's own deadline, and Vite emitted root-absolute assets for a sub-path deployment. The timeout is now a ceiling that may tighten a contract but never loosen one, and one base path feeds the router, the Service Worker scope and the asset base together. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1203 lines
50 KiB
TypeScript
1203 lines
50 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 {
|
|
validateInstallScriptPolicy,
|
|
validateNpmScopeEnvironment,
|
|
validatePackageScriptGraph,
|
|
} from "../lib/package-script-graph.ts";
|
|
import { PROMOTED_UPLOAD_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([
|
|
"automated-a11y",
|
|
"manual-a11y",
|
|
"architecture-dependency-report",
|
|
"design-system-contract",
|
|
"i18n-contract",
|
|
"diagnostics-contract",
|
|
"realtime-boundaries",
|
|
"optional-recipes",
|
|
"optional-recipe-fixtures",
|
|
"registry-compatibility-fixtures",
|
|
"reproducible-build",
|
|
"supply-chain-fixtures",
|
|
"supply-chain-provider-fixtures",
|
|
"compatibility-fixtures",
|
|
"documentation-review",
|
|
"hosting-headers",
|
|
"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",
|
|
"deployment-admission",
|
|
]),
|
|
})
|
|
.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 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"]),
|
|
stepId: id,
|
|
})
|
|
.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"), stepId: id })
|
|
.strict();
|
|
const cleanupPromotionStep = z
|
|
.object({ kind: z.literal("cleanup-promotion"), finalizerStepId: id })
|
|
.strict();
|
|
|
|
const jobStepSchema = z.discriminatedUnion("kind", [
|
|
checkoutStep,
|
|
setupNodeStep,
|
|
frozenInstallStep,
|
|
browserInstallStep,
|
|
runGateStep,
|
|
archiveCandidateStep,
|
|
uploadStep,
|
|
downloadStep,
|
|
extractStep,
|
|
providerStep,
|
|
validateProviderStep,
|
|
promotionStep,
|
|
cleanupPromotionStep,
|
|
]);
|
|
|
|
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", "needs-success", "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");
|
|
}
|
|
|
|
function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[] {
|
|
const failures: string[] = [];
|
|
const commandReferenceCount = contract.gates.reduce(
|
|
(total, gate) => total + gate.commandIds.length,
|
|
0,
|
|
);
|
|
if (contract.gates.length !== 26) {
|
|
failures.push(`gate authority baseline must contain exactly 26 gates; received ${contract.gates.length}`);
|
|
}
|
|
if (contract.commands.length !== 81 || commandReferenceCount !== 93) {
|
|
failures.push(
|
|
`command authority baseline must contain exactly 81 definitions and 93 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`,
|
|
);
|
|
}
|
|
if (contract.artifacts.length !== 105) {
|
|
failures.push(`artifact authority baseline must contain exactly 105 artifacts; received ${contract.artifacts.length}`);
|
|
}
|
|
if (contract.stages.length !== 5) {
|
|
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
|
|
}
|
|
const expectedRetentionClasses = [
|
|
{ id: "merge-cycle", policy: "at least through pull-request readiness decision" },
|
|
{ id: "release-coherence", policy: "at least until the next release is promoted" },
|
|
{ id: "prod-drill", policy: "at least until the next production promotion decision" },
|
|
{ id: "field", policy: "through the 28-day window and aggregation" },
|
|
{ id: "documentation", policy: "through documentation readiness review" },
|
|
];
|
|
if (JSON.stringify(contract.retention.classes) !== JSON.stringify(expectedRetentionClasses)) {
|
|
failures.push("retention registry must contain exactly the five canonical retention classes");
|
|
}
|
|
return failures;
|
|
}
|
|
|
|
export function parseCiGateContract(
|
|
value: unknown,
|
|
options: LoadCiGateContractOptions = {},
|
|
): 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}`);
|
|
}
|
|
if ((options.mode ?? "canonical") === "canonical") {
|
|
const failures = canonicalAuthorityBaselineFailures(result.data);
|
|
if (failures.length > 0) {
|
|
throw new TypeError(`CI gate contract invalid:\n${failures.map((failure) => `root: ${failure}`).join("\n")}`);
|
|
}
|
|
}
|
|
return result.data;
|
|
}
|
|
|
|
export async function loadCiGateContract(
|
|
root = process.cwd(),
|
|
options: LoadCiGateContractOptions = {},
|
|
): Promise<CiGateContract> {
|
|
const mode = options.mode ?? "canonical";
|
|
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), { mode });
|
|
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 contractEntryScripts = [
|
|
...new Set(contract.commands.map((command) => command.script)),
|
|
];
|
|
const graphFailures = [
|
|
...validatePackageScriptGraph(packageDocument.scripts, "check:ci"),
|
|
...contractEntryScripts.flatMap((script) =>
|
|
validatePackageScriptGraph(packageDocument.scripts, script)
|
|
),
|
|
];
|
|
if (graphFailures.length > 0) {
|
|
throw new TypeError(
|
|
`CI package script graph invalid:\n${[...new Set(graphFailures)].join("\n")}`,
|
|
);
|
|
}
|
|
const installPolicyFailures = validateInstallScriptPolicy(
|
|
packageDocument.scripts,
|
|
contractEntryScripts,
|
|
);
|
|
if (installPolicyFailures.length > 0) {
|
|
throw new TypeError(
|
|
`CI package script install policy invalid:\n${installPolicyFailures.join("\n")}`,
|
|
);
|
|
}
|
|
return contract;
|
|
}
|
|
|
|
export async function withCiGatePreflight<Result>(
|
|
root: string,
|
|
gateId: string | undefined,
|
|
execute: (context: Readonly<{
|
|
contract: CiGateContract;
|
|
contractIndex: CiGateContractIndex;
|
|
gateId: string;
|
|
gate: CiGate;
|
|
}>) => Promise<Result> | Result,
|
|
): Promise<Result> {
|
|
const npmScopeEnvironmentFailures = validateNpmScopeEnvironment(process.env);
|
|
if (npmScopeEnvironmentFailures.length > 0) {
|
|
throw new TypeError(
|
|
`CI runner npm scope environment invalid:\n${npmScopeEnvironmentFailures.join("\n")}`,
|
|
);
|
|
}
|
|
const contract = await loadCiGateContract(root);
|
|
const contractIndex = indexCiGateContract(contract);
|
|
const gate = gateId ? contractIndex.gates.get(gateId) : undefined;
|
|
if (!gateId || !gate) {
|
|
throw new TypeError("CI gate id must be FE-GATE-001..FE-GATE-026");
|
|
}
|
|
return execute({ contract, contractIndex, gateId, gate });
|
|
}
|
|
|
|
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 referencedRetentionClasses = new Set(
|
|
contract.gates.map(({ retentionClassId }) => retentionClassId),
|
|
);
|
|
for (const retentionClass of contract.retention.classes) {
|
|
if (!referencedRetentionClasses.has(retentionClass.id)) {
|
|
issue(`orphan retention class: ${retentionClass.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"], "needs-success"],
|
|
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", "run-provider", "validate-provider-evidence", "upload"],
|
|
provenance_provider: ["checkout", "setup-node", "frozen-install", "download", "run-provider", "validate-provider-evidence", "upload"],
|
|
promotion: ["checkout", "setup-node", "frozen-install", "download", "download", "download", "verify-promotion", "upload", "cleanup-promotion"],
|
|
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: "CI_RUN_ID", value: "${{ gitea.run_id }}" },
|
|
{ name: "CI_RUN_ATTEMPT", value: "${{ gitea.run_attempt }}" },
|
|
{ name: "EXPECTED_SOURCE_REVISION", value: "${{ gitea.sha }}" },
|
|
{ name: "VULNERABILITY_PUBLIC_KEY_PATH", value: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}" },
|
|
{ name: "VULNERABILITY_KEY_ID", value: "${{ vars.VULNERABILITY_KEY_ID }}" },
|
|
{ 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: "CI_RUN_ID", value: "${{ gitea.run_id }}" },
|
|
{ name: "CI_RUN_ATTEMPT", value: "${{ gitea.run_attempt }}" },
|
|
{ name: "EXPECTED_SOURCE_REVISION", value: "${{ gitea.sha }}" },
|
|
{ name: "PROVENANCE_PUBLIC_KEY_PATH", value: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}" },
|
|
{ name: "PROVENANCE_KEY_ID", value: "${{ vars.PROVENANCE_KEY_ID }}" },
|
|
{ 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: "CI_RUN_ID", value: "${{ gitea.run_id }}" },
|
|
{ name: "CI_RUN_ATTEMPT", value: "${{ gitea.run_attempt }}" },
|
|
{ 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 }}" },
|
|
{ name: "VULNERABILITY_INVOCATION_NONCE", value: "${{ needs.vulnerability_provider.outputs.invocation_nonce }}" },
|
|
{ name: "PROVENANCE_INVOCATION_NONCE", value: "${{ needs.provenance_provider.outputs.invocation_nonce }}" },
|
|
],
|
|
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");
|
|
}
|
|
const cleanupIndex = order.indexOf("cleanup-promotion");
|
|
if (
|
|
order.includes("extract") ||
|
|
verificationIndex < order.lastIndexOf("download") ||
|
|
uploadIndex < verificationIndex ||
|
|
cleanupIndex !== uploadIndex + 1
|
|
) {
|
|
issue("promotion formula order must download, finalize, upload, then cleanup without extraction");
|
|
}
|
|
}
|
|
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_UPLOAD_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",
|
|
stepId: "supervise_vulnerability",
|
|
downloadPath: ".release/vulnerability-candidate",
|
|
archivePath: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz",
|
|
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",
|
|
stepId: "supervise_provenance",
|
|
downloadPath: ".release/provenance-candidate",
|
|
archivePath: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz",
|
|
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 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 ||
|
|
!runProvider || runProvider.kind !== "run-provider" || runProvider.provider !== expected.provider || runProvider.stepId !== expected.stepId ||
|
|
!validateProvider || validateProvider.kind !== "validate-provider-evidence" || validateProvider.provider !== expected.provider ||
|
|
environment.get("CANDIDATE_ARCHIVE_PATH") !== expected.archivePath ||
|
|
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 promotionFinalizer = promotion?.steps.find(({ kind }) => kind === "verify-promotion");
|
|
const promotionCleanup = promotion?.steps.find(({ kind }) => kind === "cleanup-promotion");
|
|
if (
|
|
JSON.stringify(promotionDownloads) !== JSON.stringify(expectedDownloads) ||
|
|
!promotionFinalizer ||
|
|
promotionFinalizer.kind !== "verify-promotion" ||
|
|
promotionFinalizer.stepId !== "finalize" ||
|
|
!promotionCleanup ||
|
|
promotionCleanup.kind !== "cleanup-promotion" ||
|
|
promotionCleanup.finalizerStepId !== "finalize"
|
|
) {
|
|
issue("promotion download fields and finalizer/cleanup step identities 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", "extract", "run-provider", "validate-provider-evidence", "upload"]),
|
|
promotion: new Set(["checkout", "setup-node", "frozen-install", "download", "verify-promotion", "upload", "cleanup-promotion"]),
|
|
};
|
|
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);
|
|
if (job.kind === "provider") {
|
|
const providerIndex = kinds.indexOf("run-provider");
|
|
const validateProviderIndex = kinds.indexOf("validate-provider-evidence");
|
|
const uploadIndex = kinds.indexOf("upload");
|
|
if (
|
|
providerIndex < kinds.lastIndexOf("download") ||
|
|
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;
|
|
}
|