| 1 | import { existsSync } from "node:fs"; |
| 2 | import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; |
| 3 | import path from "node:path"; |
| 4 | import type { StepSpec } from "@diffinery/shared"; |
| 5 | import { getBuiltin, makeBuiltinContext } from "../builtins/index.js"; |
| 6 | import { errMsg, failure, StepFailure, type JobRunner, type Scope, type StepIO } from "../job.js"; |
| 7 | import { exec, shellWords } from "../util.js"; |
| 8 | import { parseYaml } from "../yaml.js"; |
| 9 | import type { StepExec } from "./run.js"; |
| 10 | |
| 11 | export type UsesRef = |
| 12 | | { kind: "docker"; image: string } |
| 13 | | { kind: "local"; path: string } |
| 14 | | { kind: "remote"; owner: string; repo: string; subpath: string; ref: string; name: string }; |
| 15 | |
| 16 | export function parseUses(uses: string): UsesRef { |
| 17 | const u = uses.trim(); |
| 18 | if (u.startsWith("docker://")) return { kind: "docker", image: u.slice("docker://".length) }; |
| 19 | if (u.startsWith("./") || u.startsWith("../") || u === ".") return { kind: "local", path: u }; |
| 20 | const at = u.lastIndexOf("@"); |
| 21 | if (at <= 0) throw failure(`Expected format {owner}/{repo}[/path]@ref. Actual '${u}'`); |
| 22 | const ref = u.slice(at + 1); |
| 23 | const parts = u.slice(0, at).split("/"); |
| 24 | if (parts.length < 2 || !parts[0] || !parts[1] || !ref) throw failure(`Expected format {owner}/{repo}[/path]@ref. Actual '${u}'`); |
| 25 | return { kind: "remote", owner: parts[0], repo: parts[1], subpath: parts.slice(2).join("/"), ref, name: parts.join("/") }; |
| 26 | } |
| 27 | |
| 28 | export interface ActionInput { |
| 29 | description?: string; |
| 30 | required?: boolean; |
| 31 | default?: unknown; |
| 32 | deprecationMessage?: string; |
| 33 | } |
| 34 | |
| 35 | export interface ActionMeta { |
| 36 | name?: string; |
| 37 | inputs?: Record<string, ActionInput | null>; |
| 38 | outputs?: Record<string, { description?: string; value?: string } | null>; |
| 39 | runs: { |
| 40 | using: string; |
| 41 | main?: string; |
| 42 | pre?: string; |
| 43 | "pre-if"?: string; |
| 44 | post?: string; |
| 45 | "post-if"?: string; |
| 46 | steps?: StepSpec[]; |
| 47 | image?: string; |
| 48 | env?: Record<string, string>; |
| 49 | entrypoint?: string; |
| 50 | args?: unknown[]; |
| 51 | "pre-entrypoint"?: string; |
| 52 | "post-entrypoint"?: string; |
| 53 | }; |
| 54 | } |
| 55 | |
| 56 | export async function loadActionMeta(dir: string): Promise<ActionMeta> { |
| 57 | for (const f of ["action.yml", "action.yaml"]) { |
| 58 | const p = path.join(dir, f); |
| 59 | if (existsSync(p)) { |
| 60 | const meta = parseYaml(await readFile(p, "utf8")) as ActionMeta | null; |
| 61 | if (!meta || typeof meta !== "object" || !meta.runs || typeof meta.runs !== "object") { |
| 62 | throw failure(`Invalid action metadata in ${p}: missing 'runs'`); |
| 63 | } |
| 64 | return meta; |
| 65 | } |
| 66 | } |
| 67 | if (existsSync(path.join(dir, "Dockerfile"))) return { runs: { using: "docker", image: "Dockerfile" } }; |
| 68 | throw failure(`Can't find 'action.yml' or 'action.yaml' under '${dir}'. Did you forget to run actions/checkout before running your local action?`); |
| 69 | } |
| 70 | |
| 71 | /** Where actions come from on GitHub, and the first retry's delay (tests point these at local servers and shorten it). */ |
| 72 | export const actionDownload = { codeload: "https://codeload.github.com", git: "https://github.com", backoffMs: 1000 }; |
| 73 | |
| 74 | /** How long the tarball download waits for GitHub to answer before it counts as a failed attempt. */ |
| 75 | const ANSWER_TIMEOUT_MS = 60_000; |
| 76 | /** How long the `git fetch` fallback may take. */ |
| 77 | const GIT_TIMEOUT_MS = 300_000; |
| 78 | |
| 79 | /** |
| 80 | * Downloads `owner/repo@ref` into the action cache and returns its root dir: from GitHub, and when |
| 81 | * GitHub fails, from the copy Diffinery keeps of every action jobs have used (lib/action-mirror.ts |
| 82 | * on the server). After a download from GitHub the runner tells Diffinery, which refreshes its copy |
| 83 | * by fetching the action from GitHub itself; the job doesn't wait for that. |
| 84 | */ |
| 85 | export async function downloadAction(job: JobRunner, io: StepIO, owner: string, repo: string, ref: string): Promise<string> { |
| 86 | const dest = path.join(job.actionsDir, owner, repo, ref); |
| 87 | if (existsSync(path.join(dest, ".diffinery-complete"))) return dest; |
| 88 | const custom = process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL; |
| 89 | const tpl = custom ?? `${actionDownload.codeload}/{owner}/{repo}/tar.gz/{ref}`; |
| 90 | const url = tpl.replace("{owner}", owner).replace("{repo}", repo).replace("{ref}", encodeURIComponent(ref)); |
| 91 | job.logger.line(io.logIndex, `Download action repository '${owner}/${repo}@${ref}'`); |
| 92 | const headers: Record<string, string> = { "user-agent": "diffinery-runner" }; |
| 93 | const ghToken = GITHUB_TOKEN; |
| 94 | if (ghToken && url.startsWith("https://codeload.github.com/")) headers.authorization = `token ${ghToken}`; |
| 95 | let lastErr: unknown; |
| 96 | for (let attempt = 0; attempt < 4; attempt++) { |
| 97 | if (attempt) await new Promise((r) => setTimeout(r, actionDownload.backoffMs * 2 ** attempt)); |
| 98 | try { |
| 99 | // A GitHub outage can leave connections hanging rather than refused. |
| 100 | const answered = new AbortController(); |
| 101 | const timer = setTimeout(() => answered.abort(new Error(`no answer from ${new URL(url).host} in ${ANSWER_TIMEOUT_MS / 1000} s`)), ANSWER_TIMEOUT_MS); |
| 102 | const res = await fetch(url, { headers, redirect: "follow", signal: answered.signal }).finally(() => clearTimeout(timer)); |
| 103 | if (res.status === 404) throw failure(`Unable to resolve action '${owner}/${repo}@${ref}', repository or version not found`); |
| 104 | if (res.status === 401 || res.status === 403) { |
| 105 | lastErr = new Error(`HTTP ${res.status} downloading ${url}`); |
| 106 | break; |
| 107 | } |
| 108 | if (!res.ok) throw new Error(`HTTP ${res.status} downloading ${url}`); |
| 109 | await extractAction(dest, Buffer.from(await res.arrayBuffer())); |
| 110 | if (!custom) reportAction(job, owner, repo, ref); |
| 111 | return dest; |
| 112 | } catch (e) { |
| 113 | if (e instanceof StepFailure) throw e; |
| 114 | lastErr = e; |
| 115 | } |
| 116 | } |
| 117 | if (!custom) { |
| 118 | // The tarball endpoint can be unreachable (proxies, rate limits) while git still works. |
| 119 | if (await cloneAction(dest, owner, repo, ref, ghToken)) { |
| 120 | reportAction(job, owner, repo, ref); |
| 121 | return dest; |
| 122 | } |
| 123 | // GitHub is down: Diffinery's copy, if a job has used this action before. |
| 124 | const fetched = await mirroredAction(job, dest, owner, repo, ref); |
| 125 | if (fetched) { |
| 126 | const msg = `GitHub is unreachable (${errMsg(lastErr)}); using Diffinery's copy of '${owner}/${repo}@${ref}', downloaded from GitHub at ${fetched}`; |
| 127 | job.logger.line(io.logIndex, `##[warning]${msg}`); |
| 128 | job.addAnnotation(io.logIndex, { level: "warning", message: msg }); |
| 129 | return dest; |
| 130 | } |
| 131 | } |
| 132 | throw failure(`Failed to download action '${owner}/${repo}@${ref}': ${errMsg(lastErr)}`); |
| 133 | } |
| 134 | |
| 135 | /** Unpacks a GitHub source tarball (one top-level directory) into `dest`. */ |
| 136 | async function extractAction(dest: string, tarball: Buffer): Promise<void> { |
| 137 | const tmp = `${dest}.tmp-${process.pid}-${Date.now()}`; |
| 138 | await mkdir(tmp, { recursive: true }); |
| 139 | try { |
| 140 | const tarFile = `${tmp}.tar.gz`; |
| 141 | await writeFile(tarFile, tarball); |
| 142 | const r = await exec("tar", ["-xzf", tarFile, "-C", tmp, "--strip-components=1"], { capture: true }); |
| 143 | await rm(tarFile, { force: true }); |
| 144 | if (r.exitCode !== 0) throw new Error(`Failed to extract action archive: ${r.stderr.trim()}`); |
| 145 | await rm(dest, { recursive: true, force: true }); |
| 146 | await mkdir(path.dirname(dest), { recursive: true }); |
| 147 | await rename(tmp, dest); |
| 148 | await writeFile(path.join(dest, ".diffinery-complete"), ""); |
| 149 | } catch (e) { |
| 150 | await rm(tmp, { recursive: true, force: true }); |
| 151 | throw e; |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | /** Asks Diffinery to keep a fresh copy of an action this job got from GitHub. In the background: never fails or delays the job. */ |
| 156 | function reportAction(job: JobRunner, owner: string, repo: string, ref: string): void { |
| 157 | if (!job.api.online) return; |
| 158 | job.api.request("/action-mirror", { method: "POST", json: { owner, repo, ref }, retries: 0 }).then( |
| 159 | (res) => res.body?.cancel(), |
| 160 | () => {}, |
| 161 | ); |
| 162 | } |
| 163 | |
| 164 | /** Diffinery's copy of the action, unpacked into `dest`: when it was downloaded from GitHub, or null when there is none. */ |
| 165 | async function mirroredAction(job: JobRunner, dest: string, owner: string, repo: string, ref: string): Promise<string | null> { |
| 166 | if (!job.api.online) return null; |
| 167 | try { |
| 168 | const q = new URLSearchParams({ owner, repo, ref }); |
| 169 | const res = await job.api.request(`/action-mirror?${q}`, { retries: 2, allow: [404] }); |
| 170 | if (res.status === 404) { |
| 171 | await res.body?.cancel(); |
| 172 | return null; |
| 173 | } |
| 174 | const fetched = res.headers.get("x-diffinery-fetched-at") ?? "an earlier time"; |
| 175 | await extractAction(dest, Buffer.from(await res.arrayBuffer())); |
| 176 | return fetched; |
| 177 | } catch { |
| 178 | return null; |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | /** Fetches `owner/repo@ref` (branch, tag or commit SHA) from github.com with git; false when that fails. */ |
| 183 | async function cloneAction(dest: string, owner: string, repo: string, ref: string, token: string | undefined): Promise<boolean> { |
| 184 | if (!/^[\w.-]+$/.test(owner) || !/^[\w.-]+$/.test(repo) || ref.startsWith("-") || /[\s\\]|\.\.|[~^:?*[]/.test(ref)) return false; |
| 185 | const tmp = `${dest}.tmp-${process.pid}-${Date.now()}`; |
| 186 | // The header goes through the environment, not argv, where other processes could read it. |
| 187 | const n = Number(process.env.GIT_CONFIG_COUNT) || 0; |
| 188 | const auth: Record<string, string> = token |
| 189 | ? { GIT_CONFIG_COUNT: String(n + 1), [`GIT_CONFIG_KEY_${n}`]: "http.https://github.com/.extraheader", [`GIT_CONFIG_VALUE_${n}`]: `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}` } |
| 190 | : {}; |
| 191 | const git = (args: string[]) => exec("git", args, { cwd: tmp, capture: true, timeoutMs: GIT_TIMEOUT_MS, env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...auth } }); |
| 192 | try { |
| 193 | await mkdir(tmp, { recursive: true }); |
| 194 | for (const args of [["init", "-q"], ["fetch", "-q", "--depth", "1", `${actionDownload.git}/${owner}/${repo}`, ref], ["checkout", "-q", "FETCH_HEAD"]]) { |
| 195 | if ((await git(args)).exitCode !== 0) throw new Error(args[0]); |
| 196 | } |
| 197 | await rm(path.join(tmp, ".git"), { recursive: true, force: true }); |
| 198 | await rm(dest, { recursive: true, force: true }); |
| 199 | await mkdir(path.dirname(dest), { recursive: true }); |
| 200 | await rename(tmp, dest); |
| 201 | await writeFile(path.join(dest, ".diffinery-complete"), ""); |
| 202 | return true; |
| 203 | } catch { |
| 204 | await rm(tmp, { recursive: true, force: true }); |
| 205 | return false; |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | export function inputEnvName(name: string): string { |
| 210 | return `INPUT_${name.replace(/ /g, "_").toUpperCase()}`; |
| 211 | } |
| 212 | |
| 213 | /** Evaluates `with:` plus defaults from action.yml. */ |
| 214 | export function computeInputs(job: JobRunner, x: StepExec, meta: ActionMeta | null): Record<string, string> { |
| 215 | const inputs: Record<string, string> = {}; |
| 216 | const given = new Map<string, string>(); |
| 217 | for (const [k, v] of Object.entries(x.step.with ?? {})) { |
| 218 | given.set(k.toLowerCase(), job.interp(v === null || v === undefined ? "" : String(v), x.ctx, x.scope)); |
| 219 | inputs[k] = given.get(k.toLowerCase())!; |
| 220 | } |
| 221 | for (const [k, def] of Object.entries(meta?.inputs ?? {})) { |
| 222 | if (given.has(k.toLowerCase())) { |
| 223 | // normalise the key to the declared spelling |
| 224 | const actual = Object.keys(inputs).find((x) => x.toLowerCase() === k.toLowerCase())!; |
| 225 | if (actual !== k) { |
| 226 | inputs[k] = inputs[actual]!; |
| 227 | delete inputs[actual]; |
| 228 | } |
| 229 | if (def?.deprecationMessage) job.logger.line(x.io.logIndex, `##[warning]Input '${k}' has been deprecated with message: ${def.deprecationMessage}`); |
| 230 | continue; |
| 231 | } |
| 232 | if (def && def.default !== undefined && def.default !== null) { |
| 233 | inputs[k] = job.interp(String(def.default), x.ctx, x.scope); |
| 234 | } else if (def?.required) { |
| 235 | job.logger.line(x.io.logIndex, `##[warning]Input required and not supplied: ${k}`); |
| 236 | } |
| 237 | } |
| 238 | return inputs; |
| 239 | } |
| 240 | |
| 241 | export async function runAction(job: JobRunner, x: StepExec): Promise<void> { |
| 242 | const uses = job.interp(String(x.step.uses), x.ctx, x.scope); |
| 243 | const ref = parseUses(uses); |
| 244 | if (ref.kind === "docker") { |
| 245 | // `uses: docker://image`: `with.args` and `with.entrypoint` drive the container; the rest become INPUT_*. |
| 246 | const given = computeInputs(job, x, null); |
| 247 | const inputs: Record<string, string> = {}; |
| 248 | let args: string[] = []; |
| 249 | let entrypoint: string | undefined; |
| 250 | for (const [k, v] of Object.entries(given)) { |
| 251 | if (k.toLowerCase() === "args") args = shellWords(v); |
| 252 | else if (k.toLowerCase() === "entrypoint") entrypoint = v || undefined; |
| 253 | else inputs[k] = v; |
| 254 | } |
| 255 | logActionHeader(job, x.io, uses, given); |
| 256 | await runDockerAction(job, x, { image: `docker://${ref.image}`, dir: null, args, entrypoint, env: {}, inputs, action: { path: "", repository: "", ref: "" } }); |
| 257 | return; |
| 258 | } |
| 259 | |
| 260 | if (ref.kind === "remote") { |
| 261 | const given = computeInputs(job, x, null); |
| 262 | const builtin = getBuiltin(ref.name, ref.ref, given, x.envCtx); |
| 263 | if (builtin) { |
| 264 | const inputs = given; |
| 265 | logActionHeader(job, x.io, uses, inputs); |
| 266 | const c = makeBuiltinContext(job, x, inputs, uses); |
| 267 | await builtin(c); |
| 268 | return; |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | let dir: string; |
| 273 | let repository: string; |
| 274 | let actionRef: string; |
| 275 | if (ref.kind === "local") { |
| 276 | dir = path.resolve(job.workspace, ref.path); |
| 277 | repository = ""; |
| 278 | actionRef = ""; |
| 279 | } else { |
| 280 | const root = await downloadAction(job, x.io, ref.owner, ref.repo, ref.ref); |
| 281 | dir = ref.subpath ? path.join(root, ref.subpath) : root; |
| 282 | repository = `${ref.owner}/${ref.repo}`; |
| 283 | actionRef = ref.ref; |
| 284 | } |
| 285 | const meta = await loadActionMeta(dir); |
| 286 | const using = String(meta.runs.using ?? "").toLowerCase(); |
| 287 | const inputs = computeInputs(job, x, meta); |
| 288 | logActionHeader(job, x.io, uses, inputs); |
| 289 | const actionInfo = { path: dir, repository, ref: actionRef }; |
| 290 | |
| 291 | if (using === "docker" || meta.runs.image) { |
| 292 | const runs = meta.runs; |
| 293 | if (!runs.image) throw failure(`Action '${uses}' uses Docker but has no 'runs.image'`); |
| 294 | const actx = job.contexts({ ...x.scope, inputs }, x.envCtx); |
| 295 | const ev = (v: unknown) => job.interp(v === undefined || v === null ? "" : String(v), actx, x.scope); |
| 296 | const args = Array.isArray(runs.args) ? runs.args.map(ev) : []; |
| 297 | const env: Record<string, string> = {}; |
| 298 | for (const [k, v] of Object.entries(runs.env ?? {})) env[k] = ev(v); |
| 299 | await runDockerAction(job, x, { |
| 300 | image: runs.image, |
| 301 | dir, |
| 302 | args, |
| 303 | entrypoint: runs.entrypoint ? ev(runs.entrypoint) : undefined, |
| 304 | pre: runs["pre-entrypoint"] ? ev(runs["pre-entrypoint"]) : undefined, |
| 305 | post: runs["post-entrypoint"] ? ev(runs["post-entrypoint"]) : undefined, |
| 306 | preIf: runs["pre-if"], |
| 307 | postIf: runs["post-if"], |
| 308 | env, |
| 309 | inputs, |
| 310 | action: actionInfo, |
| 311 | }); |
| 312 | return; |
| 313 | } |
| 314 | if (using === "composite") { |
| 315 | await runComposite(job, x, meta, inputs, actionInfo); |
| 316 | return; |
| 317 | } |
| 318 | if (/^node\d+$/.test(using)) { |
| 319 | await runNodeAction(job, x, meta, inputs, actionInfo); |
| 320 | return; |
| 321 | } |
| 322 | throw failure(`Unsupported action runtime 'runs.using: ${meta.runs.using}' in ${uses}`); |
| 323 | } |
| 324 | |
| 325 | function logActionHeader(job: JobRunner, io: StepIO, uses: string, inputs: Record<string, string>): void { |
| 326 | job.logger.line(io.logIndex, `##[group]Run ${uses}`); |
| 327 | const keys = Object.keys(inputs); |
| 328 | if (keys.length) { |
| 329 | job.logger.line(io.logIndex, "with:"); |
| 330 | for (const k of keys) job.logger.line(io.logIndex, ` ${k}: ${inputs[k]!.split("\n").join("\n ")}`); |
| 331 | } |
| 332 | job.logger.line(io.logIndex, "##[endgroup]"); |
| 333 | } |
| 334 | |
| 335 | async function runNodeAction( |
| 336 | job: JobRunner, |
| 337 | x: StepExec, |
| 338 | meta: ActionMeta, |
| 339 | inputs: Record<string, string>, |
| 340 | action: { path: string; repository: string; ref: string }, |
| 341 | ): Promise<void> { |
| 342 | const runs = meta.runs; |
| 343 | if (!runs.main) throw failure(`Action '${x.step.uses}' has no 'runs.main'`); |
| 344 | const inputEnv: Record<string, string> = {}; |
| 345 | for (const [k, v] of Object.entries(inputs)) inputEnv[inputEnvName(k)] = v; |
| 346 | const baseExtra: Record<string, string> = { |
| 347 | ...job.runtimeEnv, |
| 348 | ...inputEnv, |
| 349 | GITHUB_ACTION: x.step.id ?? "__" + (action.repository || "local").replace(/[^A-Za-z0-9]/g, "_"), |
| 350 | GITHUB_ACTION_PATH: action.path, |
| 351 | GITHUB_ACTION_REPOSITORY: action.repository, |
| 352 | GITHUB_ACTION_REF: action.ref, |
| 353 | }; |
| 354 | // @actions/artifact refuses to run on anything that looks like GitHub Enterprise Server |
| 355 | // (any GITHUB_SERVER_URL but github.com), so the artifact actions see github.com; their |
| 356 | // API calls still go to GITHUB_API_URL and the runtime services, and the URLs they |
| 357 | // output are pointed back at this server. |
| 358 | const serverUrl = job.processEnv(x.envCtx).GITHUB_SERVER_URL ?? ""; |
| 359 | const artifactAction = ARTIFACT_ACTIONS.has(action.repository.toLowerCase()); |
| 360 | if (artifactAction) baseExtra.GITHUB_SERVER_URL = "https://github.com"; |
| 361 | const state: Record<string, string> = {}; |
| 362 | const runScriptFile = async (file: string, io: StepIO, envCtx: Record<string, string>, extra: Record<string, string>, label: string) => { |
| 363 | const res = await job.runStepProcess([job.nodePath(), path.join(action.path, file)], { |
| 364 | cwd: job.workspace, |
| 365 | envCtx, |
| 366 | io, |
| 367 | timeoutMs: x.timeoutMs, |
| 368 | extraEnv: extra, |
| 369 | wrap: job.containerWrap(), |
| 370 | }); |
| 371 | if (job.isStopping) return; |
| 372 | if (res.timedOut) throw failure(io.reason ?? "The step timed out.", true); |
| 373 | if (res.exitCode !== 0) { |
| 374 | const msg = `${label}: Process completed with exit code ${res.exitCode}.`; |
| 375 | job.logger.line(io.logIndex, `##[error]${msg}`); |
| 376 | job.addAnnotation(io.logIndex, { level: "error", message: msg }); |
| 377 | io.reason = msg; |
| 378 | throw failure(msg, true); |
| 379 | } |
| 380 | }; |
| 381 | |
| 382 | const ctxScope = x.scope; |
| 383 | if (runs.pre) { |
| 384 | const cond = runs["pre-if"] ?? "always()"; |
| 385 | let ok = true; |
| 386 | try { |
| 387 | ok = job.evalIf(cond, x.ctx, ctxScope); |
| 388 | } catch (e) { |
| 389 | throw failure(`Error evaluating pre-if: ${errMsg(e)}`); |
| 390 | } |
| 391 | if (ok) { |
| 392 | const preIo: StepIO = { logIndex: x.io.logIndex, outputs: {}, state: {} }; |
| 393 | job.logger.line(x.io.logIndex, `Running pre: ${runs.pre}`); |
| 394 | await runScriptFile(runs.pre, preIo, x.envCtx, baseExtra, "pre"); |
| 395 | Object.assign(state, preIo.state); |
| 396 | } |
| 397 | } |
| 398 | if (runs.post) { |
| 399 | const postFile = runs.post; |
| 400 | const envAtMain = { ...x.envCtx }; |
| 401 | job.registerPost({ |
| 402 | name: `Post ${x.name}`, |
| 403 | condition: runs["post-if"] ?? "always()", |
| 404 | run: async (logIndex) => { |
| 405 | const stateEnv: Record<string, string> = {}; |
| 406 | for (const [k, v] of Object.entries(state)) stateEnv[`STATE_${k}`] = v; |
| 407 | const postIo: StepIO = { logIndex, outputs: {}, state: {} }; |
| 408 | await runScriptFile(postFile, postIo, { ...envAtMain, ...job.envAccum }, { ...baseExtra, ...stateEnv }, "post"); |
| 409 | }, |
| 410 | }); |
| 411 | } |
| 412 | const mainIo = x.io; |
| 413 | try { |
| 414 | await runScriptFile(runs.main, mainIo, x.envCtx, { ...baseExtra, ...stateEnvOf(state) }, "main"); |
| 415 | } finally { |
| 416 | Object.assign(state, mainIo.state); |
| 417 | const url = mainIo.outputs["artifact-url"]; |
| 418 | if (artifactAction && serverUrl && url?.startsWith("https://github.com/")) mainIo.outputs["artifact-url"] = `${serverUrl}/${url.slice("https://github.com/".length)}`; |
| 419 | } |
| 420 | } |
| 421 | |
| 422 | const ARTIFACT_ACTIONS = new Set(["actions/upload-artifact", "actions/download-artifact"]); |
| 423 | |
| 424 | /** |
| 425 | * The operator's github.com token for downloading actions (rate limits, private |
| 426 | * actions), read once when the runner starts. Steps never see it: job.ts strips |
| 427 | * it from every child environment. |
| 428 | */ |
| 429 | const GITHUB_TOKEN = process.env.DIFFINERY_GITHUB_TOKEN || undefined; |
| 430 | |
| 431 | /** Docker container actions: `uses: docker://image`, or an action with `runs.using: docker`. */ |
| 432 | async function runDockerAction( |
| 433 | job: JobRunner, |
| 434 | x: StepExec, |
| 435 | d: { |
| 436 | image: string; |
| 437 | dir: string | null; |
| 438 | args: string[]; |
| 439 | entrypoint?: string; |
| 440 | pre?: string; |
| 441 | post?: string; |
| 442 | preIf?: string; |
| 443 | postIf?: string; |
| 444 | env: Record<string, string>; |
| 445 | inputs: Record<string, string>; |
| 446 | action: { path: string; repository: string; ref: string }; |
| 447 | }, |
| 448 | ): Promise<void> { |
| 449 | const image = await job.docker.prepareAction(x.io, d.image, d.dir); |
| 450 | const extra: Record<string, string> = { |
| 451 | ...job.runtimeEnv, |
| 452 | GITHUB_ACTION: x.step.id ?? "__" + (d.action.repository || "docker").replace(/[^A-Za-z0-9]/g, "_"), |
| 453 | GITHUB_ACTION_REPOSITORY: d.action.repository, |
| 454 | GITHUB_ACTION_REF: d.action.ref, |
| 455 | }; |
| 456 | for (const [k, v] of Object.entries(d.inputs)) extra[inputEnvName(k)] = v; |
| 457 | const runIn = async (io: StepIO, envCtx: Record<string, string>, o: { entrypoint?: string; args: string[] }, label: string) => { |
| 458 | const wrap = await job.docker.runWrap(io, image, o); |
| 459 | const res = await job.runStepProcess([], { cwd: job.workspace, envCtx: { ...envCtx, ...d.env }, io, timeoutMs: x.timeoutMs, extraEnv: extra, wrap }); |
| 460 | if (job.isStopping) return; |
| 461 | if (res.timedOut) throw failure(io.reason ?? "The step timed out.", true); |
| 462 | if (res.exitCode !== 0) { |
| 463 | const msg = `${label}Docker run failed with exit code ${res.exitCode}`; |
| 464 | job.logger.line(io.logIndex, `##[error]${msg}`); |
| 465 | job.addAnnotation(io.logIndex, { level: "error", message: msg }); |
| 466 | io.reason = msg; |
| 467 | throw failure(msg, true); |
| 468 | } |
| 469 | }; |
| 470 | if (d.pre) { |
| 471 | let ok = true; |
| 472 | try { |
| 473 | ok = job.evalIf(d.preIf ?? "always()", x.ctx, x.scope); |
| 474 | } catch (e) { |
| 475 | throw failure(`Error evaluating pre-if: ${errMsg(e)}`); |
| 476 | } |
| 477 | if (ok) await runIn({ logIndex: x.io.logIndex, outputs: {}, state: {} }, x.envCtx, { entrypoint: d.pre, args: [] }, "pre: "); |
| 478 | } |
| 479 | if (d.post) { |
| 480 | const post = d.post; |
| 481 | const envAtMain = { ...x.envCtx }; |
| 482 | job.registerPost({ |
| 483 | name: `Post ${x.name}`, |
| 484 | condition: d.postIf ?? "always()", |
| 485 | run: async (logIndex) => runIn({ logIndex, outputs: {}, state: {} }, { ...envAtMain, ...job.envAccum }, { entrypoint: post, args: [] }, "post: "), |
| 486 | }); |
| 487 | } |
| 488 | await runIn(x.io, x.envCtx, { entrypoint: d.entrypoint, args: d.args }, ""); |
| 489 | } |
| 490 | |
| 491 | function stateEnvOf(state: Record<string, string>): Record<string, string> { |
| 492 | const out: Record<string, string> = {}; |
| 493 | for (const [k, v] of Object.entries(state)) out[`STATE_${k}`] = v; |
| 494 | return out; |
| 495 | } |
| 496 | |
| 497 | async function runComposite( |
| 498 | job: JobRunner, |
| 499 | x: StepExec, |
| 500 | meta: ActionMeta, |
| 501 | inputs: Record<string, string>, |
| 502 | action: { path: string; repository: string; ref: string }, |
| 503 | ): Promise<void> { |
| 504 | if (x.scope.depth > 9) throw failure("Composite action nesting is too deep (max 10)."); |
| 505 | const steps = Array.isArray(meta.runs.steps) ? meta.runs.steps : []; |
| 506 | const scope: Scope = { |
| 507 | steps: {}, |
| 508 | inputs, |
| 509 | failed: false, |
| 510 | inheritedEnv: { ...x.envCtx }, |
| 511 | composite: true, |
| 512 | action, |
| 513 | depth: x.scope.depth + 1, |
| 514 | }; |
| 515 | for (const s of steps) { |
| 516 | if (job.isStopping) break; |
| 517 | const sub = normaliseStep(s); |
| 518 | await job.executeStep(sub, scope, x.io.logIndex); |
| 519 | } |
| 520 | // outputs |
| 521 | const ctx = job.contexts(scope, job.baseEnvContext(scope)); |
| 522 | for (const [k, o] of Object.entries(meta.outputs ?? {})) { |
| 523 | if (!o || o.value === undefined || o.value === null) continue; |
| 524 | try { |
| 525 | x.io.outputs[k] = job.interp(String(o.value), ctx, scope); |
| 526 | } catch (e) { |
| 527 | job.logger.line(x.io.logIndex, `##[warning]Failed to evaluate output '${k}': ${errMsg(e)}`); |
| 528 | } |
| 529 | } |
| 530 | if (job.isStopping) return; |
| 531 | if (scope.failed) throw failure(`Composite action '${x.step.uses}' failed.`, true); |
| 532 | } |
| 533 | |
| 534 | function normaliseStep(s: unknown): StepSpec { |
| 535 | const o = (s ?? {}) as Record<string, unknown>; |
| 536 | const step: StepSpec = {}; |
| 537 | if (o.id !== undefined) step.id = String(o.id); |
| 538 | if (o.name !== undefined) step.name = String(o.name); |
| 539 | if (o.if !== undefined && o.if !== null) step.if = typeof o.if === "boolean" ? String(o.if) : String(o.if); |
| 540 | if (o.run !== undefined) step.run = String(o.run); |
| 541 | if (o.uses !== undefined) step.uses = String(o.uses); |
| 542 | if (o.shell !== undefined) step.shell = String(o.shell); |
| 543 | if (o["working-directory"] !== undefined) step["working-directory"] = String(o["working-directory"]); |
| 544 | if (o["continue-on-error"] !== undefined) step["continue-on-error"] = o["continue-on-error"] as boolean | string; |
| 545 | if (o["timeout-minutes"] !== undefined) step["timeout-minutes"] = Number(o["timeout-minutes"]); |
| 546 | const strMap = (m: unknown) => { |
| 547 | const r: Record<string, string> = {}; |
| 548 | if (m && typeof m === "object") for (const [k, v] of Object.entries(m)) r[k] = v === null || v === undefined ? "" : String(v); |
| 549 | return r; |
| 550 | }; |
| 551 | if (o.with) step.with = strMap(o.with); |
| 552 | if (o.env) step.env = strMap(o.env); |
| 553 | return step; |
| 554 | } |