| 1 | import { spawn, type ChildProcess } from "node:child_process"; |
| 2 | import { createHash, randomUUID } from "node:crypto"; |
| 3 | import { createWriteStream, existsSync } from "node:fs"; |
| 4 | import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; |
| 5 | import path from "node:path"; |
| 6 | import { Readable, Transform } from "node:stream"; |
| 7 | import { pipeline } from "node:stream/promises"; |
| 8 | import type { ReadableStream as WebReadableStream } from "node:stream/web"; |
| 9 | import { failure } from "../job.js"; |
| 10 | import { hashFiles, inputList } from "../util.js"; |
| 11 | import { isExactKeyMatch, restoreCache, saveCache } from "./cache.js"; |
| 12 | import type { BuiltinContext } from "./index.js"; |
| 13 | |
| 14 | /** Downloads a public URL to a file with a few retries. */ |
| 15 | export 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 | |
| 33 | export 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 | |
| 48 | export 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 | |
| 54 | export function toolDir(c: BuiltinContext, tool: string, version: string, arch: string): string { |
| 55 | return path.join(c.job.toolCache, tool, version, arch); |
| 56 | } |
| 57 | |
| 58 | export 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). */ |
| 63 | export 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 | */ |
| 73 | export 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. */ |
| 127 | export 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 | |
| 133 | export 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 | */ |
| 178 | export 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). */ |
| 201 | export 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 | } |