| 1 | import { randomUUID } from "node:crypto"; |
| 2 | import { createWriteStream, existsSync } from "node:fs"; |
| 3 | import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; |
| 4 | import path from "node:path"; |
| 5 | import { Readable } from "node:stream"; |
| 6 | import { pipeline } from "node:stream/promises"; |
| 7 | import type { ReadableStream as WebReadableStream } from "node:stream/web"; |
| 8 | import { failure } from "../job.js"; |
| 9 | import { hashFiles, inputList } from "../util.js"; |
| 10 | import { isExactKeyMatch, restoreCache, saveCache } from "./cache.js"; |
| 11 | import type { BuiltinContext } from "./index.js"; |
| 12 | |
| 13 | /** Downloads a public URL to a file with a few retries. */ |
| 14 | export async function fetchToFile(url: string, file: string): Promise<void> { |
| 15 | let last: unknown; |
| 16 | for (let i = 0; i < 4; i++) { |
| 17 | if (i) await new Promise((r) => setTimeout(r, 1000 * 2 ** i)); |
| 18 | try { |
| 19 | const res = await fetch(url, { headers: { "user-agent": "diffinery-runner" }, redirect: "follow" }); |
| 20 | if (res.status === 404) throw failure(`Not found: ${url}`); |
| 21 | if (!res.ok || !res.body) throw new Error(`HTTP ${res.status} for ${url}`); |
| 22 | await pipeline(Readable.fromWeb(res.body as unknown as WebReadableStream), createWriteStream(file)); |
| 23 | return; |
| 24 | } catch (e) { |
| 25 | if (e instanceof Error && e.message.startsWith("Not found:")) throw e; |
| 26 | last = e; |
| 27 | } |
| 28 | } |
| 29 | throw last instanceof Error ? last : new Error(String(last)); |
| 30 | } |
| 31 | |
| 32 | export async function fetchJson<T>(url: string): Promise<T> { |
| 33 | let last: unknown; |
| 34 | for (let i = 0; i < 3; i++) { |
| 35 | if (i) await new Promise((r) => setTimeout(r, 1000 * 2 ** i)); |
| 36 | try { |
| 37 | const res = await fetch(url, { headers: { "user-agent": "diffinery-runner", accept: "application/json" } }); |
| 38 | if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); |
| 39 | return (await res.json()) as T; |
| 40 | } catch (e) { |
| 41 | last = e; |
| 42 | } |
| 43 | } |
| 44 | throw last instanceof Error ? last : new Error(String(last)); |
| 45 | } |
| 46 | |
| 47 | export function toolArch(kind: "node" | "go" | "python"): string { |
| 48 | const a = process.arch; |
| 49 | if (kind === "go") return a === "arm64" ? "arm64" : a === "arm" ? "armv6l" : "amd64"; |
| 50 | return a === "arm64" ? "arm64" : a === "arm" ? "armv7l" : "x64"; |
| 51 | } |
| 52 | |
| 53 | export function toolDir(c: BuiltinContext, tool: string, version: string, arch: string): string { |
| 54 | return path.join(c.job.toolCache, tool, version, arch); |
| 55 | } |
| 56 | |
| 57 | export function isCached(dir: string): boolean { |
| 58 | return existsSync(`${dir}.complete`) && existsSync(dir); |
| 59 | } |
| 60 | |
| 61 | /** Downloads a tarball and extracts it into `dir` (stripping the top-level folder). */ |
| 62 | export async function installTarball(c: BuiltinContext, url: string, dir: string, strip = 1): Promise<void> { |
| 63 | c.log(`Downloading ${url}`); |
| 64 | const file = path.join(c.job.tempDir, `tool-${randomUUID()}${url.endsWith(".xz") ? ".tar.xz" : ".tar.gz"}`); |
| 65 | try { |
| 66 | await fetchToFile(url, file); |
| 67 | await rm(dir, { recursive: true, force: true }); |
| 68 | await mkdir(dir, { recursive: true }); |
| 69 | c.log(`Extracting to ${dir}`); |
| 70 | await c.run("tar", [url.endsWith(".xz") ? "-xJf" : "-xzf", file, "-C", dir, `--strip-components=${strip}`], { silent: true }); |
| 71 | await writeFile(`${dir}.complete`, ""); |
| 72 | } finally { |
| 73 | await rm(file, { force: true }); |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | /** Runs `cmd args` and returns trimmed stdout, or null if it fails. */ |
| 78 | export async function probe(c: BuiltinContext, cmd: string, args: string[], env?: NodeJS.ProcessEnv): Promise<string | null> { |
| 79 | const r = await c.exec(cmd, args, { capture: true, silent: true, env }); |
| 80 | if (r.exitCode !== 0) return null; |
| 81 | return (r.stdout || r.stderr).trim(); |
| 82 | } |
| 83 | |
| 84 | export async function readVersionFile(c: BuiltinContext, file: string, kind: "node" | "python" | "go"): Promise<string> { |
| 85 | const p = path.resolve(c.job.workspace, file); |
| 86 | if (!existsSync(p)) throw failure(`The specified ${kind} version file at: ${p} does not exist`); |
| 87 | const content = await readFile(p, "utf8"); |
| 88 | const base = path.basename(p); |
| 89 | if (base === "package.json") { |
| 90 | try { |
| 91 | const j = JSON.parse(content) as { volta?: { node?: string }; engines?: { node?: string }; devEngines?: { runtime?: { name?: string; version?: string } } }; |
| 92 | const v = j.volta?.node ?? j.devEngines?.runtime?.version ?? j.engines?.node; |
| 93 | if (v) return v; |
| 94 | } catch { |
| 95 | /* fallthrough */ |
| 96 | } |
| 97 | throw failure(`No node version found in ${p}`); |
| 98 | } |
| 99 | if (base === "go.mod" || base === "go.work") { |
| 100 | const tc = /^toolchain\s+go(\S+)/m.exec(content); |
| 101 | const g = /^go\s+(\d+(?:\.\d+){0,2})/m.exec(content); |
| 102 | const v = tc?.[1] ?? g?.[1]; |
| 103 | if (!v) throw failure(`No go version found in ${p}`); |
| 104 | return v; |
| 105 | } |
| 106 | if (base === ".tool-versions") { |
| 107 | const names = kind === "node" ? ["nodejs", "node"] : kind === "python" ? ["python"] : ["golang", "go"]; |
| 108 | for (const line of content.split("\n")) { |
| 109 | const [n, v] = line.trim().split(/\s+/); |
| 110 | if (n && v && names.includes(n)) return v; |
| 111 | } |
| 112 | throw failure(`No ${kind} version found in ${p}`); |
| 113 | } |
| 114 | if (base === "pyproject.toml") { |
| 115 | const m = /requires-python\s*=\s*["']([^"']+)["']/.exec(content); |
| 116 | if (m) return m[1]!.replace(/,/g, " "); |
| 117 | throw failure(`No python version found in ${p}`); |
| 118 | } |
| 119 | const first = content.split("\n").map((l) => l.trim()).find((l) => l && !l.startsWith("#")); |
| 120 | if (!first) throw failure(`The ${kind} version file at ${p} is empty`); |
| 121 | return first.replace(/^v(?=\d)/, ""); |
| 122 | } |
| 123 | |
| 124 | /** |
| 125 | * Restores a package-manager cache and registers a post-step save, the way |
| 126 | * setup-node/setup-python/setup-go `cache:` do (same keys and paths, so their |
| 127 | * caches are shared with the official actions). |
| 128 | */ |
| 129 | export async function packageCache(c: BuiltinContext, o: { key: string; paths: string[]; restoreKeys?: string[]; what: string }): Promise<void> { |
| 130 | const paths = o.paths.filter(Boolean); |
| 131 | if (!paths.length) { |
| 132 | c.warn(`Could not determine the ${o.what} cache directory; skipping cache.`); |
| 133 | return; |
| 134 | } |
| 135 | const r = await restoreCache(c, paths, o.key, o.restoreKeys ?? []); |
| 136 | c.setOutput("cache-hit", isExactKeyMatch(o.key, r.matchedKey) ? "true" : "false"); |
| 137 | c.log(r.matchedKey ? `Cache restored from key: ${r.matchedKey}` : `${o.what} cache is not found`); |
| 138 | c.registerPost(`Post ${c.name}`, "success()", async (pc) => { |
| 139 | if (isExactKeyMatch(o.key, r.matchedKey)) { |
| 140 | pc.log(`Cache hit occurred on the primary key ${o.key}, not saving cache.`); |
| 141 | return; |
| 142 | } |
| 143 | if (!paths.some((p) => existsSync(p))) { |
| 144 | pc.log(`The ${o.what} cache directories do not exist; not saving cache.`); |
| 145 | return; |
| 146 | } |
| 147 | await saveCache(pc, paths, o.key); |
| 148 | }); |
| 149 | } |
| 150 | |
| 151 | /** The hash of the first lock file that exists in the workspace root (setup-node/setup-go's default). */ |
| 152 | export function rootLockHash(c: BuiltinContext, input: string, rootFiles: string[]): string { |
| 153 | if (input) return hashFiles(c.job.workspace, inputList(input)); |
| 154 | const found = rootFiles.find((f) => existsSync(path.join(c.job.workspace, f))); |
| 155 | if (!found) throw failure(`Dependencies lock file is not found in ${c.job.workspace}. Supported file patterns: ${rootFiles.join(",")}`); |
| 156 | return hashFiles(c.job.workspace, [found]); |
| 157 | } |