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.

114 lines · 4.7 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/** Resolves a `shell:` value to an argv (with `{0}` replaced by the script path). */
24export 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
58export 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
63export 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}