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