The public site shipped a Releases page and a footer link to a release, and neither could ever have content: the read path existed, the write path did not. This adds the seven release operations to the contract contribution and the gateway, and a Studio screen that can actually write one. The editor is six markdown fields rather than one, because that is what the contract models and what a release note is — why, what, what a reader notices, what it leaves in the code, how it was verified, what is still open. Publishing is separate from saving: a draft saves in any state, but the public query keys on PUBLISHED alone, so publish is where completeness is demanded. No new CSS. The screen reuses the document editor's field classes and the working-copy list's row classes, so it inherits Studio's spacing and type instead of introducing a second look. The footer previously linked `/releases/0.1.0` — a version that did not exist, so the link 404'd, and one that would have gone stale at 0.2.0 anyway. It now points at the changelog index, which is the only place that knows what the latest release is and which reads correctly when there are none. Route inventory, navigation order, message catalog, manual accessibility evidence, artifact baseline, and the pinned gate-shape digest all move with the new route. The digest was recomputed by first reproducing the previous constant from the previous gates.json, so the computation is known to be the one it was pinned under.
1306 lines
55 KiB
TypeScript
1306 lines
55 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.
|
|
// Taxonomy route: recomputed again after FE-GATE-009 gained the
|
|
// TECH_LOG_STUDIO_TAXONOMY manual accessibility evidence artifact. The gate
|
|
// lists one evidence artifact per installed route and refuses a set that does
|
|
// not match the route scope exactly, so adding a route necessarily moves this
|
|
// digest — that is the point of pinning it.
|
|
// Release authoring: recomputed again after FE-GATE-009 gained the
|
|
// TECH_LOG_STUDIO_RELEASES evidence artifact, by the same method — 8c73d447…
|
|
// was first reproduced from the previous gates.json, so the computation that
|
|
// produced this value is known to be the one the constant was pinned under.
|
|
"187dbd9676b7b3444409b5af4143d49927b1eacc67f57a099f4a3765bb64d865";
|
|
|
|
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.
|
|
// The taxonomy route added its own manual a11y evidence file — every installed
|
|
// route carries one, and the gate checks that the two sets match exactly.
|
|
if (contract.artifacts.length !== 132) {
|
|
failures.push(`artifact authority baseline must contain exactly 132 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;
|
|
}
|