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.

333 lines · 13.5 KBRaw History
1import { existsSync } from "node:fs";
2import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3import path from "node:path";
4import type { StepSpec } from "@diffinery/shared";
5import { getBuiltin, makeBuiltinContext } from "../builtins/index.js";
6import { errMsg, failure, StepFailure, type JobRunner, type Scope, type StepIO } from "../job.js";
7import { exec } from "../util.js";
8import { parseYaml } from "../yaml.js";
9import type { StepExec } from "./run.js";
10
11export 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
16export 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
28export interface ActionInput {
29 description?: string;
30 required?: boolean;
31 default?: unknown;
32 deprecationMessage?: string;
33}
34
35export 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
52export 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. */
68export 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
105export function inputEnvName(name: string): string {
106 return `INPUT_${name.replace(/ /g, "_").toUpperCase()}`;
107}
108
109/** Evaluates `with:` plus defaults from action.yml. */
110export 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
137export 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
184function 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
194async 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
270function 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
276async 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
313function 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}