Nothing in the gate set read `public/*.json`. Every static gate passed and the whole suite passed while `pnpm dev` rendered the boot-error screen instead of the app, which is the only reason the drift survived two contract changes. `check:dev-release-manifest` compares the fixture's `setAlgorithm`, `setDigest` and package set against what `generate-contract-set.ts` composes, and is registered on FE-GATE-010 next to `check-tech-log-contract` so CI executes it. Unlike the refresh wired into contract generation, this observes the composed set directly, so it also catches a contribution added to or removed from `installed-contract-contributions.ts`. `CANONICAL_GATE_SHAPE_SHA256` recomputed by hand, as always: the committed constant 98d19911... was first reproduced from the committed `gates.json` with an independent transcription of `canonicalGateShapeSha256`, and only then was b4096244... hashed from the new one. Command counts move 84/96 -> 85/97; artifacts stay at 130 because the gate publishes no evidence file, matching `check-tech-log-contract`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1295 lines
54 KiB
TypeScript
1295 lines
54 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 { MANUAL_A11Y_ROUTE_IDS } from "../lib/manual-a11y-evidence.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 =
|
|
// Template merge. Both sides carried a digest of their own gate set; neither
|
|
// describes the merged one. Recomputed from the merged config/ci/gates.json.
|
|
// Task 11: recomputed again after FE-GATE-009 gained the
|
|
// TECH_LOG_STUDIO_ASSETS manual accessibility evidence artifact.
|
|
// Final fix wave item 1: recomputed again after FE-GATE-010 gained
|
|
// `check-tech-log-contract`. Recomputed with `canonicalGateShapeSha256`
|
|
// below, verified by first reproducing the previous constant from the
|
|
// previous `config/ci/gates.json` before hashing the new one.
|
|
// Alignment follow-up item 2: recomputed again after FE-GATE-007 gained
|
|
// `test-tech-log` and its junit evidence, by the same method — the previous
|
|
// constant f3cc9075… was reproduced from the previous gates.json first, so
|
|
// the transcription that produced this value is known to be the real one.
|
|
// Dev release manifest drift fix, item 2: recomputed again after FE-GATE-010
|
|
// gained `check-dev-release-manifest`. Same method — 98d19911… was first
|
|
// reproduced from the previous gates.json before this value was hashed.
|
|
"b40962448e617883060e09eb7183837cfea6833519f435f11713efd083210dbe";
|
|
|
|
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 !== 27) {
|
|
failures.push(`gate authority baseline must contain exactly 27 gates; received ${contract.gates.length}`);
|
|
}
|
|
// Final fix wave, item 1: FE-GATE-010 gained `check-tech-log-contract`, the
|
|
// drift gate that pins the vendored canonical Studio contract to its digest.
|
|
// Until it was referenced by a gate it ran only when typed by hand.
|
|
// Alignment follow-up, item 2: FE-GATE-007 gained `test-tech-log`. The suite
|
|
// already ran inside `test:coverage`'s combined vitest invocation, so a
|
|
// TechLog failure was reported as a coverage-gate failure with no junit of
|
|
// its own to name it.
|
|
// Dev release manifest drift fix, item 2: FE-GATE-010 gained
|
|
// `check-dev-release-manifest`. No gate read `public/*.json` at all, so a
|
|
// fixture that did not declare the compiled contract set broke `pnpm dev`
|
|
// outright while every static gate stayed green.
|
|
if (contract.commands.length !== 85 || commandReferenceCount !== 97) {
|
|
failures.push(
|
|
`command authority baseline must contain exactly 85 definitions and 97 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`,
|
|
);
|
|
}
|
|
// Template merge. 126 product artifacts plus the two the template added.
|
|
// Task 11 added one more: the TECH_LOG_STUDIO_ASSETS manual a11y evidence file.
|
|
// Alignment follow-up, item 2 added the TechLog junit report.
|
|
if (contract.artifacts.length !== 130) {
|
|
failures.push(`artifact authority baseline must contain exactly 130 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 ?? defaultCiContractMode()) === "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;
|
|
}
|
|
|
|
/**
|
|
* A removal fixture runs the whole suite against a deliberately *reduced* CI
|
|
* contract: the removed capability's gates, commands and artifacts are pruned.
|
|
* Loading that contract in canonical mode re-imposes the full exact-count
|
|
* authority on it, so the fixture failed on the very reduction it exists to
|
|
* prove. `runRemovalFixturePnpm` marks those runs, and this is where the mark
|
|
* is honoured.
|
|
*/
|
|
export function defaultCiContractMode(): "canonical" | "removal-fixture" {
|
|
return process.env.CI_CONTRACT_MODE === "removal-fixture"
|
|
? "removal-fixture"
|
|
: "canonical";
|
|
}
|
|
|
|
export function isReducedCiContractRun(): boolean {
|
|
return defaultCiContractMode() === "removal-fixture";
|
|
}
|
|
|
|
export async function loadCiGateContract(
|
|
root = process.cwd(),
|
|
options: LoadCiGateContractOptions = {},
|
|
): Promise<CiGateContract> {
|
|
const mode = options.mode ?? defaultCiContractMode();
|
|
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 accessibilityGate = contract.gates.find(
|
|
({ id: gateId }) => gateId === "FE-GATE-009",
|
|
);
|
|
const manualAccessibilityArtifacts = MANUAL_A11Y_ROUTE_IDS.map((routeId) => ({
|
|
id: `artifact-artifacts-tests-a11y-manual-${routeId.replaceAll("_", "-")}-md`,
|
|
path: `artifacts/tests/a11y-manual/${routeId}.md`,
|
|
}));
|
|
const expectedAccessibilityEvidenceArtifactIds = [
|
|
"artifact-artifacts-tests-a11y-json",
|
|
...manualAccessibilityArtifacts.map(({ id: artifactId }) => artifactId),
|
|
"artifact-artifacts-tests-a11y-manual-report-json",
|
|
];
|
|
if (
|
|
!accessibilityGate ||
|
|
JSON.stringify(accessibilityGate.evidenceArtifactIds) !==
|
|
JSON.stringify(expectedAccessibilityEvidenceArtifactIds)
|
|
) {
|
|
issue(
|
|
"FE-GATE-009 manual accessibility evidence must exactly match the installed route scope",
|
|
);
|
|
}
|
|
for (const expected of manualAccessibilityArtifacts) {
|
|
const artifact = contract.artifacts.find(
|
|
({ id: artifactId }) => artifactId === expected.id,
|
|
);
|
|
if (
|
|
!artifact ||
|
|
artifact.path !== expected.path ||
|
|
artifact.schemaId !== "markdown" ||
|
|
artifact.production !== "source-controlled"
|
|
) {
|
|
issue(
|
|
`FE-GATE-009 manual accessibility artifact registration is invalid: ${expected.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: 27 },
|
|
(_, 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..027 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", "FE-GATE-027"],
|
|
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: [
|
|
// FE-GATE-027 admits the built artifact to a named environment, so both
|
|
// the profile it was built from and the destination it is claimed for are
|
|
// declared inputs. An absent RELEASE_TARGET is a refusal, not a default.
|
|
{ name: "APP_PROFILE", value: "${{ vars.APP_PROFILE }}" },
|
|
{ name: "RELEASE_TARGET", value: "${{ vars.RELEASE_TARGET }}" },
|
|
],
|
|
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;
|
|
}
|