import { evaluateCondition } from "./expressions"; import type { ExprContext, ExprValue } from "./expressions"; import { compileUses } from "./builtin-actions"; import type { CacheSpec } from "./builtin-actions"; import { expandMatrix, matrixLabel } from "./matrix"; import { resolvePermissions } from "./permissions"; import type { TokenPermissions } from "./permissions"; import { interpolate, plainText, scriptText, shellName, stepVariable } from "./segments"; import type { Segment } from "./segments"; import type { JobSpec, StepSpec, WorkflowSpec } from "./spec"; export type { Segment } from "./segments"; export type CompiledStep = { index: number; id: string | null; name: string; kind: "run" | "upload-artifact" | "cache" | "noop"; script: string; shell: string; workingDirectory: string | null; env: Array<[string, Segment[]]>; continueOnError: boolean; timeoutMinutes: number | null; runIfOk: boolean; runIfFailed: boolean; /** Bash test evaluated at runtime for conditions on earlier steps' outputs or outcomes. */ runtimeCondition: string | null; artifact: { name: string; path: string } | null; cache: CacheSpec | 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[]]>; outputs: Array<[string, Segment[]]>; environment: { name: string; url: Segment[] } | null; permissions: TokenPermissions; steps: CompiledStep[]; }; export type EventContext = Record; export type NeedsEntry = { result: string; outputs: Record }; export type CompileInput = { event: EventContext; inputs: Record; vars: Record; needs?: Record; }; const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; const STEP_CONDITION = /^\s*(?:\$\{\{)?\s*(!)?\s*steps\.([A-Za-z_][A-Za-z0-9_-]*)\.(?:outputs\.([A-Za-z_][A-Za-z0-9_-]*)|(outcome|conclusion))\s*(?:(==|!=)\s*'([^']*)')?\s*(?:\}\})?\s*$/; export const ENVIRONMENT_URL_OUTPUT = "__stig_environment_url"; 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 singleQuoted(value: string): string { return `'${value.replace(/'/g, "'\\''")}'`; } /** Conditions on `steps.*` can't be decided at compile time, so the common forms compile to bash tests. */ function stepCondition(condition: string | boolean | undefined): string | null { if (typeof condition !== "string" || !condition.includes("steps.")) return null; const match = STEP_CONDITION.exec(condition); if (!match) throw new Error(`unsupported step condition '${condition}'; compare one steps. value with a literal`); const [, negate, stepId, output, result, operator, literal] = match; const variable = output ? stepVariable(stepId, output, "output") : stepVariable(stepId, result ?? "", "result"); const value = `"\${${variable}:-}"`; const test = operator ? `[ ${value} ${operator === "==" ? "=" : "!="} ${singleQuoted(literal ?? "")} ]` : `{ [ -n ${value} ] && [ ${value} != false ] && [ ${value} != 0 ]; }`; return negate ? `! ${test}` : test; } 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 runtimeCondition = stepCondition(step.if); const base = { index, id: step.id ? shellName(step.id) : null, 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, runtimeCondition, artifact: null, cache: null, script: "", ...(runtimeCondition ? { runIfOk: true, runIfFailed: false } : 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)) }; } const builtin = compileUses(step, index, context); switch (builtin.kind) { case "upload-artifact": return { ...base, name: builtin.name, kind: builtin.kind, artifact: builtin.artifact }; case "cache": return { ...base, name: builtin.name, kind: builtin.kind, cache: builtin.cache }; case "noop": return { ...base, name: builtin.name, kind: builtin.kind }; } } function compileEnvironment(job: JobSpec, context: ExprContext): CompiledJob["environment"] { const spec = job.environment; if (spec === undefined) return null; const name = plainText(interpolate(typeof spec === "string" ? spec : spec.name, context)).trim(); if (!name) return null; return { name: name.slice(0, 100), url: typeof spec === "string" ? [] : interpolate(spec.url ?? "", context) }; } function compileOutputs(job: JobSpec, context: ExprContext, environment: CompiledJob["environment"]) { const outputs: Array<[string, Segment[]]> = Object.entries(job.outputs ?? {}).map(([name, value]) => [ name, interpolate(value, context).filter((segment) => segment.kind !== "secret"), ]); if (environment && environment.url.length > 0) outputs.push([ENVIRONMENT_URL_OUTPUT, environment.url]); return outputs; } export function compileWorkflow(spec: WorkflowSpec, input: CompileInput): CompiledJob[] { const compiled: CompiledJob[] = []; const baseContexts: Record = { github: input.event, stig: input.event, inputs: input.inputs, vars: input.vars, 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); const needs: Record = Object.fromEntries( job.needs.map((dependency) => [dependency, input.needs?.[dependency] ?? { result: "", outputs: {} }]), ); for (const values of combos) { const label = matrixLabel(values); const key = combos.length > 1 || label ? `${baseKey}(${label})` : baseKey; const contexts: Record = { ...baseContexts, needs, matrix: values, strategy: { "job-total": combos.length }, env: envContext(workflowEnv), }; const context: ExprContext = { contexts, jobStatus: "success" }; const jobEnv = [...workflowEnv, ...compileEnv(job.env, context)]; const stepContexts = { ...contexts, env: envContext(jobEnv) }; const name = job.name ? plainText(interpolate(job.name, context)) : baseKey; const environment = compileEnvironment(job, context); compiled.push({ key, baseKey, name: label && !job.name?.includes("${{") ? `${name} (${label})` : name, runsOn: job["runs-on"].map((label) => plainText(interpolate(label, context))), needs: job.needs, timeoutMinutes: job["timeout-minutes"], continueOnError: job["continue-on-error"] ?? false, env: jobEnv, outputs: compileOutputs(job, { contexts: stepContexts, jobStatus: "success" }, environment), environment, permissions: resolvePermissions(spec.permissions, job.permissions), 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]); if (step.cache) [step.cache.key, ...step.cache.restoreKeys].forEach(visit); } return [...names]; } /** Specs stored before outputs, caches, environments and permissions existed lack those fields. */ export function withJobDefaults(job: CompiledJob): CompiledJob { return { ...job, outputs: job.outputs ?? [], environment: job.environment ?? null, permissions: job.permissions ?? resolvePermissions(undefined, undefined), steps: job.steps.map((step) => ({ ...step, id: step.id ?? null, cache: step.cache ?? null, runtimeCondition: step.runtimeCondition ?? null, })), }; }