Files
tech-log-frontend/scripts/contracts/ci-gates.ts
T
DongHyeonkaandClaude Opus 5 84d72c4f60 feat: 릴리즈 편집을 자기 주소로 옮긴다
편집 폼이 릴리즈 목록 아래에 열렸다. 릴리즈가 열 개 스무 개로 늘면 편집하려고 목록
전체를 지나 내려가야 하고, 저장 버튼은 그보다 더 아래에 있다. 목록이 길어질수록 편집이
멀어지는 구조다.

`/studio/releases/:id` 를 연다 — 문서와 프로젝트가 각자 편집 주소를 갖는 것과 같은
이유다. 목록의 "편집" 은 토글이 아니라 링크가 되고, 새 릴리즈를 만들면 곧바로 그 화면으로
간다(만들자마자 편집할 것이 분명하다).

라우트가 하나 늘어 CI 게이트가 함께 움직였다 — 아티팩트 기준선 133→134, 증거 개수
112→113, 게이트 형태 다이제스트 재계산(f9e7e521… 을 이전 gates.json 에서 먼저 재현해
계산 방법을 확인했다), 서빙 패턴 하나 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 01:41:04 +09:00

1316 lines
56 KiB
TypeScript

import { readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import path from "node:path";
import { z } from "zod";
import { PROMOTION_FORMULA } from "../../src/application/policies/promotion-readiness.ts";
import { MANUAL_A11Y_ROUTE_IDS } from "../lib/manual-a11y-evidence.ts";
import {
RELEASE_CANDIDATE_EVIDENCE_PATHS,
RELEASE_CANDIDATE_MANIFEST_PATH,
} from "../lib/release-candidate.ts";
import {
validateInstallScriptPolicy,
validateNpmScopeEnvironment,
validatePackageScriptGraph,
} from "../lib/package-script-graph.ts";
import { PROMOTED_UPLOAD_PATHS } from "./promotion-artifacts.ts";
const ciActionRegistrationSchema = z
.object({
repository: z
.string()
.regex(
/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u,
"CI action repository must be an absolute upstream GitHub URL",
),
revision: z
.string()
.regex(/^[0-9a-f]{40}$/u, "CI action revision must be a full 40-hex commit SHA"),
version: z.string().min(1).max(64),
})
.strict()
.readonly();
const ciActionRegistrySchema = z
.object({
checkout: ciActionRegistrationSchema,
setupNode: ciActionRegistrationSchema,
uploadArtifact: ciActionRegistrationSchema,
downloadArtifact: ciActionRegistrationSchema,
})
.strict()
.readonly();
export type CiActionRegistry = z.infer<typeof ciActionRegistrySchema>;
export type CiActionId = keyof CiActionRegistry;
export function parseCiActionRegistry(value: unknown): CiActionRegistry {
const result = ciActionRegistrySchema.safeParse(value);
if (!result.success) {
const diagnostic = result.error.issues
.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`)
.join("\n");
throw new TypeError(`CI action registry invalid:\n${diagnostic}`);
}
return result.data;
}
export const CI_ACTION_REGISTRY = parseCiActionRegistry({
checkout: {
repository: "https://github.com/actions/checkout",
revision: "34e114876b0b11c390a56381ad16ebd13914f8d5",
version: "v4.3.1",
},
setupNode: {
repository: "https://github.com/actions/setup-node",
revision: "49933ea5288caeca8642d1e84afbd3f7d6820020",
version: "v4.4.0",
},
uploadArtifact: {
repository: "https://github.com/ChristopherHX/gitea-upload-artifact",
revision: "81f940d004763f986ba3582c007fd842dd5cb0d7",
version: "v4 branch",
},
downloadArtifact: {
repository: "https://github.com/ChristopherHX/gitea-download-artifact",
revision: "75635f32b4c1c41c4b3d64e8f85210112ed4c9c7",
version: "v4 branch",
},
});
const actionIdByStepKind = Object.freeze({
checkout: "checkout",
"setup-node": "setupNode",
upload: "uploadArtifact",
download: "downloadArtifact",
} as const satisfies Readonly<Record<string, CiActionId>>);
export function resolveCiActionUses(actionId: CiActionId): string {
const action = CI_ACTION_REGISTRY[actionId];
if (!action) throw new TypeError(`unknown CI action: ${String(actionId)}`);
return `${action.repository}@${action.revision}`;
}
export function resolveCiStepActionUses(stepKind: string): string | null {
const actionId = actionIdByStepKind[stepKind as keyof typeof actionIdByStepKind];
return actionId ? resolveCiActionUses(actionId) : null;
}
function hasAsciiControl(value: string): boolean {
return [...value].some((character) => {
const codePoint = character.codePointAt(0)!;
return codePoint <= 0x1f || codePoint === 0x7f;
});
}
function hasForbiddenLineOrControl(value: string): boolean {
return [...value].some((character) => {
const codePoint = character.codePointAt(0)!;
return (
codePoint <= 0x1f ||
codePoint === 0x7f ||
codePoint === 0x85 ||
codePoint === 0x2028 ||
codePoint === 0x2029
);
});
}
const nonEmpty = z
.string()
.min(1)
.max(4_096)
.refine((value) => !hasForbiddenLineOrControl(value), "control and Unicode line-break characters are forbidden")
.refine((value) => value === value.trim(), "leading/trailing whitespace is forbidden");
const id = nonEmpty.max(128).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u);
const repositoryPath = nonEmpty.superRefine((value, context) => {
if (
value.includes("\\") ||
value.includes("\0") ||
path.posix.isAbsolute(value) ||
path.posix.normalize(value) !== value ||
value === "." ||
value === ".." ||
value.startsWith("../") ||
value.includes("/../")
) {
context.addIssue({ code: "custom", message: `unsafe repository path: ${value}` });
}
});
const workflowValue = nonEmpty.refine(
(value) => !value.includes("\0") && !value.includes("\r"),
"workflow values must not contain NUL or carriage returns",
);
const workflowPath = workflowValue.superRefine((value, context) => {
if (
value.includes("\\") ||
path.posix.isAbsolute(value) ||
path.posix.normalize(value) !== value ||
value === "." ||
value === ".." ||
value.startsWith("../") ||
value.includes("/../")
) {
context.addIssue({ code: "custom", message: `unsafe workflow path: ${value}` });
}
});
const commandSchema = z
.object({
id,
script: nonEmpty.regex(/^[A-Za-z0-9:_-]+$/u),
args: z.array(z.string().max(512).refine((value) => !hasAsciiControl(value), "command arguments contain controls")).max(32).optional(),
timeoutMs: z.number().int().min(1_000).max(3_600_000).optional(),
expect: z.enum(["pass", "fail"]),
expectedExitCode: z.number().int().min(1).max(255).optional(),
expectedDiagnosticId: nonEmpty.max(256).optional(),
})
.strict()
.superRefine((command, context) => {
const hasNegativeIdentity =
command.expectedExitCode !== undefined ||
command.expectedDiagnosticId !== undefined;
if (command.expect === "pass" && hasNegativeIdentity) {
context.addIssue({
code: "custom",
message: `pass command carries negative identity: ${command.id}`,
});
}
if (
command.expect === "fail" &&
(command.expectedExitCode === undefined || !command.expectedDiagnosticId)
) {
context.addIssue({
code: "custom",
message: `negative command lacks exact identity: ${command.id}`,
});
}
if (
command.expectedDiagnosticId &&
/[\n\r\0]/u.test(command.expectedDiagnosticId)
) {
context.addIssue({
code: "custom",
message: `negative command diagnostic is unsafe: ${command.id}`,
});
}
});
const artifactSchemaSchema = z.discriminatedUnion("kind", [
z.object({ id, kind: z.literal("text"), maxBytes: z.number().int().min(1).max(268_435_456) }).strict(),
z
.object({
id,
kind: z.literal("json"),
maxBytes: z.number().int().min(1).max(268_435_456),
executableSchemaId: z.enum([
"automated-a11y",
"manual-a11y",
"architecture-dependency-report",
"design-system-contract",
"i18n-contract",
"diagnostics-contract",
"realtime-boundaries",
"optional-recipes",
"optional-recipe-fixtures",
"registry-compatibility-fixtures",
"reproducible-build",
"supply-chain-fixtures",
"supply-chain-provider-fixtures",
"compatibility-fixtures",
"documentation-review",
"hosting-headers",
"coverage-summary-v8",
"risk-coverage-v3",
"build-manifest",
"module-inventory",
"dependency-inventory",
"registry-snapshot",
"registry-governance-run",
"bundle-performance",
"sbom",
"provenance",
"dependency-diff",
"license-report",
"vulnerability-report",
"field-web-vitals",
"lab-performance",
"release-verification",
"runbook-record",
"supply-chain-verification",
"release-candidate",
"supply-chain-coherence",
"http-scenario-receipt",
"test-evidence-report",
"provider-vulnerability",
"provider-provenance",
"provider-verification",
"ci-contract-report",
"deployment-admission",
]),
})
.strict(),
z
.object({
id,
kind: z.literal("json-schema"),
maxBytes: z.number().int().min(1).max(268_435_456),
})
.strict(),
z.object({ id, kind: z.literal("junit"), maxBytes: z.number().int().min(1).max(268_435_456) }).strict(),
z.object({ id, kind: z.literal("html"), maxBytes: z.number().int().min(1).max(268_435_456) }).strict(),
z.object({ id, kind: z.literal("markdown"), maxBytes: z.number().int().min(1).max(268_435_456) }).strict(),
z.object({ id, kind: z.literal("sarif"), maxBytes: z.number().int().min(1).max(268_435_456) }).strict(),
z
.object({ id, kind: z.literal("candidate-archive"), maxBytes: z.number().int().min(1).max(268_435_456) })
.strict(),
]);
const artifactBaseShape = { id, path: repositoryPath, schemaId: id } as const;
const artifactSchema = z.discriminatedUnion("production", [
z.object({ ...artifactBaseShape, production: z.literal("source-controlled") }).strict(),
z
.object({
...artifactBaseShape,
production: z.literal("command-generated"),
producerCommandIds: z.array(id).min(1).max(32),
})
.strict(),
z.object({ ...artifactBaseShape, production: z.literal("runner-generated") }).strict(),
]);
const gateSchema = z
.object({
id: z.string().regex(/^FE-GATE-\d{3}$/u),
name: nonEmpty.regex(/^[a-z0-9][a-z0-9-]*$/u),
commandIds: z.array(id).min(1).max(128),
logArtifactId: id,
evidenceArtifactIds: z.array(id).min(1).max(128),
retentionClassId: id,
requiresEnvironment: z.array(z.string().max(128).regex(/^[A-Z][A-Z0-9_]*$/u)).max(32).optional(),
})
.strict();
const stageSchema = z
.object({
id,
readiness: z.enum([
"MERGE_READY",
"RELEASE_READY",
"PROD_PROMOTION_READY",
"FIELD_SLO_READY",
"DOCUMENTATION_READY",
]),
needs: z.array(id).max(16),
gateIds: z.array(id).min(1).max(64),
})
.strict();
const environmentBindingSchema = z
.object({ name: z.string().regex(/^[A-Z][A-Z0-9_]*$/u), value: workflowValue })
.strict();
const checkoutStep = z.object({ kind: z.literal("checkout") }).strict();
const setupNodeStep = z.object({ kind: z.literal("setup-node") }).strict();
const frozenInstallStep = z.object({ kind: z.literal("frozen-install") }).strict();
const browserInstallStep = z.object({ kind: z.literal("browser-install") }).strict();
const runGateStep = z.object({ kind: z.literal("run-gate") }).strict();
const archiveCandidateStep = z
.object({
kind: z.literal("archive-candidate"),
stepId: id,
archivePath: workflowPath,
members: z.array(repositoryPath).min(1).max(128),
archiveOutputName: id,
distOutputName: id,
})
.strict();
const uploadStep = z
.object({
kind: z.literal("upload"),
transferId: id,
name: workflowValue,
paths: z.array(workflowPath).min(1).max(128),
always: z.boolean().optional(),
})
.strict();
const downloadStep = z
.object({ kind: z.literal("download"), transferId: id, path: workflowPath })
.strict();
const extractStep = z
.object({
kind: z.literal("extract"),
archivePath: workflowPath,
targetRoot: workflowPath,
})
.strict();
const providerStep = z
.object({
kind: z.literal("run-provider"),
provider: z.enum(["vulnerability", "provenance"]),
stepId: id,
})
.strict();
const validateProviderStep = z
.object({
kind: z.literal("validate-provider-evidence"),
provider: z.enum(["vulnerability", "provenance"]),
})
.strict();
const promotionStep = z
.object({ kind: z.literal("verify-promotion"), stepId: id })
.strict();
const cleanupPromotionStep = z
.object({ kind: z.literal("cleanup-promotion"), finalizerStepId: id })
.strict();
const jobStepSchema = z.discriminatedUnion("kind", [
checkoutStep,
setupNodeStep,
frozenInstallStep,
browserInstallStep,
runGateStep,
archiveCandidateStep,
uploadStep,
downloadStep,
extractStep,
providerStep,
validateProviderStep,
promotionStep,
cleanupPromotionStep,
]);
const jobSchema = z
.object({
id,
displayName: workflowValue,
kind: z.enum(["gate-matrix", "gate-single", "immutable", "provider", "promotion"]),
needs: z.array(id).max(32),
condition: z.enum(["always", "needs-success", "merge", "release", "production", "field", "documentation"]),
timeoutMinutes: z.number().int().positive(),
gateIds: z.array(id).max(64),
browserGateIds: z.array(id).max(64),
environment: z.array(environmentBindingSchema).max(32),
steps: z.array(jobStepSchema).min(1).max(64),
})
.strict();
const retentionSchema = z
.object({
durationStatus: nonEmpty,
classes: z.array(z.object({ id, policy: nonEmpty }).strict()).min(1).max(32),
})
.strict();
const ciGateContractBaseSchema = z
.object({
schemaVersion: z.literal(2),
providerAdapter: repositoryPath,
commands: z.array(commandSchema).min(1).max(256),
artifactSchemas: z.array(artifactSchemaSchema).min(1).max(128),
artifacts: z.array(artifactSchema).min(1).max(512),
gates: z.array(gateSchema).min(1).max(64),
stages: z.array(stageSchema).min(1).max(16),
jobs: z.array(jobSchema).min(1).max(32),
retention: retentionSchema,
})
.strict();
const ciGateContractSchema = ciGateContractBaseSchema.superRefine(
(contract, context) => validateContractSemantics(contract, context),
);
export type CiGateContract = z.infer<typeof ciGateContractSchema>;
export type CiGateCommand = CiGateContract["commands"][number];
export type CiGateArtifactSchema = CiGateContract["artifactSchemas"][number];
export type CiGateArtifact = CiGateContract["artifacts"][number];
export type CiGate = CiGateContract["gates"][number];
export type CiWorkflowJob = CiGateContract["jobs"][number];
export type CiWorkflowStep = CiWorkflowJob["steps"][number];
export type CiGateContractIndex = Readonly<{
commands: ReadonlyMap<string, CiGateCommand>;
artifactSchemas: ReadonlyMap<string, CiGateArtifactSchema>;
artifacts: ReadonlyMap<string, CiGateArtifact>;
gates: ReadonlyMap<string, CiGate>;
stages: ReadonlyMap<string, CiGateContract["stages"][number]>;
jobs: ReadonlyMap<string, CiWorkflowJob>;
retentionClasses: ReadonlyMap<string, CiGateContract["retention"]["classes"][number]>;
}>;
export type LoadCiGateContractOptions = Readonly<{
mode?: "canonical" | "removal-fixture";
}>;
const CANONICAL_GATE_SHAPE_SHA256 =
// Template merge. Both sides carried a digest of their own gate set; neither
// describes the merged one. Recomputed from the merged config/ci/gates.json.
// Task 11: recomputed again after FE-GATE-009 gained the
// TECH_LOG_STUDIO_ASSETS manual accessibility evidence artifact.
// Final fix wave item 1: recomputed again after FE-GATE-010 gained
// `check-tech-log-contract`. Recomputed with `canonicalGateShapeSha256`
// below, verified by first reproducing the previous constant from the
// previous `config/ci/gates.json` before hashing the new one.
// Alignment follow-up item 2: recomputed again after FE-GATE-007 gained
// `test-tech-log` and its junit evidence, by the same method — the previous
// constant f3cc9075… was reproduced from the previous gates.json first, so
// the transcription that produced this value is known to be the real one.
// Dev release manifest drift fix, item 2: recomputed again after FE-GATE-010
// gained `check-dev-release-manifest`. Same method — 98d19911… was first
// reproduced from the previous gates.json before this value was hashed.
// Taxonomy route: recomputed again after FE-GATE-009 gained the
// TECH_LOG_STUDIO_TAXONOMY manual accessibility evidence artifact. The gate
// lists one evidence artifact per installed route and refuses a set that does
// not match the route scope exactly, so adding a route necessarily moves this
// digest — that is the point of pinning it.
// Release authoring: recomputed again after FE-GATE-009 gained the
// TECH_LOG_STUDIO_RELEASES evidence artifact, by the same method — 8c73d447…
// was first reproduced from the previous gates.json, so the computation that
// produced this value is known to be the one the constant was pinned under.
// 프로젝트 편집 화면: FE-GATE-009 가 TECH_LOG_STUDIO_PROJECT_EDIT 증거 아티팩트를
// 얻어 다시 계산했다. 같은 방법이다 — 187dbd96… 을 이전 gates.json 에서 먼저 재현해,
// 이 값을 만든 계산이 상수가 고정될 때 쓰인 그 계산임을 확인했다.
// 릴리즈 편집 화면: 같은 방법으로 다시 계산했다. f9e7e521… 을 이전 gates.json 에서 먼저
// 재현했다.
"fb138e7c51fdf969f755cd8ff32cf627f1750b966d212c33ee996c8c578db0e3";
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
const normalized = gates.map(
({
id,
name,
commandIds,
logArtifactId,
evidenceArtifactIds,
retentionClassId,
requiresEnvironment,
}) => ({
id,
name,
commandIds,
logArtifactId,
evidenceArtifactIds,
retentionClassId,
requiresEnvironment: requiresEnvironment ?? [],
}),
);
return createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
}
function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[] {
const failures: string[] = [];
const commandReferenceCount = contract.gates.reduce(
(total, gate) => total + gate.commandIds.length,
0,
);
if (contract.gates.length !== 27) {
failures.push(`gate authority baseline must contain exactly 27 gates; received ${contract.gates.length}`);
}
// Final fix wave, item 1: FE-GATE-010 gained `check-tech-log-contract`, the
// drift gate that pins the vendored canonical Studio contract to its digest.
// Until it was referenced by a gate it ran only when typed by hand.
// Alignment follow-up, item 2: FE-GATE-007 gained `test-tech-log`. The suite
// already ran inside `test:coverage`'s combined vitest invocation, so a
// TechLog failure was reported as a coverage-gate failure with no junit of
// its own to name it.
// Dev release manifest drift fix, item 2: FE-GATE-010 gained
// `check-dev-release-manifest`. No gate read `public/*.json` at all, so a
// fixture that did not declare the compiled contract set broke `pnpm dev`
// outright while every static gate stayed green.
if (contract.commands.length !== 85 || commandReferenceCount !== 97) {
failures.push(
`command authority baseline must contain exactly 85 definitions and 97 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`,
);
}
// Template merge. 126 product artifacts plus the two the template added.
// Task 11 added one more: the TECH_LOG_STUDIO_ASSETS manual a11y evidence file.
// Alignment follow-up, item 2 added the TechLog junit report.
// The taxonomy route added its own manual a11y evidence file — every installed
// route carries one, and the gate checks that the two sets match exactly.
// The project edit route did the same: it is what finally lets a project carry
// a purpose, a current objective, and a next step, so the public screens that
// read those fields stop rendering blanks.
// The release edit route followed: the editor used to open below the release
// list, so editing meant scrolling past every release to reach it.
if (contract.artifacts.length !== 134) {
failures.push(`artifact authority baseline must contain exactly 134 artifacts; received ${contract.artifacts.length}`);
}
if (contract.stages.length !== 5) {
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
}
const expectedRetentionClasses = [
{ id: "merge-cycle", policy: "at least through pull-request readiness decision" },
{ id: "release-coherence", policy: "at least until the next release is promoted" },
{ id: "prod-drill", policy: "at least until the next production promotion decision" },
{ id: "field", policy: "through the 28-day window and aggregation" },
{ id: "documentation", policy: "through documentation readiness review" },
];
if (JSON.stringify(contract.retention.classes) !== JSON.stringify(expectedRetentionClasses)) {
failures.push("retention registry must contain exactly the five canonical retention classes");
}
return failures;
}
export function parseCiGateContract(
value: unknown,
options: LoadCiGateContractOptions = {},
): CiGateContract {
const result = ciGateContractSchema.safeParse(value);
if (!result.success) {
const diagnostic = result.error.issues
.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`)
.join("\n");
throw new TypeError(`CI gate contract invalid:\n${diagnostic}`);
}
if ((options.mode ?? defaultCiContractMode()) === "canonical") {
const failures = canonicalAuthorityBaselineFailures(result.data);
if (failures.length > 0) {
throw new TypeError(`CI gate contract invalid:\n${failures.map((failure) => `root: ${failure}`).join("\n")}`);
}
}
return result.data;
}
/**
* A removal fixture runs the whole suite against a deliberately *reduced* CI
* contract: the removed capability's gates, commands and artifacts are pruned.
* Loading that contract in canonical mode re-imposes the full exact-count
* authority on it, so the fixture failed on the very reduction it exists to
* prove. `runRemovalFixturePnpm` marks those runs, and this is where the mark
* is honoured.
*/
export function defaultCiContractMode(): "canonical" | "removal-fixture" {
return process.env.CI_CONTRACT_MODE === "removal-fixture"
? "removal-fixture"
: "canonical";
}
export function isReducedCiContractRun(): boolean {
return defaultCiContractMode() === "removal-fixture";
}
export async function loadCiGateContract(
root = process.cwd(),
options: LoadCiGateContractOptions = {},
): Promise<CiGateContract> {
const mode = options.mode ?? defaultCiContractMode();
const [rawContract, rawPackage] = await Promise.all([
readFile(path.join(root, "config/ci/gates.json"), "utf8"),
readFile(path.join(root, "package.json"), "utf8"),
]);
const contract = parseCiGateContract(JSON.parse(rawContract), { mode });
if (
mode === "canonical" &&
canonicalGateShapeSha256(contract.gates) !== CANONICAL_GATE_SHAPE_SHA256
) {
throw new TypeError("CI gate contract canonical gate semantic shape drift");
}
const packageDocument = z
.object({ scripts: z.record(z.string(), z.string()).default({}) })
.passthrough()
.parse(JSON.parse(rawPackage));
const missing = contract.commands
.map((command) => command.script)
.filter((script, index, scripts) => scripts.indexOf(script) === index)
.filter((script) => !packageDocument.scripts[script]);
if (missing.length > 0) {
throw new TypeError(`CI gate contract missing package scripts: ${missing.join(", ")}`);
}
const expectedCheckCi = mode === "canonical"
? "corepack pnpm check:artifact-schemas && node scripts/check-ci-contract.ts && corepack pnpm check:ci-workflow"
: "corepack pnpm check:artifact-schemas && node scripts/check-ci-contract.ts --reduced-removal-fixture && corepack pnpm check:ci-workflow";
if (packageDocument.scripts["check:ci"] !== expectedCheckCi) {
throw new TypeError("check:ci must use the exact canonical non-recursive orchestration");
}
const canonicalCheckCiDependencies = {
"check:artifact-schemas": "node scripts/generate-artifact-schemas.ts --check",
"check:ci-workflow": mode === "canonical"
? "node scripts/generate-ci-workflow.ts --check"
: "node scripts/generate-ci-workflow.ts --check --reduced-removal-fixture",
} as const;
for (const [script, expected] of Object.entries(canonicalCheckCiDependencies)) {
if (packageDocument.scripts[script] !== expected) {
throw new TypeError(`canonical check:ci dependency drift: ${script}`);
}
}
const contractEntryScripts = [
...new Set(contract.commands.map((command) => command.script)),
];
const graphFailures = [
...validatePackageScriptGraph(packageDocument.scripts, "check:ci"),
...contractEntryScripts.flatMap((script) =>
validatePackageScriptGraph(packageDocument.scripts, script)
),
];
if (graphFailures.length > 0) {
throw new TypeError(
`CI package script graph invalid:\n${[...new Set(graphFailures)].join("\n")}`,
);
}
const installPolicyFailures = validateInstallScriptPolicy(
packageDocument.scripts,
contractEntryScripts,
);
if (installPolicyFailures.length > 0) {
throw new TypeError(
`CI package script install policy invalid:\n${installPolicyFailures.join("\n")}`,
);
}
return contract;
}
export async function withCiGatePreflight<Result>(
root: string,
gateId: string | undefined,
execute: (context: Readonly<{
contract: CiGateContract;
contractIndex: CiGateContractIndex;
gateId: string;
gate: CiGate;
}>) => Promise<Result> | Result,
): Promise<Result> {
const npmScopeEnvironmentFailures = validateNpmScopeEnvironment(process.env);
if (npmScopeEnvironmentFailures.length > 0) {
throw new TypeError(
`CI runner npm scope environment invalid:\n${npmScopeEnvironmentFailures.join("\n")}`,
);
}
const contract = await loadCiGateContract(root);
const contractIndex = indexCiGateContract(contract);
const gate = gateId ? contractIndex.gates.get(gateId) : undefined;
if (!gateId || !gate) {
throw new TypeError("CI gate id must be FE-GATE-001..FE-GATE-026");
}
return execute({ contract, contractIndex, gateId, gate });
}
export function indexCiGateContract(contract: CiGateContract): CiGateContractIndex {
return Object.freeze({
commands: new Map(contract.commands.map((entry) => [entry.id, entry])),
artifactSchemas: new Map(contract.artifactSchemas.map((entry) => [entry.id, entry])),
artifacts: new Map(contract.artifacts.map((entry) => [entry.id, entry])),
gates: new Map(contract.gates.map((entry) => [entry.id, entry])),
stages: new Map(contract.stages.map((entry) => [entry.id, entry])),
jobs: new Map(contract.jobs.map((entry) => [entry.id, entry])),
retentionClasses: new Map(contract.retention.classes.map((entry) => [entry.id, entry])),
});
}
function validateContractSemantics(
contract: z.infer<typeof ciGateContractBaseSchema>,
context: z.RefinementCtx,
): void {
const issue = (message: string, path: PropertyKey[] = []) =>
context.addIssue({ code: "custom", message, path });
if (contract.providerAdapter !== ".gitea/workflows/quality-gates.yml") {
issue("providerAdapter must target the canonical generated workflow");
}
const registries = [
["command", contract.commands],
["artifact schema", contract.artifactSchemas],
["artifact", contract.artifacts],
["gate", contract.gates],
["stage", contract.stages],
["job", contract.jobs],
["retention class", contract.retention.classes],
] as const;
for (const [label, entries] of registries) {
const seen = new Set<string>();
for (const entry of entries) {
if (seen.has(entry.id)) issue(`duplicate ${label} id: ${entry.id}`);
seen.add(entry.id);
}
}
const commandTuples = new Map<string, string>();
for (const command of contract.commands) {
const { id: _id, ...tuple } = command;
const key = JSON.stringify(tuple);
const previous = commandTuples.get(key);
if (previous) issue(`duplicate command tuple: ${previous}, ${command.id}`);
commandTuples.set(key, command.id);
}
const artifactPaths = new Map<string, string>();
for (const artifact of contract.artifacts) {
const previous = artifactPaths.get(artifact.path);
if (previous) issue(`duplicate artifact path: ${previous}, ${artifact.id}`);
artifactPaths.set(artifact.path, artifact.id);
}
const commandIds = new Set(contract.commands.map(({ id }) => id));
const schemaIds = new Set(contract.artifactSchemas.map(({ id }) => id));
const artifactIds = new Set(contract.artifacts.map(({ id }) => id));
const gateIds = new Set(contract.gates.map(({ id }) => id));
const stageIds = new Set(contract.stages.map(({ id }) => id));
const jobIds = new Set(contract.jobs.map(({ id }) => id));
const retentionIds = new Set(contract.retention.classes.map(({ id }) => id));
for (const artifact of contract.artifacts) {
if (!schemaIds.has(artifact.schemaId)) {
issue(`unknown artifact schema ${artifact.schemaId} for ${artifact.id}`);
}
if (artifact.production === "command-generated") {
if (new Set(artifact.producerCommandIds).size !== artifact.producerCommandIds.length) {
issue(`duplicate producer command reference for artifact: ${artifact.id}`);
}
for (const producerCommandId of artifact.producerCommandIds) {
if (!commandIds.has(producerCommandId)) {
issue(`unknown producer command ${producerCommandId} for ${artifact.id}`);
}
}
}
}
for (const gate of contract.gates) {
if (new Set(gate.commandIds).size !== gate.commandIds.length) {
issue(`duplicate command reference within gate: ${gate.id}`);
}
if (new Set(gate.evidenceArtifactIds).size !== gate.evidenceArtifactIds.length) {
issue(`duplicate artifact reference within gate: ${gate.id}`);
}
for (const commandId of gate.commandIds) {
if (!commandIds.has(commandId)) issue(`unknown command ${commandId} for ${gate.id}`);
}
for (const artifactId of [gate.logArtifactId, ...gate.evidenceArtifactIds]) {
if (!artifactIds.has(artifactId)) issue(`unknown artifact ${artifactId} for ${gate.id}`);
}
const logArtifact = contract.artifacts.find(({ id }) => id === gate.logArtifactId);
if (logArtifact && logArtifact.production !== "runner-generated") {
issue(`gate log must be runner-generated: ${gate.id}`);
}
for (const artifactId of gate.evidenceArtifactIds) {
const artifact = contract.artifacts.find(({ id }) => id === artifactId);
if (
artifact?.production === "command-generated" &&
!artifact.producerCommandIds.some((producerCommandId) =>
gate.commandIds.includes(producerCommandId)
)
) {
issue(`gate lacks a bound producer command for ${artifact.id}: ${gate.id}`);
}
}
if (!retentionIds.has(gate.retentionClassId)) {
issue(`unknown retention class ${gate.retentionClassId} for ${gate.id}`);
}
}
const accessibilityGate = contract.gates.find(
({ id: gateId }) => gateId === "FE-GATE-009",
);
const manualAccessibilityArtifacts = MANUAL_A11Y_ROUTE_IDS.map((routeId) => ({
id: `artifact-artifacts-tests-a11y-manual-${routeId.replaceAll("_", "-")}-md`,
path: `artifacts/tests/a11y-manual/${routeId}.md`,
}));
const expectedAccessibilityEvidenceArtifactIds = [
"artifact-artifacts-tests-a11y-json",
...manualAccessibilityArtifacts.map(({ id: artifactId }) => artifactId),
"artifact-artifacts-tests-a11y-manual-report-json",
];
if (
!accessibilityGate ||
JSON.stringify(accessibilityGate.evidenceArtifactIds) !==
JSON.stringify(expectedAccessibilityEvidenceArtifactIds)
) {
issue(
"FE-GATE-009 manual accessibility evidence must exactly match the installed route scope",
);
}
for (const expected of manualAccessibilityArtifacts) {
const artifact = contract.artifacts.find(
({ id: artifactId }) => artifactId === expected.id,
);
if (
!artifact ||
artifact.path !== expected.path ||
artifact.schemaId !== "markdown" ||
artifact.production !== "source-controlled"
) {
issue(
`FE-GATE-009 manual accessibility artifact registration is invalid: ${expected.id}`,
);
}
}
const referencedRetentionClasses = new Set(
contract.gates.map(({ retentionClassId }) => retentionClassId),
);
for (const retentionClass of contract.retention.classes) {
if (!referencedRetentionClasses.has(retentionClass.id)) {
issue(`orphan retention class: ${retentionClass.id}`);
}
}
const referencedCommands = new Set(contract.gates.flatMap(({ commandIds }) => commandIds));
for (const command of contract.commands) {
if (!referencedCommands.has(command.id)) issue(`orphan command: ${command.id}`);
}
const referencedArtifacts = new Set(
contract.gates.flatMap((gate) => [gate.logArtifactId, ...gate.evidenceArtifactIds]),
);
for (const artifact of contract.artifacts) {
if (!referencedArtifacts.has(artifact.id)) issue(`orphan artifact: ${artifact.id}`);
}
const referencedSchemas = new Set(contract.artifacts.map(({ schemaId }) => schemaId));
for (const schema of contract.artifactSchemas) {
if (!referencedSchemas.has(schema.id)) issue(`orphan artifact schema: ${schema.id}`);
}
for (const stage of contract.stages) {
for (const dependency of stage.needs) {
if (dependency === stage.id) issue(`stage self dependency: ${stage.id}`);
else if (!stageIds.has(dependency)) issue(`unknown stage dependency ${dependency} for ${stage.id}`);
}
for (const gateId of stage.gateIds) {
if (!gateIds.has(gateId)) issue(`unknown gate ${gateId} for stage ${stage.id}`);
}
}
for (const cycle of findCycles(contract.stages.map((stage) => [stage.id, stage.needs] as const))) {
issue(`stage dependency cycle: ${cycle.join(" -> ")}`);
}
const expectedGateIds = Array.from(
{ length: 27 },
(_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`,
);
if (JSON.stringify(contract.gates.map(({ id }) => id)) !== JSON.stringify(expectedGateIds)) {
issue("gate registry must contain FE-GATE-001..027 in canonical order");
}
const expectedStages: ReadonlyArray<readonly [string, string, readonly string[], readonly string[]]> = [
["merge", "MERGE_READY", [], PROMOTION_FORMULA.MERGE_READY],
["release", "RELEASE_READY", ["merge"], PROMOTION_FORMULA.RELEASE_READY],
["production", "PROD_PROMOTION_READY", ["release"], PROMOTION_FORMULA.PROD_PROMOTION_READY],
["field", "FIELD_SLO_READY", ["production"], PROMOTION_FORMULA.FIELD_SLO_READY],
["documentation", "DOCUMENTATION_READY", [], PROMOTION_FORMULA.DOCUMENTATION_READY],
];
const stageShape = contract.stages.map(({ id, readiness, needs, gateIds }) => [id, readiness, needs, gateIds]);
if (JSON.stringify(stageShape) !== JSON.stringify(expectedStages)) {
issue("stage formula/order/ownership drift");
}
const stageOwners = new Map<string, string[]>();
for (const stage of contract.stages) {
for (const gateId of stage.gateIds) {
stageOwners.set(gateId, [...(stageOwners.get(gateId) ?? []), stage.id]);
}
}
for (const gateId of expectedGateIds) {
if ((stageOwners.get(gateId) ?? []).length !== 1) issue(`gate must belong to exactly one stage: ${gateId}`);
}
const owners = new Map<string, string[]>();
for (const job of contract.jobs) {
for (const [label, values] of [
["needs", job.needs],
["gateIds", job.gateIds],
["browserGateIds", job.browserGateIds],
] as const) {
if (new Set(values).size !== values.length) {
issue(`duplicate ${label} reference in job: ${job.id}`);
}
}
if (job.timeoutMinutes !== 45) issue(`job timeout must be 45 minutes: ${job.id}`);
for (const dependency of job.needs) {
if (dependency === job.id) issue(`job self dependency: ${job.id}`);
else if (!jobIds.has(dependency)) issue(`unknown job dependency ${dependency} for ${job.id}`);
}
for (const gateId of job.gateIds) {
if (!gateIds.has(gateId)) issue(`unknown gate ${gateId} for job ${job.id}`);
owners.set(gateId, [...(owners.get(gateId) ?? []), job.id]);
}
for (const browserGateId of job.browserGateIds) {
if (!job.gateIds.includes(browserGateId)) {
issue(`browser gate ${browserGateId} is not owned by ${job.id}`);
}
}
validateJobStepKinds(job, issue);
for (const step of job.steps) {
const uses = resolveCiStepActionUses(step.kind);
if (uses && !/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+@[0-9a-f]{40}$/u.test(uses)) {
issue(`CI action ref is not an absolute upstream URL pinned to a full commit SHA: ${step.kind}`);
}
}
const envNames = new Set(job.environment.map(({ name }) => name));
for (const gateId of job.gateIds) {
const gate = contract.gates.find(({ id }) => id === gateId);
for (const required of gate?.requiresEnvironment ?? []) {
if (!envNames.has(required)) issue(`job ${job.id} lacks environment ${required} for ${gateId}`);
}
}
}
for (const gate of contract.gates) {
const gateOwners = owners.get(gate.id) ?? [];
if (gateOwners.length === 0) issue(`unowned gate: ${gate.id}`);
if (gateOwners.length > 1) issue(`multiply owned gate: ${gate.id} by ${gateOwners.join(", ")}`);
}
const expectedJobOwnership: Readonly<Record<string, readonly string[]>> = {
merge_gate: ["FE-GATE-001", "FE-GATE-002", "FE-GATE-003", "FE-GATE-004", "FE-GATE-005", "FE-GATE-006", "FE-GATE-007", "FE-GATE-008", "FE-GATE-009", "FE-GATE-010", "FE-GATE-011", "FE-GATE-013", "FE-GATE-020"],
release_gate: ["FE-GATE-012", "FE-GATE-014", "FE-GATE-019", "FE-GATE-026"],
immutable_build: ["FE-GATE-015", "FE-GATE-027"],
vulnerability_provider: [],
provenance_provider: [],
promotion: [],
production_gate: ["FE-GATE-016", "FE-GATE-021", "FE-GATE-022", "FE-GATE-023", "FE-GATE-024", "FE-GATE-025"],
field_gate: ["FE-GATE-018"],
documentation_gate: ["FE-GATE-017"],
};
if (JSON.stringify(contract.jobs.map(({ id }) => id)) !== JSON.stringify(Object.keys(expectedJobOwnership))) {
issue("job registry must contain the exact nine canonical jobs in semantic order");
}
for (const [jobId, gateIds] of Object.entries(expectedJobOwnership)) {
const job = contract.jobs.find(({ id }) => id === jobId);
if (!job || JSON.stringify(job.gateIds) !== JSON.stringify(gateIds)) {
issue(`exact gate execution ownership drift: ${jobId}`);
}
}
const expectedJobGraph: Readonly<Record<string, readonly [CiWorkflowJob["kind"], readonly string[], CiWorkflowJob["condition"]]>> = {
merge_gate: ["gate-matrix", [], "merge"],
release_gate: ["gate-matrix", ["merge_gate"], "release"],
immutable_build: ["immutable", ["release_gate"], "release"],
vulnerability_provider: ["provider", ["immutable_build"], "always"],
provenance_provider: ["provider", ["immutable_build"], "always"],
promotion: ["promotion", ["immutable_build", "vulnerability_provider", "provenance_provider"], "needs-success"],
production_gate: ["gate-matrix", ["promotion"], "production"],
field_gate: ["gate-single", ["production_gate"], "field"],
documentation_gate: ["gate-single", [], "documentation"],
};
for (const [jobId, [kind, needs, condition]] of Object.entries(expectedJobGraph)) {
const job = contract.jobs.find(({ id }) => id === jobId);
if (!job || job.kind !== kind || JSON.stringify(job.needs) !== JSON.stringify(needs) || job.condition !== condition) {
issue(`job graph drift: ${jobId}`);
}
}
const expectedStepKinds: Readonly<Record<string, readonly CiWorkflowStep["kind"][]>> = {
merge_gate: ["checkout", "setup-node", "frozen-install", "browser-install", "run-gate", "upload"],
release_gate: ["checkout", "setup-node", "frozen-install", "browser-install", "run-gate", "upload"],
immutable_build: ["checkout", "setup-node", "frozen-install", "run-gate", "archive-candidate", "upload"],
vulnerability_provider: ["checkout", "setup-node", "frozen-install", "download", "run-provider", "validate-provider-evidence", "upload"],
provenance_provider: ["checkout", "setup-node", "frozen-install", "download", "run-provider", "validate-provider-evidence", "upload"],
promotion: ["checkout", "setup-node", "frozen-install", "download", "download", "download", "verify-promotion", "upload", "cleanup-promotion"],
production_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"],
field_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"],
documentation_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"],
};
for (const [jobId, expected] of Object.entries(expectedStepKinds)) {
const actual = contract.jobs.find(({ id }) => id === jobId)?.steps.map(({ kind }) => kind);
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
issue(`canonical job step sequence drift: ${jobId}`);
}
}
const expectedEnvironmentBindings: Readonly<Record<string, readonly Readonly<{ name: string; value: string }> []>> = {
merge_gate: [],
release_gate: [{ name: "HOSTING_BASE_URL", value: "${{ vars.HOSTING_BASE_URL }}" }],
immutable_build: [
// FE-GATE-027 admits the built artifact to a named environment, so both
// the profile it was built from and the destination it is claimed for are
// declared inputs. An absent RELEASE_TARGET is a refusal, not a default.
{ name: "APP_PROFILE", value: "${{ vars.APP_PROFILE }}" },
{ name: "RELEASE_TARGET", value: "${{ vars.RELEASE_TARGET }}" },
],
vulnerability_provider: [
{ name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" },
{ name: "CANDIDATE_ARCHIVE_PATH", value: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" },
{ name: "CI_RUN_ID", value: "${{ gitea.run_id }}" },
{ name: "CI_RUN_ATTEMPT", value: "${{ gitea.run_attempt }}" },
{ name: "EXPECTED_SOURCE_REVISION", value: "${{ gitea.sha }}" },
{ name: "VULNERABILITY_PUBLIC_KEY_PATH", value: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}" },
{ name: "VULNERABILITY_KEY_ID", value: "${{ vars.VULNERABILITY_KEY_ID }}" },
{ name: "VULNERABILITY_PROVIDER_COMMAND", value: "${{ vars.VULNERABILITY_PROVIDER_COMMAND }}" },
{ name: "VULNERABILITY_REPORT_PATH", value: "provider-evidence/untrusted/vulnerability-report.json" },
{ name: "VALIDATED_PROVIDER_REPORT_PATH", value: "provider-evidence/vulnerability-report.json" },
],
provenance_provider: [
{ name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" },
{ name: "CANDIDATE_ARCHIVE_PATH", value: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" },
{ name: "CI_RUN_ID", value: "${{ gitea.run_id }}" },
{ name: "CI_RUN_ATTEMPT", value: "${{ gitea.run_attempt }}" },
{ name: "EXPECTED_SOURCE_REVISION", value: "${{ gitea.sha }}" },
{ name: "PROVENANCE_PUBLIC_KEY_PATH", value: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}" },
{ name: "PROVENANCE_KEY_ID", value: "${{ vars.PROVENANCE_KEY_ID }}" },
{ name: "PROVENANCE_PROVIDER_COMMAND", value: "${{ vars.PROVENANCE_PROVIDER_COMMAND }}" },
{ name: "PROVENANCE_ATTESTATION_PATH", value: "provider-evidence/untrusted/provenance-attestation.json" },
{ name: "VALIDATED_PROVIDER_REPORT_PATH", value: "provider-evidence/provenance-attestation.json" },
],
promotion: [
{ name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" },
{ name: "CANDIDATE_ARCHIVE_PATH", value: ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" },
{ name: "CI_RUN_ID", value: "${{ gitea.run_id }}" },
{ name: "CI_RUN_ATTEMPT", value: "${{ gitea.run_attempt }}" },
{ name: "VULNERABILITY_REPORT_PATH", value: "${{ gitea.workspace }}/.release/vulnerability/vulnerability-report.json" },
{ name: "PROVENANCE_ATTESTATION_PATH", value: "${{ gitea.workspace }}/.release/provenance/provenance-attestation.json" },
{ name: "VULNERABILITY_PUBLIC_KEY_PATH", value: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}" },
{ name: "VULNERABILITY_KEY_ID", value: "${{ vars.VULNERABILITY_KEY_ID }}" },
{ name: "PROVENANCE_PUBLIC_KEY_PATH", value: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}" },
{ name: "PROVENANCE_KEY_ID", value: "${{ vars.PROVENANCE_KEY_ID }}" },
{ name: "VULNERABILITY_INVOCATION_NONCE", value: "${{ needs.vulnerability_provider.outputs.invocation_nonce }}" },
{ name: "PROVENANCE_INVOCATION_NONCE", value: "${{ needs.provenance_provider.outputs.invocation_nonce }}" },
],
production_gate: [],
field_gate: [
{ name: "FIELD_WEB_VITALS_INPUT", value: "${{ vars.FIELD_WEB_VITALS_INPUT }}" },
{ name: "MIN_ELIGIBLE_SAMPLES", value: "${{ vars.MIN_ELIGIBLE_SAMPLES }}" },
],
documentation_gate: [],
};
for (const job of contract.jobs) {
const environmentNames = job.environment.map(({ name }) => name);
if (new Set(environmentNames).size !== environmentNames.length ||
JSON.stringify(job.environment) !== JSON.stringify(expectedEnvironmentBindings[job.id])) {
issue(`job environment binding drift: ${job.id}`);
}
}
if (owners.get("FE-GATE-015")?.[0] !== "immutable_build") {
issue("FE-GATE-015 must be owned only by immutable_build");
}
for (const job of contract.jobs.filter(({ kind }) => kind === "gate-matrix")) {
if (job.gateIds.includes("FE-GATE-015")) issue(`release matrix duplicates FE-GATE-015: ${job.id}`);
}
const browserGateIds = contract.jobs.flatMap(({ browserGateIds }) => browserGateIds).sort(asciiCompare);
if (JSON.stringify(browserGateIds) !== JSON.stringify(["FE-GATE-008", "FE-GATE-009", "FE-GATE-026"])) {
issue(`browser gate set drift: ${browserGateIds.join(",")}`);
}
for (const job of contract.jobs) {
const browserSteps = job.steps.filter(({ kind }) => kind === "browser-install").length;
const expected = job.browserGateIds.length > 0 ? 1 : 0;
if (browserSteps !== expected) issue(`browser install step drift: ${job.id}`);
}
for (const cycle of findCycles(contract.jobs.map((job) => [job.id, job.needs] as const))) {
issue(`job dependency cycle: ${cycle.join(" -> ")}`);
}
const uploads = new Map<string, { producer: string; step: z.infer<typeof uploadStep> }>();
for (const job of contract.jobs) {
for (const step of job.steps) {
if (step.kind !== "upload") continue;
const previous = uploads.get(step.transferId);
if (previous) issue(`duplicate upload transfer ${step.transferId}: ${previous.producer}, ${job.id}`);
uploads.set(step.transferId, { producer: job.id, step });
}
}
const dependencies = new Map(contract.jobs.map((job) => [job.id, job.needs]));
for (const job of contract.jobs) {
for (const step of job.steps) {
if (step.kind !== "download") continue;
const upload = uploads.get(step.transferId);
if (!upload) issue(`unknown download transfer ${step.transferId} for ${job.id}`);
else if (!isDependencyReachable(job.id, upload.producer, dependencies)) {
issue(`download producer ${upload.producer} is unreachable from ${job.id}`);
}
}
}
const promotion = contract.jobs.find(({ id }) => id === "promotion");
if (promotion) {
if (promotion.kind !== "promotion") issue("promotion job kind drift");
if (promotion.gateIds.length > 0 || promotion.steps.some(({ kind }) => kind === "run-gate")) {
issue("promotion job must not own or run a gate");
}
const forbidden = promotion.steps.filter(({ kind }) =>
["archive-candidate", "run-provider"].includes(kind),
);
if (forbidden.length > 0) issue("promotion job must not build or rebuild candidate bytes");
const order = promotion.steps.map(({ kind }) => kind);
const verificationIndex = order.indexOf("verify-promotion");
const uploadIndex = order.indexOf("upload");
if (verificationIndex < 0 || uploadIndex !== verificationIndex + 1) {
issue("promotion verification and upload must be immediately adjacent");
}
const upload = promotion.steps[uploadIndex];
if (upload?.kind === "upload" && upload.always) {
issue("promotion upload must not use always");
}
const cleanupIndex = order.indexOf("cleanup-promotion");
if (
order.includes("extract") ||
verificationIndex < order.lastIndexOf("download") ||
uploadIndex < verificationIndex ||
cleanupIndex !== uploadIndex + 1
) {
issue("promotion formula order must download, finalize, upload, then cleanup without extraction");
}
}
const immutable = contract.jobs.find(({ id }) => id === "immutable_build");
const archive = immutable?.steps.find(({ kind }) => kind === "archive-candidate");
const expectedArchiveMembers = ["dist", ...RELEASE_CANDIDATE_EVIDENCE_PATHS, RELEASE_CANDIDATE_MANIFEST_PATH];
if (
!archive ||
archive.kind !== "archive-candidate" ||
JSON.stringify(archive.members) !== JSON.stringify(expectedArchiveMembers)
) {
issue("immutable candidate archive member set drift");
}
if (
archive?.kind === "archive-candidate" &&
(archive.stepId !== "candidate" ||
archive.archivePath !== ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" ||
archive.archiveOutputName !== "archive_sha256" ||
archive.distOutputName !== "dist_sha256")
) {
issue("immutable candidate output identity drift");
}
const promotionUpload = promotion?.steps.find(
(step) => step.kind === "upload" && step.transferId === "promoted-release",
);
if (!promotionUpload || promotionUpload.kind !== "upload" || JSON.stringify(promotionUpload.paths) !== JSON.stringify(PROMOTED_UPLOAD_PATHS)) {
issue("promotion upload bundle must contain the exact five typed paths");
}
validateCanonicalStepFields(contract, issue);
}
function validateCanonicalStepFields(
contract: z.infer<typeof ciGateContractBaseSchema>,
issue: (message: string, path?: PropertyKey[]) => void,
): void {
const immutable = contract.jobs.find(({ id }) => id === "immutable_build");
const immutableArchive = immutable?.steps.find(({ kind }) => kind === "archive-candidate");
const immutableUpload = immutable?.steps.find(
(step) => step.kind === "upload" && step.transferId === "release-candidate",
);
if (
!immutableArchive ||
immutableArchive.kind !== "archive-candidate" ||
!immutableUpload ||
immutableUpload.kind !== "upload" ||
JSON.stringify(immutableUpload.paths) !== JSON.stringify([immutableArchive.archivePath])
) {
issue("immutable archive and upload fields must remain linked");
}
const providerExpectations = {
vulnerability_provider: {
provider: "vulnerability",
stepId: "supervise_vulnerability",
downloadPath: ".release/vulnerability-candidate",
archivePath: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz",
rawPath: "provider-evidence/untrusted/vulnerability-report.json",
rawName: "VULNERABILITY_REPORT_PATH",
sealedPath: "provider-evidence/vulnerability-report.json",
transferId: "vulnerability-provider-evidence",
},
provenance_provider: {
provider: "provenance",
stepId: "supervise_provenance",
downloadPath: ".release/provenance-candidate",
archivePath: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz",
rawPath: "provider-evidence/untrusted/provenance-attestation.json",
rawName: "PROVENANCE_ATTESTATION_PATH",
sealedPath: "provider-evidence/provenance-attestation.json",
transferId: "provenance-provider-evidence",
},
} as const;
for (const [jobId, expected] of Object.entries(providerExpectations)) {
const job = contract.jobs.find(({ id }) => id === jobId);
const environment = new Map(job?.environment.map(({ name, value }) => [name, value]));
const download = job?.steps.find(({ kind }) => kind === "download");
const runProvider = job?.steps.find(({ kind }) => kind === "run-provider");
const validateProvider = job?.steps.find(({ kind }) => kind === "validate-provider-evidence");
const upload = job?.steps.find(
(step) => step.kind === "upload" && step.transferId === expected.transferId,
);
if (
!download || download.kind !== "download" || download.transferId !== "release-candidate" || download.path !== expected.downloadPath ||
!runProvider || runProvider.kind !== "run-provider" || runProvider.provider !== expected.provider || runProvider.stepId !== expected.stepId ||
!validateProvider || validateProvider.kind !== "validate-provider-evidence" || validateProvider.provider !== expected.provider ||
environment.get("CANDIDATE_ARCHIVE_PATH") !== expected.archivePath ||
environment.get(expected.rawName) !== expected.rawPath ||
environment.get("VALIDATED_PROVIDER_REPORT_PATH") !== expected.sealedPath ||
!upload || upload.kind !== "upload" || JSON.stringify(upload.paths) !== JSON.stringify([expected.sealedPath])
) {
issue(`provider archive, extraction, evidence, and upload fields must remain linked: ${jobId}`);
}
}
const promotion = contract.jobs.find(({ id }) => id === "promotion");
const promotionDownloads = promotion?.steps.filter(({ kind }) => kind === "download");
const expectedDownloads = [
{ kind: "download", transferId: "release-candidate", path: ".release/candidate" },
{ kind: "download", transferId: "vulnerability-provider-evidence", path: ".release/vulnerability" },
{ kind: "download", transferId: "provenance-provider-evidence", path: ".release/provenance" },
];
const promotionFinalizer = promotion?.steps.find(({ kind }) => kind === "verify-promotion");
const promotionCleanup = promotion?.steps.find(({ kind }) => kind === "cleanup-promotion");
if (
JSON.stringify(promotionDownloads) !== JSON.stringify(expectedDownloads) ||
!promotionFinalizer ||
promotionFinalizer.kind !== "verify-promotion" ||
promotionFinalizer.stepId !== "finalize" ||
!promotionCleanup ||
promotionCleanup.kind !== "cleanup-promotion" ||
promotionCleanup.finalizerStepId !== "finalize"
) {
issue("promotion download fields and finalizer/cleanup step identities must remain linked");
}
}
function validateJobStepKinds(
job: z.infer<typeof jobSchema>,
issue: (message: string, path?: PropertyKey[]) => void,
): void {
const allowed: Readonly<Record<z.infer<typeof jobSchema>["kind"], ReadonlySet<string>>> = {
"gate-matrix": new Set(["checkout", "setup-node", "frozen-install", "browser-install", "run-gate", "upload"]),
"gate-single": new Set(["checkout", "setup-node", "frozen-install", "run-gate", "upload"]),
immutable: new Set(["checkout", "setup-node", "frozen-install", "run-gate", "archive-candidate", "upload"]),
provider: new Set(["checkout", "setup-node", "frozen-install", "download", "extract", "run-provider", "validate-provider-evidence", "upload"]),
promotion: new Set(["checkout", "setup-node", "frozen-install", "download", "verify-promotion", "upload", "cleanup-promotion"]),
};
for (const step of job.steps) {
if (!allowed[job.kind].has(step.kind)) {
issue(`step kind ${step.kind} is forbidden for ${job.kind} job ${job.id}`);
}
}
const kinds = job.steps.map(({ kind }) => kind);
if (job.kind === "provider") {
const providerIndex = kinds.indexOf("run-provider");
const validateProviderIndex = kinds.indexOf("validate-provider-evidence");
const uploadIndex = kinds.indexOf("upload");
if (
providerIndex < kinds.lastIndexOf("download") ||
validateProviderIndex < providerIndex ||
uploadIndex < validateProviderIndex
) {
issue(`provider execution/validation/upload order drift: ${job.id}`);
}
}
}
function findCycles(entries: readonly (readonly [string, readonly string[]])[]): string[][] {
const graph = new Map(entries);
const visiting = new Set<string>();
const visited = new Set<string>();
const stack: string[] = [];
const cycles: string[][] = [];
const visit = (node: string): void => {
if (visiting.has(node)) {
const start = stack.indexOf(node);
cycles.push([...stack.slice(start), node]);
return;
}
if (visited.has(node) || !graph.has(node)) return;
visiting.add(node);
stack.push(node);
for (const next of graph.get(node) ?? []) visit(next);
stack.pop();
visiting.delete(node);
visited.add(node);
};
for (const node of graph.keys()) visit(node);
return cycles;
}
function isDependencyReachable(
consumer: string,
producer: string,
graph: ReadonlyMap<string, readonly string[]>,
): boolean {
const pending = [...(graph.get(consumer) ?? [])];
const visited = new Set<string>();
while (pending.length > 0) {
const current = pending.shift()!;
if (current === producer) return true;
if (visited.has(current)) continue;
visited.add(current);
pending.push(...(graph.get(current) ?? []));
}
return false;
}
export function asciiCompare(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}