import { evaluateCondition } from "../expressions"; import type { ExprContext } from "../expressions"; import { interpolate, plainText } from "../segments"; import type { Segment } from "../segments"; import type { StepSpec } from "../spec"; import type { ResolvedAction, ResolvedActions } from "./resolve"; import type { ResolvedImage } from "./images"; /** * The secret `${{ github.token }}` (and `${{ secrets.GITHUB_TOKEN }}`) stands for in an action's `with:` inputs. Actions use it against * api.github.com (release lookups, tool downloads), where a gitfor token is useless and anonymous calls from * shared egress IPs are rate limited at once; a repository or org secret with this name supplies a real one. */ export const GITHUB_COM_TOKEN_SECRET = "GITHUB_COM_TOKEN"; const GITHUB_TOKEN_EXPRESSION = /\$\{\{\s*(?:github\.token|secrets\.GITHUB_TOKEN)\s*\}\}/g; const DEFAULT_PHASE_CONDITION = "always()"; /** Input names become `INPUT_=` arguments to env(1), so `=` or control characters must not appear. */ const INPUT_NAME = /^[A-Za-z0-9_][A-Za-z0-9_ .-]{0,99}$/; /** How a Docker action step runs on the runner (see `__gitfor_docker_run`). */ export type CompiledDocker = { image: ({ kind: "registry" } & ResolvedImage) | { kind: "dockerfile"; path: string }; /** Overrides the image's ENTRYPOINT for the main phase; null keeps it. */ entrypoint: string | null; args: Segment[][]; env: Array<[string, Segment[]]>; }; export type CompiledAction = { uses: string; /** `owner/repo@ref` (or `./path`) for logs. */ label: string; local: boolean; repository: string | null; ref: string | null; sha: string | null; path: string; archive: { key: string; sha256: string } | null; main: string; pre: string | null; post: string | null; /** `pre-if`, evaluated at job start, when every status function sees success. */ preRun: boolean; postIfOk: boolean; postIfFailed: boolean; /** `INPUT_` values: `with:` merged over action.yml defaults. */ inputs: Array<[string, Segment[]]>; /** Set for Docker actions; `main`, `pre` and `post` then hold entrypoints ('' keeps the image's own). */ docker?: CompiledDocker | null; }; const INPUT_REFERENCE = /\$\{\{\s*inputs\.([A-Za-z0-9_-]+)\s*\}\}/g; /** Action-authored text (args, env): `${{ inputs.x }}` takes the step's input, the rest interpolates like a default. */ function withInputs(value: string, inputs: Map, context: ExprContext): Segment[] { const segments: Segment[] = []; let cursor = 0; for (const match of value.matchAll(INPUT_REFERENCE)) { const index = match.index ?? 0; if (index > cursor) segments.push(...defaultSegments(value.slice(cursor, index), context)); segments.push(...(inputs.get(match[1].toLowerCase()) ?? [])); cursor = index + match[0].length; } if (cursor < value.length) segments.push(...defaultSegments(value.slice(cursor), context)); return segments; } /** Splits `with.args` like a shell would: whitespace separates, quotes group, `${{ }}` stays whole. */ export function splitArgs(value: string): string[] { const args: string[] = []; let current = ""; let quote: string | null = null; let started = false; for (let index = 0; index < value.length; index++) { const char = value[index]; if (!quote && value.startsWith("${{", index)) { const end = value.indexOf("}}", index); const stop = end === -1 ? value.length : end + 2; current += value.slice(index, stop); started = true; index = stop - 1; } else if (quote) { if (char === quote) quote = null; else current += char; } else if (char === "'" || char === '"') { quote = char; started = true; } else if (/\s/.test(char)) { if (started) args.push(current); current = ""; started = false; } else { current += char; started = true; } } if (started) args.push(current); return args; } function compileDocker( resolved: ResolvedAction, step: StepSpec, context: ExprContext, inputs: Array<[string, Segment[]]>, ): CompiledDocker | null { const docker = resolved.manifest.docker; if (!docker) return null; const byName = new Map(inputs.map(([name, segments]) => [name.toLowerCase(), segments])); let image: CompiledDocker["image"]; if (docker.image.kind === "dockerfile") image = { kind: "dockerfile", path: docker.image.path }; else if (resolved.image) image = { kind: "registry", ...resolved.image }; else throw new Error(`'${resolved.uses}': the image was not resolved`); const direct = resolved.uses.startsWith("docker://"); const withArgs = step.with?.args; const args = direct && withArgs !== undefined ? splitArgs(withArgs).map((arg) => inputSegments(arg, context)) : docker.args.map((arg) => withInputs(arg, byName, context)); const entrypoint = direct && step.with?.entrypoint !== undefined ? step.with.entrypoint.trim() || null : docker.entrypoint; return { image, entrypoint, args, env: docker.env.map(([name, value]): [string, Segment[]] => [name, withInputs(value, byName, context)]), }; } function phaseCondition(condition: string | boolean | null, context: ExprContext, status: "success" | "failure") { try { return evaluateCondition(condition ?? DEFAULT_PHASE_CONDITION, { ...context, jobStatus: status }); } catch { return status === "success"; } } /** * An action's own defaults never receive a secret: `token: default: ${{ github.token }}` is common, and a * default can name any `secrets.*`, which would hand repository secrets to third-party code the workflow * author never chose to give them. The author opts in by writing the expression in `with:`. */ function defaultSegments(value: string, context: ExprContext): Segment[] { return interpolate(value.replace(GITHUB_TOKEN_EXPRESSION, ""), context).filter( (segment) => segment.kind !== "secret", ); } function inputSegments(value: string, context: ExprContext): Segment[] { return interpolate(value.replace(GITHUB_TOKEN_EXPRESSION, `\${{ secrets.${GITHUB_COM_TOKEN_SECRET} }}`), context); } export function compileAction( step: StepSpec, index: number, context: ExprContext, actions: ResolvedActions, ): { name: string; action: CompiledAction } { const uses = (step.uses ?? "").trim(); const resolved = actions[uses]; if (!resolved) throw new Error(`step ${index + 1}: action '${uses}' was not resolved`); const { manifest } = resolved; for (const name of Object.keys(step.with ?? {})) { if (!INPUT_NAME.test(name)) throw new Error(`step ${index + 1}: invalid input name '${name.slice(0, 100)}'`); } const provided = new Map(Object.entries(step.with ?? {}).map(([name, value]) => [name.toLowerCase(), value])); const inputs: Array<[string, Segment[]]> = []; const unusable = manifest.inputs.find((candidate) => !INPUT_NAME.test(candidate.name)); if (unusable) throw new Error(`step ${index + 1}: '${uses}' declares an unusable input name '${unusable.name.slice(0, 100)}'`); for (const input of manifest.inputs) { const written = provided.get(input.name.toLowerCase()); provided.delete(input.name.toLowerCase()); if (written !== undefined) inputs.push([input.name, inputSegments(written, context)]); else if (input.default !== null) inputs.push([input.name, defaultSegments(input.default, context)]); } const direct = uses.startsWith("docker://"); for (const [name, value] of provided) { if (direct && (name === "args" || name === "entrypoint")) continue; inputs.push([name, inputSegments(value, context)]); } const docker = compileDocker(resolved, step, context, inputs); const label = direct ? uses : resolved.local ? uses : `${resolved.repository}${resolved.path ? `/${resolved.path}` : ""}@${resolved.ref}`; const name = step.name ? plainText(interpolate(step.name, context)) : `Run ${label}`; return { name, action: { uses, label, local: resolved.local, repository: resolved.repository, ref: resolved.ref, sha: resolved.sha, path: resolved.path, archive: resolved.archive, main: docker ? (docker.entrypoint ?? "") : manifest.main, pre: docker ? (manifest.docker?.preEntrypoint ?? null) : manifest.pre, post: docker ? (manifest.docker?.postEntrypoint ?? null) : manifest.post, preRun: (docker ? manifest.docker?.preEntrypoint : manifest.pre) != null && phaseCondition(manifest.preIf, context, "success"), postIfOk: phaseCondition(manifest.postIf, context, "success"), postIfFailed: phaseCondition(manifest.postIf, context, "failure"), inputs, docker, }, }; }