| 1 | import { existsSync, readFileSync } from "node:fs"; |
| 2 | import { mkdir, readFile, writeFile } from "node:fs/promises"; |
| 3 | import path from "node:path"; |
| 4 | import { errMsg, failure } from "../job.js"; |
| 5 | import { maxSatisfying, parseVer, satisfies, truthyInput } from "../util.js"; |
| 6 | import type { Builtin, BuiltinContext } from "./index.js"; |
| 7 | import { fetchJson, installTarball, isCached, packageCache, probe, readVersionFile, rootLockHash, toolArch, toolDir } from "./tools.js"; |
| 8 | |
| 9 | interface NodeDistEntry { |
| 10 | version: string; // "v22.11.0" |
| 11 | lts: string | false; |
| 12 | files: string[]; |
| 13 | } |
| 14 | |
| 15 | export function nodeMirror(): string { |
| 16 | return (process.env.DIFFINERY_NODE_MIRROR ?? "https://nodejs.org/dist").replace(/\/$/, ""); |
| 17 | } |
| 18 | |
| 19 | /** Resolves a setup-node version spec against nodejs.org's index. */ |
| 20 | export function resolveNodeVersion(index: NodeDistEntry[], spec: string): string | null { |
| 21 | const s = spec.trim().toLowerCase(); |
| 22 | const arch = `linux-${toolArch("node")}`; |
| 23 | const avail = index.filter((e) => e.files.includes(arch) || e.files.length === 0); |
| 24 | if (s.startsWith("lts/")) { |
| 25 | const code = s.slice(4); |
| 26 | const lts = avail.filter((e) => e.lts && (code === "*" || String(e.lts).toLowerCase() === code)); |
| 27 | if (code.startsWith("-")) { |
| 28 | // lts/-1: previous LTS line |
| 29 | const n = Number(code.slice(1)); |
| 30 | const majors = [...new Set(avail.filter((e) => e.lts).map((e) => parseVer(e.version)![0]))].sort((a, b) => b - a); |
| 31 | const major = majors[n]; |
| 32 | return major === undefined ? null : maxSatisfying(avail.filter((e) => e.lts).map((e) => e.version), String(major))?.replace(/^v/, "") ?? null; |
| 33 | } |
| 34 | return maxSatisfying(lts.map((e) => e.version), "*")?.replace(/^v/, "") ?? null; |
| 35 | } |
| 36 | if (s === "latest" || s === "current" || s === "node") return maxSatisfying(avail.map((e) => e.version), "*")?.replace(/^v/, "") ?? null; |
| 37 | return maxSatisfying(avail.map((e) => e.version), s.replace(/^v/, ""))?.replace(/^v/, "") ?? null; |
| 38 | } |
| 39 | |
| 40 | export const setupNode: Builtin = async (c) => { |
| 41 | let spec = c.input("node-version"); |
| 42 | const file = c.input("node-version-file"); |
| 43 | if (!spec && file) spec = await readVersionFile(c, file, "node"); |
| 44 | const installed = (await probe(c, "node", ["--version"]))?.replace(/^v/, "") ?? null; |
| 45 | let version = installed; |
| 46 | |
| 47 | if (spec) { |
| 48 | const s = spec.trim().replace(/^v(?=\d)/, ""); |
| 49 | let ok = false; |
| 50 | if (installed && !truthyInput(c.input("check-latest"), false)) { |
| 51 | if (/^(lts\/|latest|current|node$)/i.test(s)) { |
| 52 | try { |
| 53 | const target = resolveNodeVersion(await fetchJson<NodeDistEntry[]>(`${nodeMirror()}/index.json`), s); |
| 54 | ok = !!target && parseVer(target)![0] === parseVer(installed)![0]; |
| 55 | } catch (e) { |
| 56 | c.warn(`Could not resolve '${s}' (${errMsg(e)}); using installed node ${installed}.`); |
| 57 | ok = true; |
| 58 | } |
| 59 | } else { |
| 60 | ok = satisfies(installed, s); |
| 61 | } |
| 62 | } |
| 63 | if (ok) { |
| 64 | c.log(`Found in cache @ ${installed} (pre-installed node satisfies '${s}')`); |
| 65 | } else { |
| 66 | let index: NodeDistEntry[]; |
| 67 | try { |
| 68 | index = await fetchJson<NodeDistEntry[]>(`${nodeMirror()}/index.json`); |
| 69 | } catch (e) { |
| 70 | throw failure(`Unable to fetch the Node.js version index: ${errMsg(e)}`); |
| 71 | } |
| 72 | const v = resolveNodeVersion(index, s); |
| 73 | if (!v) throw failure(`Unable to find Node version '${s}' for platform linux and architecture ${toolArch("node")}.`); |
| 74 | const dir = toolDir(c, "node", v, toolArch("node")); |
| 75 | if (!isCached(dir)) { |
| 76 | await installTarball(c, `${nodeMirror()}/v${v}/node-v${v}-linux-${toolArch("node")}.tar.gz`, dir); |
| 77 | } else c.log(`Found in cache @ ${dir}`); |
| 78 | c.addPath(path.join(dir, "bin")); |
| 79 | version = v; |
| 80 | } |
| 81 | } else { |
| 82 | c.log(`No node-version specified; using the pre-installed node ${installed ?? "(none)"}.`); |
| 83 | } |
| 84 | if (version) c.setOutput("node-version", `v${version}`); |
| 85 | |
| 86 | const registry = c.input("registry-url"); |
| 87 | if (registry) await configureRegistry(c, registry); |
| 88 | |
| 89 | let pm = c.input("cache").toLowerCase(); |
| 90 | // setup-node v5+: caches npm automatically when package.json names npm as its package manager. |
| 91 | if (!pm && major(c) >= 5 && truthyInput(c.input("package-manager-cache"), true) && packageJsonUsesNpm(c.job.workspace)) { |
| 92 | c.log("Detected npm as the package manager from package.json's packageManager field. Auto caching has been enabled for npm. If you want to disable it, set package-manager-cache input to false"); |
| 93 | pm = "npm"; |
| 94 | } |
| 95 | if (pm) { |
| 96 | const env = c.toolEnv(); |
| 97 | let dir: string | null; |
| 98 | let lockFiles: string[]; |
| 99 | if (pm === "npm") { |
| 100 | dir = await probe(c, "npm", ["config", "get", "cache"], env); |
| 101 | lockFiles = ["package-lock.json", "npm-shrinkwrap.json", "yarn.lock"]; |
| 102 | } else if (pm === "pnpm") { |
| 103 | dir = await probe(c, "pnpm", ["store", "path", "--silent"], env); |
| 104 | lockFiles = ["pnpm-lock.yaml"]; |
| 105 | } else if (pm === "yarn") { |
| 106 | const ver = await probe(c, "yarn", ["--version"], env); |
| 107 | dir = ver && parseVer(ver)![0] >= 2 ? await probe(c, "yarn", ["config", "get", "cacheFolder"], env) : await probe(c, "yarn", ["cache", "dir"], env); |
| 108 | lockFiles = ["yarn.lock"]; |
| 109 | } else { |
| 110 | throw failure(`Caching for '${pm}' is not supported`); |
| 111 | } |
| 112 | const hash = rootLockHash(c, c.input("cache-dependency-path"), lockFiles); |
| 113 | if (!hash) throw failure("Some specified paths were not resolved, unable to cache dependencies."); |
| 114 | const key = `node-cache-Linux-${toolArch("node")}-${pm}-${hash}`; |
| 115 | c.setOutput("cache-primary-key", key); |
| 116 | await packageCache(c, { key, paths: dir ? [dir] : [], what: pm }); |
| 117 | } |
| 118 | }; |
| 119 | |
| 120 | function major(c: { uses: string }): number { |
| 121 | return Number(/@v?(\d+)/.exec(c.uses)?.[1] ?? 99); |
| 122 | } |
| 123 | |
| 124 | function packageJsonUsesNpm(workspace: string): boolean { |
| 125 | const npm = /^(\^)?npm(@.*)?$/; |
| 126 | try { |
| 127 | const pkg = JSON.parse(readFileSync(path.join(workspace, "package.json"), "utf8")) as { packageManager?: unknown; devEngines?: { packageManager?: unknown } }; |
| 128 | const dev = pkg.devEngines?.packageManager; |
| 129 | for (const o of Array.isArray(dev) ? dev : dev ? [dev] : []) if (typeof (o as { name?: unknown })?.name === "string" && npm.test((o as { name: string }).name)) return true; |
| 130 | return typeof pkg.packageManager === "string" && npm.test(pkg.packageManager); |
| 131 | } catch { |
| 132 | return false; |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | /** setup-node's `registry-url`: an .npmrc in RUNNER_TEMP that reads NODE_AUTH_TOKEN. */ |
| 137 | async function configureRegistry(c: BuiltinContext, registry: string): Promise<void> { |
| 138 | const url = registry.endsWith("/") ? registry : `${registry}/`; |
| 139 | let scope = c.input("scope"); |
| 140 | if (!scope && url.includes("npm.pkg.github.com")) scope = c.job.spec.repo.owner; |
| 141 | if (scope && !scope.startsWith("@")) scope = `@${scope}`; |
| 142 | const prefix = scope ? `${scope.toLowerCase()}:` : ""; |
| 143 | const npmrc = path.join(c.job.tempDir, ".npmrc"); |
| 144 | await mkdir(c.job.tempDir, { recursive: true }); |
| 145 | let contents = ""; |
| 146 | if (existsSync(npmrc)) { |
| 147 | for (const line of (await readFile(npmrc, "utf8")).split("\n")) if (!line.toLowerCase().startsWith(`${prefix}registry`)) contents += `${line}\n`; |
| 148 | } |
| 149 | contents += `${url.replace(/^\w+:/, "")}:_authToken=\${NODE_AUTH_TOKEN}\n${prefix}registry=${url}`; |
| 150 | const alwaysAuth = c.input("always-auth"); |
| 151 | if (major(c) < 5 || alwaysAuth) contents += `\nalways-auth=${alwaysAuth || "false"}`; |
| 152 | await writeFile(npmrc, contents); |
| 153 | c.exportVariable("NPM_CONFIG_USERCONFIG", npmrc); |
| 154 | c.exportVariable("NODE_AUTH_TOKEN", c.toolEnv().NODE_AUTH_TOKEN || "XXXXX-XXXXX-XXXXX-XXXXX"); |
| 155 | } |