import { evaluateCondition } from "./expressions"; import type { ExprContext, ExprValue } from "./expressions"; import { compileUses, isBuiltinAction } from "./builtin-actions"; import type { CacheSpec } from "./builtin-actions"; import { compileAction } from "./actions/compile-action"; import type { CompiledAction } from "./actions/compile-action"; import type { ResolvedActions } from "./actions/resolve"; import { expandMatrix, matrixLabel } from "./matrix"; import { resolvePermissions } from "./permissions"; import { withDockerRunner } from "./runner-sizes"; 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" | "action"; 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; /** A third-party (or local) JavaScript action; absent on specs stored before actions were supported. */ action?: CompiledAction | 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; /** GitHub-compatible `GITHUB_*` values known at compile time (the runner adds the rest). */ githubEnv?: Array<[string, string]>; /** The event payload for `GITHUB_EVENT_PATH`, kept only for jobs that run actions. */ eventJson?: string | null; steps: CompiledStep[]; }; export type EventContext = Record; export type NeedsEntry = { result: string; outputs: Record }; export type CompileInput = { event: EventContext; inputs: Record; vars: Record; needs?: Record; /** Third-party actions resolved when the run was scheduled. */ actions?: ResolvedActions; }; const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; /** Environment names end up in the OIDC `sub` claim, where `:` separates fields. */ const ENVIRONMENT_NAME = /^[A-Za-z0-9._ -]{1,100}$/; 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 = "__gitfor_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[]]>, actions: ResolvedActions, ): 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)) }; } if (!isBuiltinAction(step)) { const action = compileAction(step, index, context, actions); return { ...base, name: action.name, kind: "action", action: action.action }; } 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; if (!ENVIRONMENT_NAME.test(name)) throw new Error( `environment name '${name.slice(0, 100)}' may only contain letters, digits, spaces, '.', '_' and '-'`, ); return { name, 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; } const MAX_EVENT_JSON_BYTES = 64 * 1024; function workflowName(spec: WorkflowSpec, event: EventContext): string { return spec.name ?? String(event.workflow ?? ""); } /** `GITHUB_*` variables the toolkit and many scripts read; values come from the run's event context. */ function githubEnv(event: EventContext, workflow: string, job: string): Array<[string, string]> { const text = (value: ExprValue | undefined) => (value === undefined || value === null ? "" : String(value)); return [ ["GITHUB_EVENT_NAME", text(event.event_name)], ["GITHUB_ACTOR", text(event.actor)], ["GITHUB_REF_NAME", text(event.ref_name)], ["GITHUB_REF_TYPE", text(event.ref_type)], ["GITHUB_HEAD_REF", text(event.head_ref)], ["GITHUB_BASE_REF", text(event.base_ref)], ["GITHUB_REPOSITORY_OWNER", text(event.repository_owner)], ["GITHUB_WORKFLOW", workflow], ["GITHUB_JOB", job], ]; } function eventJson(event: EventContext): string { const json = JSON.stringify(event.event ?? {}); return new TextEncoder().encode(json).byteLength <= MAX_EVENT_JSON_BYTES ? json : "{}"; } export function compileWorkflow(spec: WorkflowSpec, input: CompileInput): CompiledJob[] { const compiled: CompiledJob[] = []; const baseContexts: Record = { github: input.event, gitfor: 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), githubEnv: githubEnv(input.event, workflowName(spec, input.event), baseKey), eventJson: null, steps: job.steps.map((step, index) => compileStep(step, index, stepContexts, jobEnv, input.actions ?? {})), ...conditions(job.if, contexts), }); const last = compiled[compiled.length - 1]; if (last.steps.some((step) => step.kind === "action")) last.eventJson = eventJson(input.event); // Docker actions need the Docker-in-Docker twin of the requested cloud size; other jobs keep the light image. if (last.steps.some((step) => step.action?.docker)) last.runsOn = withDockerRunner(last.runsOn); } } 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(/\$\{GITFOR_SECRET_([A-Za-z0-9_]+)\}/g)) names.add(match[1]); if (step.cache) [step.cache.key, ...step.cache.restoreKeys].forEach(visit); if (step.action) for (const [, segments] of step.action.inputs) visit(segments); if (step.action?.docker) { step.action.docker.args.forEach(visit); for (const [, segments] of step.action.docker.env) visit(segments); } } 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, action: step.action ?? null, })), githubEnv: job.githubEnv ?? [], eventJson: job.eventJson ?? null, }; }