Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

121 lines · 5.3 KBRaw History
1import { randomUUID } from "node:crypto";
2import { writeFile } from "node:fs/promises";
3import path from "node:path";
4import type { StepSpec } from "@diffinery/shared";
5import { failure, type JobRunner, type Scope, type StepIO } from "../job.js";
6import { shellWords, which } from "../util.js";
7
8export 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
18interface 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 */
27export 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
61export 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
66export 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}