Carries eight template commits: the provider sandbox actually running, release
admission to a named environment, the product feature manifest with its runtime
kill switch, architecture and documentation rules that match what is enforced,
the removability fixtures, and the browser, visual and performance evidence.
Product identity is unchanged. `package.json` keeps `tech-log-frontend` and the
catalog keeps the Tech Log naming; the home page was not in the delta. The
visual baselines are this product's own — the template's were excluded from the
transplant and these were regenerated here, where the only difference is the
platform overview's new product-feature section.
What this repository gains operationally: `config/runtime/{local,development,
staging,production}.json` with `FE-GATE-027` refusing an artifact whose runtime
document does not match the environment it is being admitted to, and
`FEATURE_OVERRIDES` for taking an installed feature out of service without a
rebuild.
Verified here: eight gates green, build green, visual 5/5, and 1,858 of 1,859
tests in the suites that do not need a sandbox — the one failure passes in
isolation and is a jsdom lazy-chunk timeout under parallel load. The provider
suites cannot run on this machine at all: `kernel.apparmor_restrict_unprivileged
_userns=1` makes `bwrap --unshare-net` fail, reproducible without any code from
either repository.
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;
|
|
}
|