import { evaluate, evaluateCondition, stringify } from "./expressions"; import type { ExprContext, ExprValue } from "./expressions"; import type { JobSpec, StepSpec, WorkflowSpec } from "./spec"; export type Segment = { kind: "text"; value: string } | { kind: "secret"; name: string }; export type CompiledStep = { index: number; name: string; kind: "run" | "upload-artifact" | "noop"; script: string; shell: string; workingDirectory: string | null; env: Array<[string, Segment[]]>; continueOnError: boolean; timeoutMinutes: number | null; runIfOk: boolean; runIfFailed: boolean; artifact: { name: string; path: string } | null; }; export type CompiledJob = { key: string; baseKey: string; name: string; runsOn: string[]; needs: string[]; runIfOk: boolean; runIfFailed: boolean; timeoutMinutes: number; continueOnError: boolean; env: Array<[string, Segment[]]>; steps: CompiledStep[]; }; export type EventContext = Record; const EXPRESSION = /\$\{\{([\s\S]*?)\}\}/g; const SECRET_REFERENCE = /^\s*secrets\.([A-Za-z_][A-Za-z0-9_]*)\s*$/; const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; const CHECKOUT_ACTION = /^(actions\/)?checkout(@.*)?$/; const UPLOAD_ACTION = /^(actions\/)?upload-artifact(@.*)?$/; const SETUP_ACTION = /^(actions\/setup-node|oven-sh\/setup-bun)(@.*)?$/; const MAX_MATRIX_COMBINATIONS = 64; function interpolate(source: string, context: ExprContext): Segment[] { const segments: Segment[] = []; let cursor = 0; for (const match of source.matchAll(EXPRESSION)) { const index = match.index ?? 0; if (index > cursor) segments.push({ kind: "text", value: source.slice(cursor, index) }); const expression = match[1] ?? ""; const secret = SECRET_REFERENCE.exec(expression); if (secret) { segments.push({ kind: "secret", name: secret[1] }); } else { segments.push({ kind: "text", value: stringify(evaluate(expression, context)) }); } cursor = index + match[0].length; } if (cursor < source.length) segments.push({ kind: "text", value: source.slice(cursor) }); return segments; } function plainText(segments: Segment[]): string { return segments.map((segment) => (segment.kind === "text" ? segment.value : `***`)).join(""); } function scriptText(segments: Segment[]): string { return segments .map((segment) => (segment.kind === "text" ? segment.value : `\${STIG_SECRET_${segment.name}}`)) .join(""); } function compileEnv(env: Record | undefined, context: ExprContext): Array<[string, Segment[]]> { return Object.entries(env ?? {}) .filter(([name]) => ENV_NAME.test(name)) .map(([name, value]) => [name, interpolate(value, context)]); } function envContext(entries: Array<[string, Segment[]]>): Record { return Object.fromEntries(entries.map(([name, segments]) => [name, plainText(segments)])); } function conditions(condition: string | boolean | undefined, contexts: Record) { return { runIfOk: evaluateCondition(condition, { contexts, jobStatus: "success" }), runIfFailed: evaluateCondition(condition, { contexts, jobStatus: "failure" }), }; } function compileStep( step: StepSpec, index: number, contexts: Record, jobEnv: Array<[string, Segment[]]>, ): CompiledStep { const context: ExprContext = { contexts, jobStatus: "success" }; const env = compileEnv(step.env, { contexts: { ...contexts, env: { ...envContext(jobEnv) } }, jobStatus: "success" }); const base = { index, shell: step.shell ?? "bash", workingDirectory: step["working-directory"] ? plainText(interpolate(step["working-directory"], context)) : null, env, continueOnError: step["continue-on-error"] ?? false, timeoutMinutes: step["timeout-minutes"] ?? null, ...conditions(step.if, contexts), }; if (step.run !== undefined) { const name = step.name ? plainText(interpolate(step.name, context)) : step.run.split("\n", 1)[0].slice(0, 80); return { ...base, name, kind: "run", script: scriptText(interpolate(step.run, context)), artifact: null }; } const uses = step.uses ?? ""; const withValues = Object.fromEntries( Object.entries(step.with ?? {}).map(([key, value]) => [key, plainText(interpolate(value, context))]), ); if (UPLOAD_ACTION.test(uses)) { const name = withValues.name ?? "artifact"; const path = withValues.path; if (!path) throw new Error(`step ${index + 1}: upload-artifact requires 'with.path'`); return { ...base, name: step.name ?? `Upload ${name}`, kind: "upload-artifact", script: "", artifact: { name: name.replace(/[^A-Za-z0-9._-]/g, "_"), path }, }; } if (CHECKOUT_ACTION.test(uses) || SETUP_ACTION.test(uses)) { return { ...base, name: step.name ?? uses, kind: "noop", script: "", artifact: null }; } throw new Error( `step ${index + 1}: unsupported action '${uses}' (only run steps, checkout and upload-artifact are available)`, ); } function expandMatrix(spec: JobSpec): Array> { const matrix = spec.strategy?.matrix; if (!matrix) return [{}]; const { include, exclude, ...axes } = matrix; let combos: Array> = [{}]; for (const [axis, values] of Object.entries(axes)) { if (!Array.isArray(values)) continue; combos = combos.flatMap((combo) => values.map((value) => ({ ...combo, [axis]: value }))); } if (Object.keys(axes).length === 0) combos = []; const matches = (combo: Record, row: Record) => Object.entries(row).every(([key, value]) => combo[key] === value); combos = combos.filter((combo) => !(exclude ?? []).some((row) => matches(combo, row))); for (const row of include ?? []) { const target = combos.find((combo) => Object.keys(axes).every((axis) => !(axis in row) || combo[axis] === row[axis]), ); if (target && Object.keys(row).some((key) => key in axes)) Object.assign(target, row); else combos.push({ ...row }); } if (combos.length > MAX_MATRIX_COMBINATIONS) throw new Error(`matrix expands to more than ${MAX_MATRIX_COMBINATIONS} jobs`); return combos.length > 0 ? combos : [{}]; } function matrixLabel(values: Record): string { return Object.values(values).map(stringify).join(", "); } export function compileWorkflow( spec: WorkflowSpec, event: EventContext, inputs: Record, ): CompiledJob[] { const compiled: CompiledJob[] = []; const baseContexts: Record = { github: event, stig: event, inputs, runner: { os: "Linux", arch: "X64" }, }; const workflowEnv = compileEnv(spec.env, { contexts: baseContexts, jobStatus: "success" }); for (const [baseKey, job] of Object.entries(spec.jobs)) { const combos = expandMatrix(job); for (const values of combos) { const label = matrixLabel(values); const key = combos.length > 1 || label ? `${baseKey}(${label})` : baseKey; const contexts: Record = { ...baseContexts, matrix: values, env: envContext(workflowEnv) }; const jobEnv = [...workflowEnv, ...compileEnv(job.env, { contexts, jobStatus: "success" })]; const stepContexts = { ...contexts, env: envContext(jobEnv) }; const name = job.name ? plainText(interpolate(job.name, { contexts, jobStatus: "success" })) : baseKey; compiled.push({ key, baseKey, name: label && !job.name?.includes("${{") ? `${name} (${label})` : name, runsOn: job["runs-on"], needs: job.needs, timeoutMinutes: job["timeout-minutes"], continueOnError: job["continue-on-error"] ?? false, env: jobEnv, steps: job.steps.map((step, index) => compileStep(step, index, stepContexts, jobEnv)), ...conditions(job.if, contexts), }); } } return compiled; } export function stepSecretNames(job: CompiledJob): string[] { const names = new Set(); const visit = (segments: Segment[]) => { for (const segment of segments) if (segment.kind === "secret") names.add(segment.name); }; for (const [, segments] of job.env) visit(segments); for (const step of job.steps) { for (const [, segments] of step.env) visit(segments); for (const match of step.script.matchAll(/\$\{STIG_SECRET_([A-Za-z0-9_]+)\}/g)) names.add(match[1]); } return [...names]; }