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.

183 lines · 7.4 KBRaw History
1import { existsSync, readdirSync } from "node:fs";
2import path from "node:path";
3import type { JobSpec, StepSpec } from "@diffinery/shared";
4import { getBuiltin } from "./builtins/index.js";
5import { resolveGo } from "./builtins/setup-go.js";
6import { extractUrl, fetchJson, isCached, toolArch } from "./builtins/tools.js";
7import type { JobRunner } from "./job.js";
8import { parseUses } from "./steps/actions.js";
9
10/** How long a step waits for its head start before installing the tool itself. */
11const WAIT_MS = 3 * 60_000;
12
13interface Tool {
14 /** Same key, same download: two steps asking for the same tool share one. */
15 key: string;
16 what: string;
17 fetch: (toolCache: string, signal: AbortSignal) => Promise<void>;
18}
19
20interface Pending {
21 what: string;
22 done: Promise<void>;
23 settled: boolean;
24 abort: AbortController;
25}
26
27/**
28 * Hosted runners: the toolchain head start. For a job that sets up Go (actions/setup-go) or a Temurin
29 * JDK (actions/setup-java) with a version the workflow names, the runner starts downloading and
30 * extracting it into the tool cache as the job begins, while the job checks out its code, and the
31 * setup step finds it there. It installs exactly what the step would (the same release, in the same
32 * place), so the step behaves as it would without it. Jobs without such a step do nothing extra.
33 */
34export class ToolPrefetch {
35 private readonly steps = new Map<number, Pending>();
36 private readonly all: Pending[] = [];
37
38 constructor(private readonly job: JobRunner) {}
39
40 start(spec: JobSpec): void {
41 // Steps in a job container run in another image, which may need another build of the tool.
42 if (spec.container?.image) return;
43 const byKey = new Map<string, Pending>();
44 spec.steps.forEach((step, i) => {
45 const tool = toolFor(step, spec);
46 if (!tool) return;
47 let p = byKey.get(tool.key);
48 if (!p) {
49 const abort = new AbortController();
50 const signal = AbortSignal.any([abort.signal, this.job.signal]);
51 const entry: Pending = { what: tool.what, settled: false, abort, done: Promise.resolve() };
52 entry.done = tool
53 .fetch(this.job.toolCache, signal)
54 // On any failure the step installs the tool itself, as it would without a head start.
55 .catch(() => {})
56 .finally(() => {
57 entry.settled = true;
58 });
59 p = entry;
60 byKey.set(tool.key, p);
61 this.all.push(p);
62 }
63 this.steps.set(i, p);
64 });
65 }
66
67 /** Before step `i` (of the job's own steps) runs: lets its head start finish, if it has one. */
68 async wait(i: number, log: (msg: string) => void): Promise<void> {
69 const p = this.steps.get(i);
70 if (!p || p.settled) return;
71 log(`Waiting for ${p.what}, which the runner started installing as the job began`);
72 let timer: NodeJS.Timeout | undefined;
73 const late = new Promise<void>((resolve) => {
74 timer = setTimeout(() => {
75 p.abort.abort();
76 resolve();
77 }, WAIT_MS);
78 });
79 await Promise.race([p.done, late]);
80 clearTimeout(timer);
81 // Aborted: let it clean up before the step installs the tool itself.
82 await p.done;
83 }
84
85 /** At the end of the job: stops head starts that no step waited for. */
86 async stop(): Promise<void> {
87 for (const p of this.all) if (!p.settled) p.abort.abort();
88 await Promise.all(this.all.map((p) => p.done));
89 }
90}
91
92/** A `with:` value as the workflow names it: literal, or exactly `${{ matrix.x }}` / `${{ inputs.x }}` / `${{ vars.x }}`. */
93function literal(v: string | undefined, spec: JobSpec): string | null {
94 if (v === undefined) return "";
95 if (!v.includes("${{")) return v.trim();
96 const m = /^\$\{\{\s*(matrix|inputs|vars)\.([\w-]+)\s*\}\}$/.exec(v.trim());
97 if (!m) return null;
98 const ctx = (m[1] === "matrix" ? spec.matrix : m[1] === "inputs" ? spec.inputs : spec.vars) as Record<string, unknown> | undefined;
99 const value = ctx?.[m[2]!];
100 return typeof value === "string" || typeof value === "number" ? String(value).trim() : null;
101}
102
103function toolFor(step: StepSpec, spec: JobSpec): Tool | null {
104 if (!step.uses || step.run !== undefined) return null;
105 let ref;
106 try {
107 ref = parseUses(step.uses);
108 } catch {
109 return null;
110 }
111 if (ref.kind !== "remote") return null;
112 const name = ref.name.toLowerCase();
113 const inputs: Record<string, string> = {};
114 for (const [k, v] of Object.entries(step.with ?? {})) {
115 const lit = literal(String(v), spec);
116 if (lit === null) return null;
117 inputs[k.toLowerCase()] = lit;
118 }
119 if (name === "actions/setup-go") return goTool(ref.ref, inputs, { ...spec.env, ...step.env });
120 if (name === "actions/setup-java") return javaTool(ref.ref, inputs);
121 return null;
122}
123
124/** Go, for the runner's own setup-go (builtins/setup-go.ts), which installs it to `go/<version>/<arch>`. */
125function goTool(version: string, inputs: Record<string, string>, env: Record<string, string>): Tool | null {
126 if (!getBuiltin("actions/setup-go", version, inputs, env)) return null;
127 const spec = (inputs["go-version"] ?? "").replace(/^go/, "");
128 if (!spec) return null;
129 return {
130 key: `go ${spec}`,
131 what: `Go ${spec}`,
132 fetch: async (toolCache, signal) => {
133 const rel = await resolveGo(spec);
134 if (!rel) return;
135 const dir = path.join(toolCache, "go", rel.version, toolArch("go"));
136 if (!isCached(dir)) await extractUrl(rel.url, dir, { sha256: rel.sha256, signal });
137 },
138 };
139}
140
141interface AdoptiumAsset {
142 binary: { package: { link: string; checksum?: string } };
143 version: { semver: string };
144}
145
146/**
147 * A Temurin JDK for the real actions/setup-java, laid out in the tool cache the way it caches one
148 * (`Java_Temurin-Hotspot_jdk/<version, + as ->/<arch>`), so it finds the JDK there and uses it, as it
149 * does with the JDKs preinstalled on GitHub's runners. Only a major version (`java-version: 21`),
150 * which setup-java resolves to the same latest release.
151 */
152function javaTool(version: string, inputs: Record<string, string>): Tool | null {
153 const m = /^v(\d+)(\.\d+){0,2}$/.exec(version);
154 if (!m || Number(m[1]) < 2 || Number(m[1]) > 5) return null;
155 const major = inputs["java-version"] ?? "";
156 const arch = process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "x64" : null;
157 if (
158 !arch ||
159 !/^\d+$/.test(major) ||
160 (inputs.distribution ?? "").toLowerCase() !== "temurin" ||
161 !["", "jdk"].includes(inputs["java-package"] ?? "") ||
162 !["", arch].includes((inputs.architecture ?? "").toLowerCase()) ||
163 /^true$/i.test(inputs["check-latest"] ?? "") ||
164 inputs.jdkfile
165 ) {
166 return null;
167 }
168 return {
169 key: `java ${major}`,
170 what: `Java ${major} (Temurin)`,
171 fetch: async (toolCache, signal) => {
172 const root = path.join(toolCache, "Java_Temurin-Hotspot_jdk");
173 // Already there (a self-hosted runner's cache): setup-java will use it.
174 if (existsSync(root) && readdirSync(root).some((v) => v.split(/[.+-]/)[0] === major && existsSync(path.join(root, v, `${arch}.complete`)))) return;
175 const api = process.env.DIFFINERY_ADOPTIUM_API ?? "https://api.adoptium.net";
176 const assets = await fetchJson<AdoptiumAsset[]>(`${api}/v3/assets/latest/${major}/hotspot?architecture=${arch === "arm64" ? "aarch64" : arch}&image_type=jdk&os=linux&vendor=eclipse`);
177 const a = assets.find((x) => x.binary?.package?.link && x.version?.semver);
178 if (!a) return;
179 const dir = path.join(root, a.version.semver.replace("+", "-"), arch);
180 if (!isCached(dir)) await extractUrl(a.binary.package.link, dir, { sha256: a.binary.package.checksum, signal });
181 },
182 };
183}