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; }>; export type GenerateCiWorkflowResult = Readonly<{ target: string; written: boolean; matches: boolean; firstDifferenceByte: number | null; firstDifferenceLine: number | null; }>; export type CiWorkflowFileSystem = Readonly<{ mkdir(directory: string): Promise; readFile(target: string): Promise; open(target: string, flags: number, mode: number): Promise<{ writeFile(content: string, encoding: "utf8"): Promise; sync(): Promise; close(): Promise; }>; openDirectory(target: string): Promise<{ sync(): Promise; close(): Promise }>; rename(source: string, destination: string): Promise; rm(target: string): Promise; }>; 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(); 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, transfers: ReadonlyMap>, ): 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.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[] { 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", ]; 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 "validate-candidate-archive": return [ " - name: Validate immutable candidate before extraction", ` run: node scripts/verify-ci-candidate-archive.ts --archive ${shellDoubleQuoted(step.archivePath)}`, ]; 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`, ` 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", " run: node scripts/stage-verified-promotion.ts", ]; 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 = { always: 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>, 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 { const root = path.resolve(options.root); const contract = options.contract ?? (await loadCiGateContract(root)); 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.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"); try { const result = await generateCiWorkflow({ root: process.cwd(), check }); 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; } }