Files
clean-architecture-frontend…/scripts/generate-ci-workflow.ts
T

473 lines
18 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>;
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.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 "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<CiWorkflowJob["condition"], string | null> = {
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<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.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;
}
}