| 1 | import { randomUUID } from "node:crypto"; |
| 2 | import { writeFile } from "node:fs/promises"; |
| 3 | import path from "node:path"; |
| 4 | import type { StepSpec } from "@diffinery/shared"; |
| 5 | import { failure, type JobRunner, type Scope, type StepIO } from "../job.js"; |
| 6 | import { shellWords, which } from "../util.js"; |
| 7 | |
| 8 | export interface StepExec { |
| 9 | step: StepSpec; |
| 10 | scope: Scope; |
| 11 | ctx: Record<string, unknown>; |
| 12 | envCtx: Record<string, string>; |
| 13 | io: StepIO; |
| 14 | timeoutMs: number; |
| 15 | name: string; |
| 16 | } |
| 17 | |
| 18 | interface ShellPlan { |
| 19 | argv: string[]; |
| 20 | ext: string; |
| 21 | } |
| 22 | |
| 23 | /** Resolves a `shell:` value to an argv (with `{0}` replaced by the script path). */ |
| 24 | export function resolveShell(shell: string | undefined, scriptPath: string, pathEnv: string | undefined): ShellPlan { |
| 25 | const s = (shell ?? "").trim(); |
| 26 | const sub = (tpl: string, ext: string): ShellPlan => { |
| 27 | const words = shellWords(tpl).map((w) => w.split("{0}").join(scriptPath)); |
| 28 | return { argv: words, ext }; |
| 29 | }; |
| 30 | if (s === "" ) { |
| 31 | if (which("bash", pathEnv)) return sub("bash --noprofile --norc -eo pipefail {0}", ".sh"); |
| 32 | return sub("sh -e {0}", ".sh"); |
| 33 | } |
| 34 | switch (s) { |
| 35 | case "bash": |
| 36 | return sub("bash --noprofile --norc -eo pipefail {0}", ".sh"); |
| 37 | case "sh": |
| 38 | return sub("sh -e {0}", ".sh"); |
| 39 | case "python": { |
| 40 | const py = which("python", pathEnv) ? "python" : "python3"; |
| 41 | return sub(`${py} {0}`, ".py"); |
| 42 | } |
| 43 | case "pwsh": |
| 44 | return sub(`pwsh -command ". '{0}'"`, ".ps1"); |
| 45 | case "powershell": |
| 46 | return sub(`${which("powershell", pathEnv) ? "powershell" : "pwsh"} -command ". '{0}'"`, ".ps1"); |
| 47 | case "cmd": |
| 48 | throw failure("The `cmd` shell is not supported on Linux runners."); |
| 49 | } |
| 50 | if (!s.includes("{0}")) { |
| 51 | throw failure(`Invalid shell option '${s}'. Shell must be a valid built-in (bash, sh, python, pwsh) or a format string containing '{0}'`); |
| 52 | } |
| 53 | const first = shellWords(s)[0] ?? ""; |
| 54 | const ext = /python/.test(first) ? ".py" : /pwsh|powershell/.test(first) ? ".ps1" : /node/.test(first) ? ".js" : ".sh"; |
| 55 | return sub(s, ext); |
| 56 | } |
| 57 | |
| 58 | export function resolveWorkingDirectory(job: JobRunner, wd: string | undefined): string { |
| 59 | if (!wd) return job.workspace; |
| 60 | return path.isAbsolute(wd) ? wd : path.join(job.workspace, wd); |
| 61 | } |
| 62 | |
| 63 | export async function runScript(job: JobRunner, x: StepExec): Promise<void> { |
| 64 | const { step, scope, ctx, envCtx, io } = x; |
| 65 | const script = job.interp(String(step.run ?? ""), ctx, scope); |
| 66 | const defaults = scope.composite ? undefined : job.spec.defaults?.run; |
| 67 | const shellRaw = step.shell ?? defaults?.shell; |
| 68 | const shell = shellRaw ? job.interp(String(shellRaw), ctx, scope) : undefined; |
| 69 | const wdRaw = step["working-directory"] ?? defaults?.["working-directory"]; |
| 70 | const wd = resolveWorkingDirectory(job, wdRaw ? job.interp(String(wdRaw), ctx, scope) : undefined); |
| 71 | |
| 72 | // Log a GitHub-like header |
| 73 | const lines = script.replace(/\n$/, "").split("\n"); |
| 74 | job.logger.line(io.logIndex, `##[group]Run ${lines[0] ?? ""}`); |
| 75 | for (const l of lines.slice(1)) job.logger.line(io.logIndex, l); |
| 76 | const shown = { ...(step.env ?? {}) }; |
| 77 | const envKeys = Object.keys(shown); |
| 78 | if (shell) job.logger.line(io.logIndex, `shell: ${shell}`); |
| 79 | if (envKeys.length) { |
| 80 | job.logger.line(io.logIndex, "env:"); |
| 81 | for (const k of envKeys) job.logger.line(io.logIndex, ` ${k}: ${envCtx[k] ?? ""}`); |
| 82 | } |
| 83 | job.logger.line(io.logIndex, "##[endgroup]"); |
| 84 | |
| 85 | const tmpBase = path.join(job.tempDir, randomUUID()); |
| 86 | const pathEnv = job.processEnv(envCtx).PATH; |
| 87 | const probe = resolveShell(shell, tmpBase, pathEnv); |
| 88 | const scriptPath = tmpBase + probe.ext; |
| 89 | const plan = resolveShell(shell, scriptPath, pathEnv); |
| 90 | let content = script; |
| 91 | if (probe.ext === ".ps1") { |
| 92 | content = `$ErrorActionPreference = 'stop'\n${script}\nif ((Test-Path -LiteralPath variable:\\LASTEXITCODE)) { exit $LASTEXITCODE }\n`; |
| 93 | } |
| 94 | await writeFile(scriptPath, content, { mode: 0o755 }); |
| 95 | if (!which(plan.argv[0]!, pathEnv)) { |
| 96 | throw failure(`${plan.argv[0]}: command not found. The shell '${shell ?? "bash"}' is not available on this runner.`); |
| 97 | } |
| 98 | const extraEnv: Record<string, string> = { GITHUB_ACTION: step.id ?? "__run" }; |
| 99 | if (scope.action) { |
| 100 | extraEnv.GITHUB_ACTION_PATH = scope.action.path; |
| 101 | extraEnv.GITHUB_ACTION_REPOSITORY = scope.action.repository; |
| 102 | extraEnv.GITHUB_ACTION_REF = scope.action.ref; |
| 103 | } |
| 104 | const res = await job.runStepProcess(plan.argv, { cwd: wd, envCtx, io, timeoutMs: x.timeoutMs, extraEnv }); |
| 105 | if (job.isStopping) return; |
| 106 | if (res.timedOut) throw failure(io.reason ?? "The step timed out.", true); |
| 107 | if (res.exitCode !== 0) { |
| 108 | const msg = `Process completed with exit code ${res.exitCode}.`; |
| 109 | job.logger.line(io.logIndex, `##[error]${msg}`); |
| 110 | job.addAnnotation(io.logIndex, { level: "error", message: msg }); |
| 111 | io.reason = msg; |
| 112 | throw failure(msg, true); |
| 113 | } |
| 114 | } |