FE-GATE-020 proves a capability can be removed by rebuilding the whole project without it. The fixture it built could not get that far, and the failures all came from the fixture rather than from anything about removability. It was not a repository. The supply-chain inventory is defined as the tracked file set, so it asks `git ls-files` what the project contains; with no repository to ask, generation failed and took every provider suite down with it. It is now initialised on preparation and committed after the removal — not before, or the index would still list the files the removal deleted. It had no `.gitignore`, so once it did have a repository, every generated artifact and every linked module landed in the index and the inventory refused the fixture for tracked and generated paths colliding. It carries the ignore rules now, and therefore records the same tracked set as the repository it was copied from. Each removal script kept its own copy-target list and they had drifted: the reference-feature fixture omitted `playwright.capabilities.config.ts`, which the inventory requires. There is one list now. It also gained the install and workspace identity — `.npmrc`, the lockfile, the workspace file — without which the fixture is a different project, and the release evidence a candidate is assembled from, without which no candidate can be built at all. A tracked root the removal deletes is no longer required of the result: the optional-recipe fixture deletes `recipes/`, and the inventory policy demanded it back. Roots that are gone are pruned from the fixture's policy. Two smaller causes. A platform integration file asserted the reference feature's own route ids, so removing the feature left it importing a deleted module — typecheck, the test run, coverage and the residue scan all failed on that one misplaced assertion, which now lives in the feature's test tree. And the canonical exact-count authority was re-imposed on a contract the fixture deliberately reduces, failing the fixture for the reduction it exists to prove; `CI_CONTRACT_MODE` already marked those runs and is now honoured by default. The reference-feature fixture goes from failing before its first assertion to 1,612 passing with one failure, and that one is the live process-tree observation test already red on the main tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1227 lines
51 KiB
TypeScript
1227 lines
51 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
import { createHash } from "node:crypto";
|
|
import path from "node:path";
|
|
|
|
import { z } from "zod";
|
|
|
|
import { PROMOTION_FORMULA } from "../../src/application/policies/promotion-readiness.ts";
|
|
import {
|
|
RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
|
RELEASE_CANDIDATE_MANIFEST_PATH,
|
|
} from "../lib/release-candidate.ts";
|
|
import {
|
|
validateInstallScriptPolicy,
|
|
validateNpmScopeEnvironment,
|
|
validatePackageScriptGraph,
|
|
} from "../lib/package-script-graph.ts";
|
|
import { PROMOTED_UPLOAD_PATHS } from "./promotion-artifacts.ts";
|
|
|
|
const ciActionRegistrationSchema = z
|
|
.object({
|
|
repository: z
|
|
.string()
|
|
.regex(
|
|
/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u,
|
|
"CI action repository must be an absolute upstream GitHub URL",
|
|
),
|
|
revision: z
|
|
.string()
|
|
.regex(/^[0-9a-f]{40}$/u, "CI action revision must be a full 40-hex commit SHA"),
|
|
version: z.string().min(1).max(64),
|
|
})
|
|
.strict()
|
|
.readonly();
|
|
|
|
const ciActionRegistrySchema = z
|
|
.object({
|
|
checkout: ciActionRegistrationSchema,
|
|
setupNode: ciActionRegistrationSchema,
|
|
uploadArtifact: ciActionRegistrationSchema,
|
|
downloadArtifact: ciActionRegistrationSchema,
|
|
})
|
|
.strict()
|
|
.readonly();
|
|
|
|
export type CiActionRegistry = z.infer<typeof ciActionRegistrySchema>;
|
|
export type CiActionId = keyof CiActionRegistry;
|
|
|
|
export function parseCiActionRegistry(value: unknown): CiActionRegistry {
|
|
const result = ciActionRegistrySchema.safeParse(value);
|
|
if (!result.success) {
|
|
const diagnostic = result.error.issues
|
|
.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`)
|
|
.join("\n");
|
|
throw new TypeError(`CI action registry invalid:\n${diagnostic}`);
|
|
}
|
|
return result.data;
|
|
}
|
|
|
|
export const CI_ACTION_REGISTRY = parseCiActionRegistry({
|
|
checkout: {
|
|
repository: "https://github.com/actions/checkout",
|
|
revision: "34e114876b0b11c390a56381ad16ebd13914f8d5",
|
|
version: "v4.3.1",
|
|
},
|
|
setupNode: {
|
|
repository: "https://github.com/actions/setup-node",
|
|
revision: "49933ea5288caeca8642d1e84afbd3f7d6820020",
|
|
version: "v4.4.0",
|
|
},
|
|
uploadArtifact: {
|
|
repository: "https://github.com/ChristopherHX/gitea-upload-artifact",
|
|
revision: "81f940d004763f986ba3582c007fd842dd5cb0d7",
|
|
version: "v4 branch",
|
|
},
|
|
downloadArtifact: {
|
|
repository: "https://github.com/ChristopherHX/gitea-download-artifact",
|
|
revision: "75635f32b4c1c41c4b3d64e8f85210112ed4c9c7",
|
|
version: "v4 branch",
|
|
},
|
|
});
|
|
|
|
const actionIdByStepKind = Object.freeze({
|
|
checkout: "checkout",
|
|
"setup-node": "setupNode",
|
|
upload: "uploadArtifact",
|
|
download: "downloadArtifact",
|
|
} as const satisfies Readonly<Record<string, CiActionId>>);
|
|
|
|
export function resolveCiActionUses(actionId: CiActionId): string {
|
|
const action = CI_ACTION_REGISTRY[actionId];
|
|
if (!action) throw new TypeError(`unknown CI action: ${String(actionId)}`);
|
|
return `${action.repository}@${action.revision}`;
|
|
}
|
|
|
|
export function resolveCiStepActionUses(stepKind: string): string | null {
|
|
const actionId = actionIdByStepKind[stepKind as keyof typeof actionIdByStepKind];
|
|
return actionId ? resolveCiActionUses(actionId) : null;
|
|
}
|
|
|
|
function hasAsciiControl(value: string): boolean {
|
|
return [...value].some((character) => {
|
|
const codePoint = character.codePointAt(0)!;
|
|
return codePoint <= 0x1f || codePoint === 0x7f;
|
|
});
|
|
}
|
|
|
|
function hasForbiddenLineOrControl(value: string): boolean {
|
|
return [...value].some((character) => {
|
|
const codePoint = character.codePointAt(0)!;
|
|
return (
|
|
codePoint <= 0x1f ||
|
|
codePoint === 0x7f ||
|
|
codePoint === 0x85 ||
|
|
codePoint === 0x2028 ||
|
|
codePoint === 0x2029
|
|
);
|
|
});
|
|
}
|
|
|
|
const nonEmpty = z
|
|
.string()
|
|
.min(1)
|
|
.max(4_096)
|
|
.refine((value) => !hasForbiddenLineOrControl(value), "control and Unicode line-break characters are forbidden")
|
|
.refine((value) => value === value.trim(), "leading/trailing whitespace is forbidden");
|
|
const id = nonEmpty.max(128).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u);
|
|
const repositoryPath = nonEmpty.superRefine((value, context) => {
|
|
if (
|
|
value.includes("\\") ||
|
|
value.includes("\0") ||
|
|
path.posix.isAbsolute(value) ||
|
|
path.posix.normalize(value) !== value ||
|
|
value === "." ||
|
|
value === ".." ||
|
|
value.startsWith("../") ||
|
|
value.includes("/../")
|
|
) {
|
|
context.addIssue({ code: "custom", message: `unsafe repository path: ${value}` });
|
|
}
|
|
});
|
|
const workflowValue = nonEmpty.refine(
|
|
(value) => !value.includes("\0") && !value.includes("\r"),
|
|
"workflow values must not contain NUL or carriage returns",
|
|
);
|
|
const workflowPath = workflowValue.superRefine((value, context) => {
|
|
if (
|
|
value.includes("\\") ||
|
|
path.posix.isAbsolute(value) ||
|
|
path.posix.normalize(value) !== value ||
|
|
value === "." ||
|
|
value === ".." ||
|
|
value.startsWith("../") ||
|
|
value.includes("/../")
|
|
) {
|
|
context.addIssue({ code: "custom", message: `unsafe workflow path: ${value}` });
|
|
}
|
|
});
|
|
|
|
const commandSchema = z
|
|
.object({
|
|
id,
|
|
script: nonEmpty.regex(/^[A-Za-z0-9:_-]+$/u),
|
|
args: z.array(z.string().max(512).refine((value) => !hasAsciiControl(value), "command arguments contain controls")).max(32).optional(),
|
|
timeoutMs: z.number().int().min(1_000).max(3_600_000).optional(),
|
|
expect: z.enum(["pass", "fail"]),
|
|
expectedExitCode: z.number().int().min(1).max(255).optional(),
|
|
expectedDiagnosticId: nonEmpty.max(256).optional(),
|
|
})
|
|
.strict()
|
|
.superRefine((command, context) => {
|
|
const hasNegativeIdentity =
|
|
command.expectedExitCode !== undefined ||
|
|
command.expectedDiagnosticId !== undefined;
|
|
if (command.expect === "pass" && hasNegativeIdentity) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: `pass command carries negative identity: ${command.id}`,
|
|
});
|
|
}
|
|
if (
|
|
command.expect === "fail" &&
|
|
(command.expectedExitCode === undefined || !command.expectedDiagnosticId)
|
|
) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: `negative command lacks exact identity: ${command.id}`,
|
|
});
|
|
}
|
|
if (
|
|
command.expectedDiagnosticId &&
|
|
/[\n\r\0]/u.test(command.expectedDiagnosticId)
|
|
) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: `negative command diagnostic is unsafe: ${command.id}`,
|
|
});
|
|
}
|
|
});
|
|
|
|
const artifactSchemaSchema = z.discriminatedUnion("kind", [
|
|
z.object({ id, kind: z.literal("text"), maxBytes: z.number().int().min(1).max(268_435_456) }).strict(),
|
|
z
|
|
.object({
|
|
id,
|
|
kind: z.literal("json"),
|
|
maxBytes: z.number().int().min(1).max(268_435_456),
|
|
executableSchemaId: z.enum([
|
|
"automated-a11y",
|
|
"manual-a11y",
|
|
"architecture-dependency-report",
|
|
"design-system-contract",
|
|
"i18n-contract",
|
|
"diagnostics-contract",
|
|
"realtime-boundaries",
|
|
"optional-recipes",
|
|
"optional-recipe-fixtures",
|
|
"registry-compatibility-fixtures",
|
|
"reproducible-build",
|
|
"supply-chain-fixtures",
|
|
"supply-chain-provider-fixtures",
|
|
"compatibility-fixtures",
|
|
"documentation-review",
|
|
"hosting-headers",
|
|
"coverage-summary-v8",
|
|
"risk-coverage-v3",
|
|
"build-manifest",
|
|
"module-inventory",
|
|
"dependency-inventory",
|
|
"registry-snapshot",
|
|
"registry-governance-run",
|
|
"bundle-performance",
|
|
"sbom",
|
|
"provenance",
|
|
"dependency-diff",
|
|
"license-report",
|
|
"vulnerability-report",
|
|
"field-web-vitals",
|
|
"lab-performance",
|
|
"release-verification",
|
|
"runbook-record",
|
|
"supply-chain-verification",
|
|
"release-candidate",
|
|
"supply-chain-coherence",
|
|
"http-scenario-receipt",
|
|
"test-evidence-report",
|
|
"provider-vulnerability",
|
|
"provider-provenance",
|
|
"provider-verification",
|
|
"ci-contract-report",
|
|
"deployment-admission",
|
|
]),
|
|
})
|
|
.strict(),
|
|
z
|
|
.object({
|
|
id,
|
|
kind: z.literal("json-schema"),
|
|
maxBytes: z.number().int().min(1).max(268_435_456),
|
|
})
|
|
.strict(),
|
|
z.object({ id, kind: z.literal("junit"), maxBytes: z.number().int().min(1).max(268_435_456) }).strict(),
|
|
z.object({ id, kind: z.literal("html"), maxBytes: z.number().int().min(1).max(268_435_456) }).strict(),
|
|
z.object({ id, kind: z.literal("markdown"), maxBytes: z.number().int().min(1).max(268_435_456) }).strict(),
|
|
z.object({ id, kind: z.literal("sarif"), maxBytes: z.number().int().min(1).max(268_435_456) }).strict(),
|
|
z
|
|
.object({ id, kind: z.literal("candidate-archive"), maxBytes: z.number().int().min(1).max(268_435_456) })
|
|
.strict(),
|
|
]);
|
|
|
|
const artifactBaseShape = { id, path: repositoryPath, schemaId: id } as const;
|
|
const artifactSchema = z.discriminatedUnion("production", [
|
|
z.object({ ...artifactBaseShape, production: z.literal("source-controlled") }).strict(),
|
|
z
|
|
.object({
|
|
...artifactBaseShape,
|
|
production: z.literal("command-generated"),
|
|
producerCommandIds: z.array(id).min(1).max(32),
|
|
})
|
|
.strict(),
|
|
z.object({ ...artifactBaseShape, production: z.literal("runner-generated") }).strict(),
|
|
]);
|
|
|
|
const gateSchema = z
|
|
.object({
|
|
id: z.string().regex(/^FE-GATE-\d{3}$/u),
|
|
name: nonEmpty.regex(/^[a-z0-9][a-z0-9-]*$/u),
|
|
commandIds: z.array(id).min(1).max(128),
|
|
logArtifactId: id,
|
|
evidenceArtifactIds: z.array(id).min(1).max(128),
|
|
retentionClassId: id,
|
|
requiresEnvironment: z.array(z.string().max(128).regex(/^[A-Z][A-Z0-9_]*$/u)).max(32).optional(),
|
|
})
|
|
.strict();
|
|
|
|
const stageSchema = z
|
|
.object({
|
|
id,
|
|
readiness: z.enum([
|
|
"MERGE_READY",
|
|
"RELEASE_READY",
|
|
"PROD_PROMOTION_READY",
|
|
"FIELD_SLO_READY",
|
|
"DOCUMENTATION_READY",
|
|
]),
|
|
needs: z.array(id).max(16),
|
|
gateIds: z.array(id).min(1).max(64),
|
|
})
|
|
.strict();
|
|
|
|
const environmentBindingSchema = z
|
|
.object({ name: z.string().regex(/^[A-Z][A-Z0-9_]*$/u), value: workflowValue })
|
|
.strict();
|
|
|
|
const checkoutStep = z.object({ kind: z.literal("checkout") }).strict();
|
|
const setupNodeStep = z.object({ kind: z.literal("setup-node") }).strict();
|
|
const frozenInstallStep = z.object({ kind: z.literal("frozen-install") }).strict();
|
|
const browserInstallStep = z.object({ kind: z.literal("browser-install") }).strict();
|
|
const runGateStep = z.object({ kind: z.literal("run-gate") }).strict();
|
|
const archiveCandidateStep = z
|
|
.object({
|
|
kind: z.literal("archive-candidate"),
|
|
stepId: id,
|
|
archivePath: workflowPath,
|
|
members: z.array(repositoryPath).min(1).max(128),
|
|
archiveOutputName: id,
|
|
distOutputName: id,
|
|
})
|
|
.strict();
|
|
const uploadStep = z
|
|
.object({
|
|
kind: z.literal("upload"),
|
|
transferId: id,
|
|
name: workflowValue,
|
|
paths: z.array(workflowPath).min(1).max(128),
|
|
always: z.boolean().optional(),
|
|
})
|
|
.strict();
|
|
const downloadStep = z
|
|
.object({ kind: z.literal("download"), transferId: id, path: workflowPath })
|
|
.strict();
|
|
const extractStep = z
|
|
.object({
|
|
kind: z.literal("extract"),
|
|
archivePath: workflowPath,
|
|
targetRoot: workflowPath,
|
|
})
|
|
.strict();
|
|
const providerStep = z
|
|
.object({
|
|
kind: z.literal("run-provider"),
|
|
provider: z.enum(["vulnerability", "provenance"]),
|
|
stepId: id,
|
|
})
|
|
.strict();
|
|
const validateProviderStep = z
|
|
.object({
|
|
kind: z.literal("validate-provider-evidence"),
|
|
provider: z.enum(["vulnerability", "provenance"]),
|
|
})
|
|
.strict();
|
|
const promotionStep = z
|
|
.object({ kind: z.literal("verify-promotion"), stepId: id })
|
|
.strict();
|
|
const cleanupPromotionStep = z
|
|
.object({ kind: z.literal("cleanup-promotion"), finalizerStepId: id })
|
|
.strict();
|
|
|
|
const jobStepSchema = z.discriminatedUnion("kind", [
|
|
checkoutStep,
|
|
setupNodeStep,
|
|
frozenInstallStep,
|
|
browserInstallStep,
|
|
runGateStep,
|
|
archiveCandidateStep,
|
|
uploadStep,
|
|
downloadStep,
|
|
extractStep,
|
|
providerStep,
|
|
validateProviderStep,
|
|
promotionStep,
|
|
cleanupPromotionStep,
|
|
]);
|
|
|
|
const jobSchema = z
|
|
.object({
|
|
id,
|
|
displayName: workflowValue,
|
|
kind: z.enum(["gate-matrix", "gate-single", "immutable", "provider", "promotion"]),
|
|
needs: z.array(id).max(32),
|
|
condition: z.enum(["always", "needs-success", "merge", "release", "production", "field", "documentation"]),
|
|
timeoutMinutes: z.number().int().positive(),
|
|
gateIds: z.array(id).max(64),
|
|
browserGateIds: z.array(id).max(64),
|
|
environment: z.array(environmentBindingSchema).max(32),
|
|
steps: z.array(jobStepSchema).min(1).max(64),
|
|
})
|
|
.strict();
|
|
|
|
const retentionSchema = z
|
|
.object({
|
|
durationStatus: nonEmpty,
|
|
classes: z.array(z.object({ id, policy: nonEmpty }).strict()).min(1).max(32),
|
|
})
|
|
.strict();
|
|
|
|
const ciGateContractBaseSchema = z
|
|
.object({
|
|
schemaVersion: z.literal(2),
|
|
providerAdapter: repositoryPath,
|
|
commands: z.array(commandSchema).min(1).max(256),
|
|
artifactSchemas: z.array(artifactSchemaSchema).min(1).max(128),
|
|
artifacts: z.array(artifactSchema).min(1).max(512),
|
|
gates: z.array(gateSchema).min(1).max(64),
|
|
stages: z.array(stageSchema).min(1).max(16),
|
|
jobs: z.array(jobSchema).min(1).max(32),
|
|
retention: retentionSchema,
|
|
})
|
|
.strict();
|
|
|
|
const ciGateContractSchema = ciGateContractBaseSchema.superRefine(
|
|
(contract, context) => validateContractSemantics(contract, context),
|
|
);
|
|
|
|
export type CiGateContract = z.infer<typeof ciGateContractSchema>;
|
|
export type CiGateCommand = CiGateContract["commands"][number];
|
|
export type CiGateArtifactSchema = CiGateContract["artifactSchemas"][number];
|
|
export type CiGateArtifact = CiGateContract["artifacts"][number];
|
|
export type CiGate = CiGateContract["gates"][number];
|
|
export type CiWorkflowJob = CiGateContract["jobs"][number];
|
|
export type CiWorkflowStep = CiWorkflowJob["steps"][number];
|
|
|
|
export type CiGateContractIndex = Readonly<{
|
|
commands: ReadonlyMap<string, CiGateCommand>;
|
|
artifactSchemas: ReadonlyMap<string, CiGateArtifactSchema>;
|
|
artifacts: ReadonlyMap<string, CiGateArtifact>;
|
|
gates: ReadonlyMap<string, CiGate>;
|
|
stages: ReadonlyMap<string, CiGateContract["stages"][number]>;
|
|
jobs: ReadonlyMap<string, CiWorkflowJob>;
|
|
retentionClasses: ReadonlyMap<string, CiGateContract["retention"]["classes"][number]>;
|
|
}>;
|
|
export type LoadCiGateContractOptions = Readonly<{
|
|
mode?: "canonical" | "removal-fixture";
|
|
}>;
|
|
|
|
const CANONICAL_GATE_SHAPE_SHA256 =
|
|
"4617ada21cbdeb217d118146bd572860d7c58ad222142a52d41916b26577239a";
|
|
|
|
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}`);
|
|
}
|
|
if (contract.commands.length !== 82 || commandReferenceCount !== 94) {
|
|
failures.push(
|
|
`command authority baseline must contain exactly 82 definitions and 94 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`,
|
|
);
|
|
}
|
|
if (contract.artifacts.length !== 107) {
|
|
failures.push(`artifact authority baseline must contain exactly 107 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 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;
|
|
}
|