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.

206 lines · 9.4 KBRaw History
1import { spawn, type ChildProcess } from "node:child_process";
2import { createHash, randomUUID } from "node:crypto";
3import { createWriteStream, existsSync } from "node:fs";
4import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
5import path from "node:path";
6import { Readable, Transform } from "node:stream";
7import { pipeline } from "node:stream/promises";
8import type { ReadableStream as WebReadableStream } from "node:stream/web";
9import { failure } from "../job.js";
10import { hashFiles, inputList } from "../util.js";
11import { isExactKeyMatch, restoreCache, saveCache } from "./cache.js";
12import type { BuiltinContext } from "./index.js";
13
14/** Downloads a public URL to a file with a few retries. */
15export async function fetchToFile(url: string, file: string): Promise<void> {
16 let last: unknown;
17 for (let i = 0; i < 4; i++) {
18 if (i) await new Promise((r) => setTimeout(r, 1000 * 2 ** i));
19 try {
20 const res = await fetch(url, { headers: { "user-agent": "diffinery-runner" }, redirect: "follow" });
21 if (res.status === 404) throw failure(`Not found: ${url}`);
22 if (!res.ok || !res.body) throw new Error(`HTTP ${res.status} for ${url}`);
23 await pipeline(Readable.fromWeb(res.body as unknown as WebReadableStream), createWriteStream(file));
24 return;
25 } catch (e) {
26 if (e instanceof Error && e.message.startsWith("Not found:")) throw e;
27 last = e;
28 }
29 }
30 throw last instanceof Error ? last : new Error(String(last));
31}
32
33export async function fetchJson<T>(url: string): Promise<T> {
34 let last: unknown;
35 for (let i = 0; i < 3; i++) {
36 if (i) await new Promise((r) => setTimeout(r, 1000 * 2 ** i));
37 try {
38 const res = await fetch(url, { headers: { "user-agent": "diffinery-runner", accept: "application/json" } });
39 if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
40 return (await res.json()) as T;
41 } catch (e) {
42 last = e;
43 }
44 }
45 throw last instanceof Error ? last : new Error(String(last));
46}
47
48export function toolArch(kind: "node" | "go" | "python"): string {
49 const a = process.arch;
50 if (kind === "go") return a === "arm64" ? "arm64" : a === "arm" ? "armv6l" : "amd64";
51 return a === "arm64" ? "arm64" : a === "arm" ? "armv7l" : "x64";
52}
53
54export function toolDir(c: BuiltinContext, tool: string, version: string, arch: string): string {
55 return path.join(c.job.toolCache, tool, version, arch);
56}
57
58export function isCached(dir: string): boolean {
59 return existsSync(`${dir}.complete`) && existsSync(dir);
60}
61
62/** Downloads a tarball and extracts it into `dir` (stripping the top-level folder). */
63export async function installTarball(c: BuiltinContext, url: string, dir: string, strip = 1, sha256?: string): Promise<void> {
64 c.log(`Downloading and extracting ${url} to ${dir}`);
65 await extractUrl(url, dir, { strip, sha256, signal: c.job.signal });
66}
67
68/**
69 * Downloads a .tar.gz or .tar.xz and extracts it into `dir` as it arrives, stripping `strip` leading
70 * folders and checking its SHA-256 when given, with a few retries. The tool appears at `dir` whole or
71 * not at all: it is extracted next to it, renamed into place, then marked with `${dir}.complete`.
72 */
73export async function extractUrl(url: string, dir: string, o: { strip?: number; sha256?: string; signal?: AbortSignal } = {}): Promise<void> {
74 let last: unknown;
75 for (let i = 0; i < 3; i++) {
76 if (i) await new Promise((r) => setTimeout(r, 1000 * 2 ** i));
77 if (o.signal?.aborted) break;
78 const part = `${dir}.part-${randomUUID().slice(0, 8)}`;
79 let tar: ChildProcess | undefined;
80 try {
81 await mkdir(part, { recursive: true });
82 const res = await fetch(url, { headers: { "user-agent": "diffinery-runner" }, redirect: "follow", signal: o.signal });
83 if (res.status === 404) throw failure(`Not found: ${url}`);
84 if (!res.ok || !res.body) throw new Error(`HTTP ${res.status} for ${url}`);
85 const t = spawn("tar", [/\.(tar\.)?xz$/.test(new URL(url).pathname) ? "-xJf" : "-xzf", "-", "-C", part, `--strip-components=${o.strip ?? 1}`], { stdio: ["pipe", "ignore", "pipe"] });
86 tar = t;
87 let stderr = "";
88 t.stderr!.on("data", (d: Buffer) => {
89 if (stderr.length < 4000) stderr += d.toString();
90 });
91 const exited = new Promise<number>((resolve, reject) => {
92 t.once("error", reject);
93 t.once("close", (code) => resolve(code ?? -1));
94 });
95 const hash = createHash("sha256");
96 const hashing = new Transform({
97 transform(chunk: Buffer, _enc, cb) {
98 hash.update(chunk);
99 cb(null, chunk);
100 },
101 });
102 const [piped, exit] = await Promise.allSettled([pipeline(Readable.fromWeb(res.body as unknown as WebReadableStream), hashing, t.stdin!), exited]);
103 if (exit.status === "rejected") throw exit.reason;
104 if (exit.value !== 0) throw new Error(`tar failed with exit code ${exit.value}: ${stderr.trim().split("\n").slice(-3).join(" ")}`);
105 if (piped.status === "rejected") throw piped.reason;
106 if (o.sha256 && hash.digest("hex") !== o.sha256.toLowerCase()) throw new Error(`${url} does not match its published SHA-256`);
107 await rm(dir, { recursive: true, force: true });
108 await rename(part, dir);
109 await writeFile(`${dir}.complete`, "");
110 return;
111 } catch (e) {
112 if (tar && tar.exitCode === null) {
113 const gone = new Promise((r) => tar!.once("close", r));
114 tar.kill("SIGKILL");
115 await gone;
116 }
117 await rm(part, { recursive: true, force: true });
118 if (e instanceof Error && e.message.startsWith("Not found:")) throw e;
119 last = e;
120 }
121 }
122 if (o.signal?.aborted) throw failure("cancelled");
123 throw last instanceof Error ? last : new Error(String(last));
124}
125
126/** Runs `cmd args` and returns trimmed stdout, or null if it fails. */
127export async function probe(c: BuiltinContext, cmd: string, args: string[], env?: NodeJS.ProcessEnv): Promise<string | null> {
128 const r = await c.exec(cmd, args, { capture: true, silent: true, env });
129 if (r.exitCode !== 0) return null;
130 return (r.stdout || r.stderr).trim();
131}
132
133export async function readVersionFile(c: BuiltinContext, file: string, kind: "node" | "python" | "go"): Promise<string> {
134 const p = path.resolve(c.job.workspace, file);
135 if (!existsSync(p)) throw failure(`The specified ${kind} version file at: ${p} does not exist`);
136 const content = await readFile(p, "utf8");
137 const base = path.basename(p);
138 if (base === "package.json") {
139 try {
140 const j = JSON.parse(content) as { volta?: { node?: string }; engines?: { node?: string }; devEngines?: { runtime?: { name?: string; version?: string } } };
141 const v = j.volta?.node ?? j.devEngines?.runtime?.version ?? j.engines?.node;
142 if (v) return v;
143 } catch {
144 /* fallthrough */
145 }
146 throw failure(`No node version found in ${p}`);
147 }
148 if (base === "go.mod" || base === "go.work") {
149 const tc = /^toolchain\s+go(\S+)/m.exec(content);
150 const g = /^go\s+(\d+(?:\.\d+){0,2})/m.exec(content);
151 const v = tc?.[1] ?? g?.[1];
152 if (!v) throw failure(`No go version found in ${p}`);
153 return v;
154 }
155 if (base === ".tool-versions") {
156 const names = kind === "node" ? ["nodejs", "node"] : kind === "python" ? ["python"] : ["golang", "go"];
157 for (const line of content.split("\n")) {
158 const [n, v] = line.trim().split(/\s+/);
159 if (n && v && names.includes(n)) return v;
160 }
161 throw failure(`No ${kind} version found in ${p}`);
162 }
163 if (base === "pyproject.toml") {
164 const m = /requires-python\s*=\s*["']([^"']+)["']/.exec(content);
165 if (m) return m[1]!.replace(/,/g, " ");
166 throw failure(`No python version found in ${p}`);
167 }
168 const first = content.split("\n").map((l) => l.trim()).find((l) => l && !l.startsWith("#"));
169 if (!first) throw failure(`The ${kind} version file at ${p} is empty`);
170 return first.replace(/^v(?=\d)/, "");
171}
172
173/**
174 * Restores a package-manager cache and registers a post-step save, the way
175 * setup-node/setup-python/setup-go `cache:` do (same keys and paths, so their
176 * caches are shared with the official actions).
177 */
178export async function packageCache(c: BuiltinContext, o: { key: string; paths: string[]; restoreKeys?: string[]; what: string }): Promise<void> {
179 const paths = o.paths.filter(Boolean);
180 if (!paths.length) {
181 c.warn(`Could not determine the ${o.what} cache directory; skipping cache.`);
182 return;
183 }
184 const r = await restoreCache(c, paths, o.key, o.restoreKeys ?? []);
185 c.setOutput("cache-hit", isExactKeyMatch(o.key, r.matchedKey) ? "true" : "false");
186 c.log(r.matchedKey ? `Cache restored from key: ${r.matchedKey}` : `${o.what} cache is not found`);
187 c.registerPost(`Post ${c.name}`, "success()", async (pc) => {
188 if (isExactKeyMatch(o.key, r.matchedKey)) {
189 pc.log(`Cache hit occurred on the primary key ${o.key}, not saving cache.`);
190 return;
191 }
192 if (!paths.some((p) => existsSync(p))) {
193 pc.log(`The ${o.what} cache directories do not exist; not saving cache.`);
194 return;
195 }
196 await saveCache(pc, paths, o.key);
197 });
198}
199
200/** The hash of the first lock file that exists in the workspace root (setup-node/setup-go's default). */
201export function rootLockHash(c: BuiltinContext, input: string, rootFiles: string[]): string {
202 if (input) return hashFiles(c.job.workspace, inputList(input));
203 const found = rootFiles.find((f) => existsSync(path.join(c.job.workspace, f)));
204 if (!found) throw failure(`Dependencies lock file is not found in ${c.job.workspace}. Supported file patterns: ${rootFiles.join(",")}`);
205 return hashFiles(c.job.workspace, [found]);
206}