499 lines
19 KiB
TypeScript
499 lines
19 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { constants, type Stats } from "node:fs";
|
|
import {
|
|
mkdir,
|
|
open,
|
|
readFile,
|
|
rename,
|
|
rm,
|
|
} from "node:fs/promises";
|
|
import { fileURLToPath } from "node:url";
|
|
import path from "node:path";
|
|
|
|
import {
|
|
indexCiGateContract,
|
|
loadCiGateContract,
|
|
resolveCiStepActionUses,
|
|
type CiGateContract,
|
|
type CiWorkflowJob,
|
|
type CiWorkflowStep,
|
|
} from "./contracts/ci-gates.ts";
|
|
import {
|
|
assertSafeExistingPublishPath,
|
|
assertSafePublishLeaf,
|
|
ensureSafePublishDirectory,
|
|
} from "./lib/ci-gate-log.ts";
|
|
|
|
export type GenerateCiWorkflowOptions = Readonly<{
|
|
root: string;
|
|
contract?: CiGateContract;
|
|
check: boolean;
|
|
contractMode?: "canonical" | "removal-fixture";
|
|
}>;
|
|
|
|
export type GenerateCiWorkflowResult = Readonly<{
|
|
target: string;
|
|
written: boolean;
|
|
matches: boolean;
|
|
firstDifferenceByte: number | null;
|
|
firstDifferenceLine: number | null;
|
|
}>;
|
|
|
|
export type CiWorkflowFileSystem = Readonly<{
|
|
mkdir(directory: string): Promise<unknown>;
|
|
readFile(target: string): Promise<Buffer>;
|
|
open(target: string, flags: number, mode: number): Promise<{
|
|
writeFile(content: string, encoding: "utf8"): Promise<unknown>;
|
|
chmod(mode: number): Promise<unknown>;
|
|
sync(): Promise<unknown>;
|
|
close(): Promise<unknown>;
|
|
}>;
|
|
openDirectory(target: string): Promise<{ sync(): Promise<unknown>; close(): Promise<unknown> }>;
|
|
rename(source: string, destination: string): Promise<unknown>;
|
|
rm(target: string): Promise<unknown>;
|
|
}>;
|
|
|
|
const defaultFileSystem: CiWorkflowFileSystem = Object.freeze({
|
|
mkdir: async (directory) => mkdir(directory, { recursive: true }),
|
|
readFile: async (target) => readFile(target),
|
|
open: async (target, flags, mode) => open(target, flags, mode),
|
|
openDirectory: async (target) => open(target, constants.O_RDONLY),
|
|
rename: async (source, destination) => rename(source, destination),
|
|
rm: async (target) => rm(target, { force: true }),
|
|
});
|
|
|
|
export function renderCiWorkflow(contract: CiGateContract): string {
|
|
const index = indexCiGateContract(contract);
|
|
const transfers = new Map<string, { name: string }>();
|
|
for (const job of contract.jobs) {
|
|
for (const step of job.steps) {
|
|
if (step.kind === "upload") transfers.set(step.transferId, { name: step.name });
|
|
}
|
|
}
|
|
const lines = [
|
|
"# GENERATED FILE — edit config/ci/gates.json and run `corepack pnpm generate:ci-workflow`.",
|
|
"name: frontend-quality-gates",
|
|
"",
|
|
"on:",
|
|
" push:",
|
|
" branches: [develop]",
|
|
' tags: ["v*"]',
|
|
" pull_request:",
|
|
" workflow_dispatch:",
|
|
" inputs:",
|
|
" stage:",
|
|
" description: Highest promotion tier to evaluate",
|
|
" required: true",
|
|
" default: merge",
|
|
" type: choice",
|
|
" options:",
|
|
" - merge",
|
|
" - release",
|
|
" - production",
|
|
" - field",
|
|
" - documentation",
|
|
"",
|
|
"permissions:",
|
|
" contents: read",
|
|
"",
|
|
"env:",
|
|
' CI: "true"',
|
|
' VITE_BUILD_ID: "gitea-${{ gitea.run_id }}-${{ gitea.run_attempt }}"',
|
|
' VITE_COMMIT_SHA: "${{ gitea.sha }}"',
|
|
' RELEASE_ID: "${{ gitea.ref }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}"',
|
|
' CI_RUNNER_IMAGE: "${{ vars.RUNNER_IMAGE_DIGEST }}"',
|
|
"",
|
|
"jobs:",
|
|
];
|
|
for (const [jobIndex, job] of contract.jobs.entries()) {
|
|
if (jobIndex > 0) lines.push("");
|
|
lines.push(...renderJob(job, index, transfers));
|
|
}
|
|
return `${lines.join("\n").replace(/\n+$/u, "")}\n`;
|
|
}
|
|
|
|
function renderJob(
|
|
job: CiWorkflowJob,
|
|
index: ReturnType<typeof indexCiGateContract>,
|
|
transfers: ReadonlyMap<string, Readonly<{ name: string }>>,
|
|
): string[] {
|
|
const lines = [` ${yamlKey(job.id)}:`, ` name: ${yamlScalar(job.displayName)}`];
|
|
if (job.needs.length === 1) lines.push(` needs: ${yamlKey(job.needs[0]!)}`);
|
|
if (job.needs.length > 1) lines.push(` needs: [${job.needs.map(yamlKey).join(", ")}]`);
|
|
const condition = renderCondition(job.condition);
|
|
if (condition) lines.push(` if: ${condition}`);
|
|
lines.push(" runs-on: ubuntu-latest", ` timeout-minutes: ${job.timeoutMinutes}`);
|
|
if (job.kind === "immutable") {
|
|
const archive = job.steps.find((step) => step.kind === "archive-candidate");
|
|
if (!archive || archive.kind !== "archive-candidate") throw new TypeError("immutable job lacks archive step");
|
|
lines.push(
|
|
" outputs:",
|
|
` ${archive.distOutputName}: \${{ steps.${archive.stepId}.outputs.${archive.distOutputName} }}`,
|
|
` ${archive.archiveOutputName}: \${{ steps.${archive.stepId}.outputs.${archive.archiveOutputName} }}`,
|
|
);
|
|
}
|
|
if (job.kind === "provider") {
|
|
const supervisor = job.steps.find((step) => step.kind === "run-provider");
|
|
if (!supervisor || supervisor.kind !== "run-provider") {
|
|
throw new TypeError("provider job lacks supervisor step");
|
|
}
|
|
lines.push(
|
|
" outputs:",
|
|
` invocation_nonce: \${{ steps.${supervisor.stepId}.outputs.invocation_nonce }}`,
|
|
);
|
|
}
|
|
if (job.environment.length > 0) {
|
|
lines.push(" env:");
|
|
for (const binding of job.environment) {
|
|
lines.push(` ${binding.name}: ${yamlScalar(binding.value)}`);
|
|
}
|
|
}
|
|
if (job.kind === "gate-matrix") {
|
|
lines.push(" strategy:", " fail-fast: false", " matrix:", " include:");
|
|
const includesBrowser = job.steps.some(({ kind }) => kind === "browser-install");
|
|
for (const gateId of job.gateIds) {
|
|
const gate = index.gates.get(gateId);
|
|
if (!gate) throw new TypeError(`unknown gate while rendering: ${gateId}`);
|
|
const browser = job.browserGateIds.includes(gateId);
|
|
lines.push(
|
|
` - { gate: ${gateId}, name: ${gate.name}${includesBrowser ? `, browser: ${browser ? "true" : "false"}` : ""} }`,
|
|
);
|
|
}
|
|
}
|
|
lines.push(" steps:");
|
|
for (const step of job.steps) lines.push(...renderStep(job, step, transfers));
|
|
return lines;
|
|
}
|
|
|
|
function renderStep(
|
|
job: CiWorkflowJob,
|
|
step: CiWorkflowStep,
|
|
transfers: ReadonlyMap<string, Readonly<{ name: string }>>,
|
|
): string[] {
|
|
switch (step.kind) {
|
|
case "checkout":
|
|
return [
|
|
` - uses: ${requiredStepActionUses(step.kind)}`,
|
|
" with:",
|
|
" persist-credentials: false",
|
|
];
|
|
case "setup-node":
|
|
return [
|
|
` - uses: ${requiredStepActionUses(step.kind)}`,
|
|
" with:",
|
|
" node-version-file: .nvmrc",
|
|
];
|
|
case "frozen-install":
|
|
return [
|
|
" - name: Frozen install",
|
|
" run: |",
|
|
" corepack enable",
|
|
" corepack pnpm install --frozen-lockfile --ignore-scripts",
|
|
];
|
|
case "browser-install":
|
|
return [
|
|
" - name: Install Playwright browsers",
|
|
...(job.kind === "gate-matrix" ? [" if: ${{ matrix.browser }}"] : []),
|
|
" run: corepack pnpm exec playwright install --with-deps chromium firefox webkit",
|
|
];
|
|
case "run-gate": {
|
|
const gateId = job.kind === "gate-matrix" ? "${{ matrix.gate }}" : job.gateIds[0];
|
|
if (!gateId) throw new TypeError(`run-gate step lacks ownership: ${job.id}`);
|
|
const name = job.id === "documentation_gate" ? "Run documentation gate" : job.id === "immutable_build" ? "Build candidate once and verify local evidence" : "Run blocking gate";
|
|
return [` - name: ${name}`, ` run: corepack pnpm ci:gate -- ${gateId}`];
|
|
}
|
|
case "archive-candidate": {
|
|
const archive = shellDoubleQuoted(step.archivePath);
|
|
const lines = [
|
|
" - name: Archive and validate the exact candidate file set",
|
|
` id: ${yamlKey(step.stepId)}`,
|
|
" run: |",
|
|
" mkdir -p .release",
|
|
` tar --sort=name --mtime="@0" --owner=0 --group=0 --numeric-owner -czf ${archive} \\`,
|
|
];
|
|
step.members.forEach((member, memberIndex) => {
|
|
lines.push(` ${shellWord(member)}${memberIndex === step.members.length - 1 ? "" : " \\"}`);
|
|
});
|
|
lines.push(
|
|
` node scripts/verify-ci-candidate-archive.ts --archive ${archive} --github-output "$GITHUB_OUTPUT"`,
|
|
);
|
|
return lines;
|
|
}
|
|
case "download":
|
|
return [
|
|
` - name: Download ${humanize(step.transferId)}`,
|
|
` uses: ${requiredStepActionUses(step.kind)}`,
|
|
" with:",
|
|
` name: ${yamlScalar(requiredTransferName(transfers, step.transferId))}`,
|
|
` path: ${yamlScalar(step.path)}`,
|
|
];
|
|
case "extract":
|
|
return [
|
|
" - name: Verify and extract the candidate through one inode-bound operation",
|
|
` run: node scripts/verify-ci-candidate-archive.ts --archive ${shellDoubleQuoted(step.archivePath)} --extract-to ${shellDoubleQuoted(step.targetRoot)}`,
|
|
];
|
|
case "run-provider": {
|
|
return [
|
|
` - name: Run and validate external ${step.provider} provider in one trusted supervisor`,
|
|
` id: ${yamlKey(step.stepId)}`,
|
|
` run: node scripts/run-and-validate-provider.ts --kind ${step.provider}`,
|
|
];
|
|
}
|
|
case "validate-provider-evidence":
|
|
return [
|
|
` - name: Confirm sealed ${step.provider} provider evidence`,
|
|
' run: test -s "$VALIDATED_PROVIDER_REPORT_PATH"',
|
|
];
|
|
case "verify-promotion":
|
|
return [
|
|
" - name: Finalize verified promotion from inode-bound captured inputs",
|
|
` id: ${yamlKey(step.stepId)}`,
|
|
" run: node scripts/stage-verified-promotion.ts",
|
|
];
|
|
case "cleanup-promotion":
|
|
return [
|
|
" - name: Always remove private promotion staging",
|
|
" if: always()",
|
|
" env:",
|
|
` PROMOTION_STAGING_ROOT: \${{ steps.${step.finalizerStepId}.outputs.staging_root }}`,
|
|
` PROMOTION_CLEANUP_TOKEN: \${{ steps.${step.finalizerStepId}.outputs.cleanup_token }}`,
|
|
` PROMOTION_RUNNER_TEMP_DEV: \${{ steps.${step.finalizerStepId}.outputs.runner_temp_dev }}`,
|
|
` PROMOTION_RUNNER_TEMP_INO: \${{ steps.${step.finalizerStepId}.outputs.runner_temp_ino }}`,
|
|
` PROMOTION_STAGING_DEV: \${{ steps.${step.finalizerStepId}.outputs.staging_dev }}`,
|
|
` PROMOTION_STAGING_INO: \${{ steps.${step.finalizerStepId}.outputs.staging_ino }}`,
|
|
" run: |",
|
|
' if [ -n "$PROMOTION_STAGING_ROOT" ] && [ -n "$PROMOTION_CLEANUP_TOKEN" ] && [ -n "$PROMOTION_RUNNER_TEMP_DEV" ] && [ -n "$PROMOTION_RUNNER_TEMP_INO" ] && [ -n "$PROMOTION_STAGING_DEV" ] && [ -n "$PROMOTION_STAGING_INO" ]; then',
|
|
" node scripts/cleanup-verified-promotion.ts",
|
|
" fi",
|
|
];
|
|
case "upload": {
|
|
const lines = [
|
|
` - name: Upload ${humanize(step.transferId)}`,
|
|
...(step.always ? [" if: always()"] : []),
|
|
` uses: ${requiredStepActionUses(step.kind)}`,
|
|
" with:",
|
|
` name: ${yamlScalar(step.name)}`,
|
|
];
|
|
if (step.paths.length === 1) lines.push(` path: ${yamlScalar(step.paths[0]!)}`);
|
|
else {
|
|
lines.push(" path: |");
|
|
for (const target of step.paths) lines.push(` ${target}`);
|
|
}
|
|
lines.push(" if-no-files-found: error");
|
|
return lines;
|
|
}
|
|
}
|
|
}
|
|
|
|
function requiredStepActionUses(stepKind: string): string {
|
|
const uses = resolveCiStepActionUses(stepKind);
|
|
if (!uses) throw new TypeError(`workflow step has no registered CI action: ${stepKind}`);
|
|
return uses;
|
|
}
|
|
|
|
function renderCondition(condition: CiWorkflowJob["condition"]): string | null {
|
|
const expressions: Record<CiWorkflowJob["condition"], string | null> = {
|
|
always: null,
|
|
"needs-success": null,
|
|
merge: "${{ gitea.event_name != 'workflow_dispatch' || inputs.stage != 'documentation' }}",
|
|
release: "${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }}",
|
|
production: "${{ gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'production' || inputs.stage == 'field') }}",
|
|
field: "${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'field' }}",
|
|
documentation: "${{ gitea.event_name == 'workflow_dispatch' && inputs.stage == 'documentation' }}",
|
|
};
|
|
return expressions[condition];
|
|
}
|
|
|
|
function requiredTransferName(
|
|
transfers: ReadonlyMap<string, Readonly<{ name: string }>>,
|
|
transferId: string,
|
|
): string {
|
|
const transfer = transfers.get(transferId);
|
|
if (!transfer) throw new TypeError(`download transfer has no typed producer: ${transferId}`);
|
|
return transfer.name;
|
|
}
|
|
|
|
function yamlKey(value: string): string {
|
|
if (!/^[A-Za-z0-9_-]+$/u.test(value)) throw new TypeError(`unsafe YAML key: ${value}`);
|
|
return value;
|
|
}
|
|
|
|
function yamlScalar(value: string): string {
|
|
if (/^[A-Za-z0-9._/-]+$/u.test(value)) return value;
|
|
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n")}"`;
|
|
}
|
|
|
|
function shellWord(value: string): string {
|
|
if (!/^[A-Za-z0-9._/-]+$/u.test(value)) throw new TypeError(`unsafe shell word: ${value}`);
|
|
return value;
|
|
}
|
|
|
|
function shellDoubleQuoted(value: string): string {
|
|
const expressions: string[] = [];
|
|
const withoutExpressions = value.replace(/\$\{\{ [A-Za-z0-9_.-]+ \}\}/gu, (expression) => {
|
|
expressions.push(expression);
|
|
return `__CI_EXPRESSION_${expressions.length - 1}__`;
|
|
});
|
|
if (withoutExpressions.includes("$")) {
|
|
throw new TypeError(`unapproved shell interpolation in workflow value: ${value}`);
|
|
}
|
|
let escaped = withoutExpressions
|
|
.replaceAll("\\", "\\\\")
|
|
.replaceAll('"', '\\"')
|
|
.replaceAll("`", "\\`");
|
|
expressions.forEach((expression, index) => {
|
|
escaped = escaped.replace(`__CI_EXPRESSION_${index}__`, expression);
|
|
});
|
|
return `"${escaped}"`;
|
|
}
|
|
|
|
function humanize(value: string): string {
|
|
return value.replaceAll("-", " ");
|
|
}
|
|
|
|
export function createCiWorkflowGenerator(
|
|
dependencies: Readonly<{
|
|
fileSystem?: CiWorkflowFileSystem;
|
|
createNonce?: () => string;
|
|
}> = {},
|
|
) {
|
|
const fileSystem = dependencies.fileSystem ?? defaultFileSystem;
|
|
const createNonce = dependencies.createNonce ?? randomUUID;
|
|
return async function generate(options: GenerateCiWorkflowOptions): Promise<GenerateCiWorkflowResult> {
|
|
const root = path.resolve(options.root);
|
|
const contract = options.contract ?? (await loadCiGateContract(root, {
|
|
mode: options.contractMode ?? "canonical",
|
|
}));
|
|
const target = path.resolve(root, contract.providerAdapter);
|
|
if (path.relative(root, target).startsWith("..") || path.relative(root, target) === "") {
|
|
throw new TypeError(`workflow target escapes repository root: ${contract.providerAdapter}`);
|
|
}
|
|
const expected = Buffer.from(renderCiWorkflow(contract), "utf8");
|
|
let actual: Buffer | null = null;
|
|
const existingPathIsSafe =
|
|
fileSystem === defaultFileSystem
|
|
? await assertSafeExistingPublishPath(root, target)
|
|
: true;
|
|
if (existingPathIsSafe) {
|
|
try {
|
|
actual = await fileSystem.readFile(target);
|
|
} catch (error) {
|
|
if (!hasErrorCode(error, "ENOENT")) throw error;
|
|
}
|
|
}
|
|
const difference = firstDifference(expected, actual);
|
|
if (options.check || difference === null) {
|
|
return Object.freeze({
|
|
target,
|
|
written: false,
|
|
matches: difference === null,
|
|
firstDifferenceByte: difference?.byte ?? null,
|
|
firstDifferenceLine: difference?.line ?? null,
|
|
});
|
|
}
|
|
|
|
let parentIdentity: Stats | undefined;
|
|
if (fileSystem === defaultFileSystem) {
|
|
parentIdentity = await ensureSafePublishDirectory(root, path.dirname(target));
|
|
await assertSafePublishLeaf(target, contract.providerAdapter);
|
|
} else {
|
|
await fileSystem.mkdir(path.dirname(target));
|
|
}
|
|
const temporary = path.join(path.dirname(target), `.${path.basename(target)}.${createNonce()}.tmp`);
|
|
let ownsTemporary = false;
|
|
try {
|
|
const handle = await fileSystem.open(
|
|
temporary,
|
|
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
|
0o644,
|
|
);
|
|
ownsTemporary = true;
|
|
let failure: unknown;
|
|
try {
|
|
await handle.writeFile(expected.toString("utf8"), "utf8");
|
|
await handle.chmod(0o644);
|
|
await handle.sync();
|
|
} catch (error) {
|
|
failure = error;
|
|
}
|
|
try {
|
|
await handle.close();
|
|
} catch (error) {
|
|
failure ??= error;
|
|
}
|
|
if (failure) throw failure;
|
|
if (fileSystem === defaultFileSystem && parentIdentity) {
|
|
const current = await ensureSafePublishDirectory(root, path.dirname(target));
|
|
if (
|
|
parentIdentity.dev <= 0 ||
|
|
parentIdentity.ino <= 0 ||
|
|
current.dev !== parentIdentity.dev ||
|
|
current.ino !== parentIdentity.ino
|
|
) {
|
|
throw new TypeError("CI workflow parent directory identity changed");
|
|
}
|
|
await assertSafePublishLeaf(target, contract.providerAdapter);
|
|
}
|
|
await fileSystem.rename(temporary, target);
|
|
ownsTemporary = false;
|
|
const directory = await fileSystem.openDirectory(path.dirname(target));
|
|
try {
|
|
try {
|
|
await directory.sync();
|
|
} catch (error) {
|
|
if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error;
|
|
}
|
|
} finally {
|
|
await directory.close();
|
|
}
|
|
} catch (error) {
|
|
if (ownsTemporary) {
|
|
try {
|
|
await fileSystem.rm(temporary);
|
|
} catch {
|
|
// The owned sibling temp is the only cleanup target; preserve the publish failure.
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
return Object.freeze({ target, written: true, matches: true, firstDifferenceByte: null, firstDifferenceLine: null });
|
|
};
|
|
}
|
|
|
|
export const generateCiWorkflow = createCiWorkflowGenerator();
|
|
|
|
function firstDifference(expected: Buffer, actual: Buffer | null): { byte: number; line: number } | null {
|
|
if (actual?.equals(expected)) return null;
|
|
const limit = Math.min(expected.byteLength, actual?.byteLength ?? 0);
|
|
let byte = 0;
|
|
while (byte < limit && expected[byte] === actual?.[byte]) byte += 1;
|
|
const line = expected.subarray(0, byte).toString("utf8").split("\n").length;
|
|
return { byte, line };
|
|
}
|
|
|
|
function hasErrorCode(error: unknown, code: string): boolean {
|
|
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
|
}
|
|
|
|
const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
if (isCli) {
|
|
const check = process.argv.includes("--check");
|
|
const contractMode = process.argv.includes("--reduced-removal-fixture")
|
|
? "removal-fixture" as const
|
|
: "canonical" as const;
|
|
try {
|
|
const result = await generateCiWorkflow({ root: process.cwd(), check, contractMode });
|
|
if (!result.matches) {
|
|
process.stderr.write(
|
|
`CI workflow drift: ${result.target} differs at byte ${result.firstDifferenceByte ?? 0}, line ${result.firstDifferenceLine ?? 1}\n`,
|
|
);
|
|
process.exitCode = 1;
|
|
} else {
|
|
process.stdout.write(check ? "CI workflow bytes: PASS\n" : "CI workflow generated atomically\n");
|
|
}
|
|
} catch (error) {
|
|
process.stderr.write(`CI workflow generation failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|