| 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 } 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 | }; |
| 50 | } |
| 51 | |
| 52 | export async function loadActionMeta(dir: string): Promise<ActionMeta> { |
| 53 | for (const f of ["action.yml", "action.yaml"]) { |
| 54 | const p = path.join(dir, f); |
| 55 | if (existsSync(p)) { |
| 56 | const meta = parseYaml(await readFile(p, "utf8")) as ActionMeta | null; |
| 57 | if (!meta || typeof meta !== "object" || !meta.runs || typeof meta.runs !== "object") { |
| 58 | throw failure(`Invalid action metadata in ${p}: missing 'runs'`); |
| 59 | } |
| 60 | return meta; |
| 61 | } |
| 62 | } |
| 63 | if (existsSync(path.join(dir, "Dockerfile"))) return { runs: { using: "docker", image: "Dockerfile" } }; |
| 64 | throw failure(`Can't find 'action.yml' or 'action.yaml' under '${dir}'. Did you forget to run actions/checkout before running your local action?`); |
| 65 | } |
| 66 | |
| 67 | /** Downloads `owner/repo@ref` into the action cache and returns its root dir. */ |
| 68 | export async function downloadAction(job: JobRunner, io: StepIO, owner: string, repo: string, ref: string): Promise<string> { |
| 69 | const dest = path.join(job.actionsDir, owner, repo, ref); |
| 70 | if (existsSync(path.join(dest, ".diffinery-complete"))) return dest; |
| 71 | const tpl = process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL ?? "https://codeload.github.com/{owner}/{repo}/tar.gz/{ref}"; |
| 72 | const url = tpl.replace("{owner}", owner).replace("{repo}", repo).replace("{ref}", encodeURIComponent(ref)); |
| 73 | job.logger.line(io.logIndex, `Download action repository '${owner}/${repo}@${ref}'`); |
| 74 | const headers: Record<string, string> = { "user-agent": "diffinery-runner" }; |
| 75 | const ghToken = process.env.DIFFINERY_GITHUB_TOKEN; |
| 76 | if (ghToken && url.startsWith("https://codeload.github.com/")) headers.authorization = `token ${ghToken}`; |
| 77 | let lastErr: unknown; |
| 78 | for (let attempt = 0; attempt < 4; attempt++) { |
| 79 | if (attempt) await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt)); |
| 80 | try { |
| 81 | const res = await fetch(url, { headers, redirect: "follow" }); |
| 82 | if (res.status === 404) throw failure(`Unable to resolve action '${owner}/${repo}@${ref}', repository or version not found`); |
| 83 | if (!res.ok) throw new Error(`HTTP ${res.status} downloading ${url}`); |
| 84 | const buf = Buffer.from(await res.arrayBuffer()); |
| 85 | const tmp = `${dest}.tmp-${process.pid}-${Date.now()}`; |
| 86 | await mkdir(tmp, { recursive: true }); |
| 87 | const tarFile = `${tmp}.tar.gz`; |
| 88 | await writeFile(tarFile, buf); |
| 89 | const r = await exec("tar", ["-xzf", tarFile, "-C", tmp, "--strip-components=1"], { capture: true }); |
| 90 | await rm(tarFile, { force: true }); |
| 91 | if (r.exitCode !== 0) throw new Error(`Failed to extract action archive: ${r.stderr.trim()}`); |
| 92 | await rm(dest, { recursive: true, force: true }); |
| 93 | await mkdir(path.dirname(dest), { recursive: true }); |
| 94 | await rename(tmp, dest); |
| 95 | await writeFile(path.join(dest, ".diffinery-complete"), ""); |
| 96 | return dest; |
| 97 | } catch (e) { |
| 98 | if (e instanceof StepFailure) throw e; |
| 99 | lastErr = e; |
| 100 | } |
| 101 | } |
| 102 | throw failure(`Failed to download action '${owner}/${repo}@${ref}': ${errMsg(lastErr)}`); |
| 103 | } |
| 104 | |
| 105 | export function inputEnvName(name: string): string { |
| 106 | return `INPUT_${name.replace(/ /g, "_").toUpperCase()}`; |
| 107 | } |
| 108 | |
| 109 | /** Evaluates `with:` plus defaults from action.yml. */ |
| 110 | export function computeInputs(job: JobRunner, x: StepExec, meta: ActionMeta | null): Record<string, string> { |
| 111 | const inputs: Record<string, string> = {}; |
| 112 | const given = new Map<string, string>(); |
| 113 | for (const [k, v] of Object.entries(x.step.with ?? {})) { |
| 114 | given.set(k.toLowerCase(), job.interp(v === null || v === undefined ? "" : String(v), x.ctx, x.scope)); |
| 115 | inputs[k] = given.get(k.toLowerCase())!; |
| 116 | } |
| 117 | for (const [k, def] of Object.entries(meta?.inputs ?? {})) { |
| 118 | if (given.has(k.toLowerCase())) { |
| 119 | // normalise the key to the declared spelling |
| 120 | const actual = Object.keys(inputs).find((x) => x.toLowerCase() === k.toLowerCase())!; |
| 121 | if (actual !== k) { |
| 122 | inputs[k] = inputs[actual]!; |
| 123 | delete inputs[actual]; |
| 124 | } |
| 125 | if (def?.deprecationMessage) job.logger.line(x.io.logIndex, `##[warning]Input '${k}' has been deprecated with message: ${def.deprecationMessage}`); |
| 126 | continue; |
| 127 | } |
| 128 | if (def && def.default !== undefined && def.default !== null) { |
| 129 | inputs[k] = job.interp(String(def.default), x.ctx, x.scope); |
| 130 | } else if (def?.required) { |
| 131 | job.logger.line(x.io.logIndex, `##[warning]Input required and not supplied: ${k}`); |
| 132 | } |
| 133 | } |
| 134 | return inputs; |
| 135 | } |
| 136 | |
| 137 | export async function runAction(job: JobRunner, x: StepExec): Promise<void> { |
| 138 | const uses = job.interp(String(x.step.uses), x.ctx, x.scope); |
| 139 | const ref = parseUses(uses); |
| 140 | if (ref.kind === "docker") throw failure("Docker actions are not supported on Diffinery runners yet"); |
| 141 | |
| 142 | if (ref.kind === "remote") { |
| 143 | const builtin = getBuiltin(ref.name, ref.ref); |
| 144 | if (builtin) { |
| 145 | const inputs = computeInputs(job, x, null); |
| 146 | logActionHeader(job, x.io, uses, inputs); |
| 147 | const c = makeBuiltinContext(job, x, inputs, uses); |
| 148 | await builtin(c); |
| 149 | return; |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | let dir: string; |
| 154 | let repository: string; |
| 155 | let actionRef: string; |
| 156 | if (ref.kind === "local") { |
| 157 | dir = path.resolve(job.workspace, ref.path); |
| 158 | repository = ""; |
| 159 | actionRef = ""; |
| 160 | } else { |
| 161 | const root = await downloadAction(job, x.io, ref.owner, ref.repo, ref.ref); |
| 162 | dir = ref.subpath ? path.join(root, ref.subpath) : root; |
| 163 | repository = `${ref.owner}/${ref.repo}`; |
| 164 | actionRef = ref.ref; |
| 165 | } |
| 166 | const meta = await loadActionMeta(dir); |
| 167 | const using = String(meta.runs.using ?? "").toLowerCase(); |
| 168 | const inputs = computeInputs(job, x, meta); |
| 169 | logActionHeader(job, x.io, uses, inputs); |
| 170 | const actionInfo = { path: dir, repository, ref: actionRef }; |
| 171 | |
| 172 | if (using === "docker" || meta.runs.image) throw failure("Docker actions are not supported on Diffinery runners yet"); |
| 173 | if (using === "composite") { |
| 174 | await runComposite(job, x, meta, inputs, actionInfo); |
| 175 | return; |
| 176 | } |
| 177 | if (/^node\d+$/.test(using)) { |
| 178 | await runNodeAction(job, x, meta, inputs, actionInfo); |
| 179 | return; |
| 180 | } |
| 181 | throw failure(`Unsupported action runtime 'runs.using: ${meta.runs.using}' in ${uses}`); |
| 182 | } |
| 183 | |
| 184 | function logActionHeader(job: JobRunner, io: StepIO, uses: string, inputs: Record<string, string>): void { |
| 185 | job.logger.line(io.logIndex, `##[group]Run ${uses}`); |
| 186 | const keys = Object.keys(inputs); |
| 187 | if (keys.length) { |
| 188 | job.logger.line(io.logIndex, "with:"); |
| 189 | for (const k of keys) job.logger.line(io.logIndex, ` ${k}: ${inputs[k]!.split("\n").join("\n ")}`); |
| 190 | } |
| 191 | job.logger.line(io.logIndex, "##[endgroup]"); |
| 192 | } |
| 193 | |
| 194 | async function runNodeAction( |
| 195 | job: JobRunner, |
| 196 | x: StepExec, |
| 197 | meta: ActionMeta, |
| 198 | inputs: Record<string, string>, |
| 199 | action: { path: string; repository: string; ref: string }, |
| 200 | ): Promise<void> { |
| 201 | const runs = meta.runs; |
| 202 | if (!runs.main) throw failure(`Action '${x.step.uses}' has no 'runs.main'`); |
| 203 | const inputEnv: Record<string, string> = {}; |
| 204 | for (const [k, v] of Object.entries(inputs)) inputEnv[inputEnvName(k)] = v; |
| 205 | const baseExtra: Record<string, string> = { |
| 206 | ...inputEnv, |
| 207 | GITHUB_ACTION: x.step.id ?? "__" + (action.repository || "local").replace(/[^A-Za-z0-9]/g, "_"), |
| 208 | GITHUB_ACTION_PATH: action.path, |
| 209 | GITHUB_ACTION_REPOSITORY: action.repository, |
| 210 | GITHUB_ACTION_REF: action.ref, |
| 211 | }; |
| 212 | const state: Record<string, string> = {}; |
| 213 | const runScriptFile = async (file: string, io: StepIO, envCtx: Record<string, string>, extra: Record<string, string>, label: string) => { |
| 214 | const res = await job.runStepProcess([process.execPath, path.join(action.path, file)], { |
| 215 | cwd: job.workspace, |
| 216 | envCtx, |
| 217 | io, |
| 218 | timeoutMs: x.timeoutMs, |
| 219 | extraEnv: extra, |
| 220 | }); |
| 221 | if (job.isStopping) return; |
| 222 | if (res.timedOut) throw failure(io.reason ?? "The step timed out.", true); |
| 223 | if (res.exitCode !== 0) { |
| 224 | const msg = `${label}: Process completed with exit code ${res.exitCode}.`; |
| 225 | job.logger.line(io.logIndex, `##[error]${msg}`); |
| 226 | job.addAnnotation(io.logIndex, { level: "error", message: msg }); |
| 227 | io.reason = msg; |
| 228 | throw failure(msg, true); |
| 229 | } |
| 230 | }; |
| 231 | |
| 232 | const ctxScope = x.scope; |
| 233 | if (runs.pre) { |
| 234 | const cond = runs["pre-if"] ?? "always()"; |
| 235 | let ok = true; |
| 236 | try { |
| 237 | ok = job.evalIf(cond, x.ctx, ctxScope); |
| 238 | } catch (e) { |
| 239 | throw failure(`Error evaluating pre-if: ${errMsg(e)}`); |
| 240 | } |
| 241 | if (ok) { |
| 242 | const preIo: StepIO = { logIndex: x.io.logIndex, outputs: {}, state: {} }; |
| 243 | job.logger.line(x.io.logIndex, `Running pre: ${runs.pre}`); |
| 244 | await runScriptFile(runs.pre, preIo, x.envCtx, baseExtra, "pre"); |
| 245 | Object.assign(state, preIo.state); |
| 246 | } |
| 247 | } |
| 248 | if (runs.post) { |
| 249 | const postFile = runs.post; |
| 250 | const envAtMain = { ...x.envCtx }; |
| 251 | job.registerPost({ |
| 252 | name: `Post ${x.name}`, |
| 253 | condition: runs["post-if"] ?? "always()", |
| 254 | run: async (logIndex) => { |
| 255 | const stateEnv: Record<string, string> = {}; |
| 256 | for (const [k, v] of Object.entries(state)) stateEnv[`STATE_${k}`] = v; |
| 257 | const postIo: StepIO = { logIndex, outputs: {}, state: {} }; |
| 258 | await runScriptFile(postFile, postIo, { ...envAtMain, ...job.envAccum }, { ...baseExtra, ...stateEnv }, "post"); |
| 259 | }, |
| 260 | }); |
| 261 | } |
| 262 | const mainIo = x.io; |
| 263 | try { |
| 264 | await runScriptFile(runs.main, mainIo, x.envCtx, { ...baseExtra, ...stateEnvOf(state) }, "main"); |
| 265 | } finally { |
| 266 | Object.assign(state, mainIo.state); |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | function stateEnvOf(state: Record<string, string>): Record<string, string> { |
| 271 | const out: Record<string, string> = {}; |
| 272 | for (const [k, v] of Object.entries(state)) out[`STATE_${k}`] = v; |
| 273 | return out; |
| 274 | } |
| 275 | |
| 276 | async function runComposite( |
| 277 | job: JobRunner, |
| 278 | x: StepExec, |
| 279 | meta: ActionMeta, |
| 280 | inputs: Record<string, string>, |
| 281 | action: { path: string; repository: string; ref: string }, |
| 282 | ): Promise<void> { |
| 283 | if (x.scope.depth > 9) throw failure("Composite action nesting is too deep (max 10)."); |
| 284 | const steps = Array.isArray(meta.runs.steps) ? meta.runs.steps : []; |
| 285 | const scope: Scope = { |
| 286 | steps: {}, |
| 287 | inputs, |
| 288 | failed: false, |
| 289 | inheritedEnv: { ...x.envCtx }, |
| 290 | composite: true, |
| 291 | action, |
| 292 | depth: x.scope.depth + 1, |
| 293 | }; |
| 294 | for (const s of steps) { |
| 295 | if (job.isStopping) break; |
| 296 | const sub = normaliseStep(s); |
| 297 | await job.executeStep(sub, scope, x.io.logIndex); |
| 298 | } |
| 299 | // outputs |
| 300 | const ctx = job.contexts(scope, job.baseEnvContext(scope)); |
| 301 | for (const [k, o] of Object.entries(meta.outputs ?? {})) { |
| 302 | if (!o || o.value === undefined || o.value === null) continue; |
| 303 | try { |
| 304 | x.io.outputs[k] = job.interp(String(o.value), ctx, scope); |
| 305 | } catch (e) { |
| 306 | job.logger.line(x.io.logIndex, `##[warning]Failed to evaluate output '${k}': ${errMsg(e)}`); |
| 307 | } |
| 308 | } |
| 309 | if (job.isStopping) return; |
| 310 | if (scope.failed) throw failure(`Composite action '${x.step.uses}' failed.`, true); |
| 311 | } |
| 312 | |
| 313 | function normaliseStep(s: unknown): StepSpec { |
| 314 | const o = (s ?? {}) as Record<string, unknown>; |
| 315 | const step: StepSpec = {}; |
| 316 | if (o.id !== undefined) step.id = String(o.id); |
| 317 | if (o.name !== undefined) step.name = String(o.name); |
| 318 | if (o.if !== undefined && o.if !== null) step.if = typeof o.if === "boolean" ? String(o.if) : String(o.if); |
| 319 | if (o.run !== undefined) step.run = String(o.run); |
| 320 | if (o.uses !== undefined) step.uses = String(o.uses); |
| 321 | if (o.shell !== undefined) step.shell = String(o.shell); |
| 322 | if (o["working-directory"] !== undefined) step["working-directory"] = String(o["working-directory"]); |
| 323 | if (o["continue-on-error"] !== undefined) step["continue-on-error"] = o["continue-on-error"] as boolean | string; |
| 324 | if (o["timeout-minutes"] !== undefined) step["timeout-minutes"] = Number(o["timeout-minutes"]); |
| 325 | const strMap = (m: unknown) => { |
| 326 | const r: Record<string, string> = {}; |
| 327 | if (m && typeof m === "object") for (const [k, v] of Object.entries(m)) r[k] = v === null || v === undefined ? "" : String(v); |
| 328 | return r; |
| 329 | }; |
| 330 | if (o.with) step.with = strMap(o.with); |
| 331 | if (o.env) step.env = strMap(o.env); |
| 332 | return step; |
| 333 | } |