// Fetches a candidate Node release, verifies it, and runs the existing compat checks // against it without moving the pin: surface.mjs record + delta, then golden.mjs // --check. Needs the demo apps' node_modules first (`npm run build && npm run // fixtures`), or golden.mjs crashes on `Cannot find module 'express'`. // // node scripts/node-release-check.mjs [version] [--index=] [--extra=22,26] // [--report=] [--cache=] // With no version, auto-detects via node-releases.mjs: every tracked major (the // pin's, plus --extra) whose newest LTS release beats the pin. With a version, // checks exactly that one, skipping detection (useful to re-run a known candidate). // Downloads the official tarball for this platform/arch from nodejs.org/dist, // checks it against that release's SHASUMS256.txt, and extracts it under --cache // (default: os.tmpdir()/nib-node-release/). // // Writes a markdown report to --report (default: node-release-report.md next to this // script) and also prints it. Exit code: 1 if any checked release has a non-empty // surface delta or golden diff, 0 if every checked release is clean, 0 ("up to date") // if nothing was newer than the pin. import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { PINNED } from "./pinned-node.mjs"; import { selectReleases, trackedMajors } from "./node-releases.mjs"; const HERE = fileURLToPath(new URL(".", import.meta.url)); const SURFACE = resolve(HERE, "surface.mjs"); const GOLDEN = resolve(HERE, "golden.mjs"); const DIST = "https://nodejs.org/dist"; function parseArgs(argv) { const flag = (name) => argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3); const version = argv.find((a) => !a.startsWith("--")); return { version, index: flag("index"), extra: flag("extra")?.split(",").filter(Boolean) ?? [], report: flag("report") ?? resolve(HERE, "node-release-report.md"), cache: flag("cache") ?? join(tmpdir(), "nib-node-release"), }; } async function fetchText(url) { const res = await fetch(url); if (!res.ok) throw new Error(`GET ${url}: ${res.status}`); return res.text(); } async function fetchBuffer(url) { const res = await fetch(url); if (!res.ok) throw new Error(`GET ${url}: ${res.status}`); return Buffer.from(await res.arrayBuffer()); } /** Downloads node-v--.tar.gz, checks it against that * release's SHASUMS256.txt, extracts it under cacheDir, and returns the node binary's * path. Skips the download if that binary already exists (the cache is content-addressed * by version). */ async function fetchCandidate(version, cacheDir) { const plat = `${process.platform}-${process.arch}`; const name = `node-v${version}-${plat}`; const dest = resolve(cacheDir, version); const bin = resolve(dest, name, "bin", "node"); if (existsSync(bin)) return bin; mkdirSync(dest, { recursive: true }); const shasums = await fetchText(`${DIST}/v${version}/SHASUMS256.txt`); const file = `${name}.tar.gz`; const want = shasums.split("\n").find((l) => l.endsWith(` ${file}`))?.split(/\s+/)[0]; if (!want) throw new Error(`${file} not listed in v${version}'s SHASUMS256.txt (platform not shipped for this release?)`); const tarball = await fetchBuffer(`${DIST}/v${version}/${file}`); const got = createHash("sha256").update(tarball).digest("hex"); if (got !== want) throw new Error(`${file}: checksum mismatch (want ${want}, got ${got})`); const tarPath = resolve(dest, file); writeFileSync(tarPath, tarball); const r = spawnSync("tar", ["xf", tarPath, "-C", dest], { encoding: "utf8" }); if (r.status !== 0) throw new Error(`tar xf ${tarPath}: ${r.stderr}`); if (!existsSync(bin)) throw new Error(`expected ${bin} after extracting ${file}`); return bin; } function run(cmd, args) { const r = spawnSync(process.execPath, [cmd, ...args], { encoding: "utf8", maxBuffer: 64 << 20 }); return { ...r, output: `${r.stdout ?? ""}${r.stderr ?? ""}` }; } /** Fetches, records, deltas and checks goldens for one candidate. Returns a report * section and whether it found any change. */ async function checkRelease(version, cacheDir) { const bin = await fetchCandidate(version, cacheDir); const record = run(SURFACE, ["record", `--node=${bin}`]); if (record.status !== 0) throw new Error(`surface.mjs record --node=${bin}: ${record.output}`); const delta = run(SURFACE, ["delta", PINNED, version]); const golden = run(GOLDEN, ["--check", `--node=${bin}`]); const deltaEmpty = /^0 added or changed, 0 removed/m.test(delta.output.trim()); const goldenClean = golden.status === 0; const changed = !deltaEmpty || !goldenClean; const section = [ `## Node ${version}`, "", "### Surface delta", "```", delta.output.trim(), "```", "", "### Golden check", "```", golden.output.trim(), "```", "", changed ? "**Result: changed — triage needed.**" : "**Result: clean.**", ].join("\n"); return { changed, section }; } async function main() { const { version, index, extra, report, cache } = parseArgs(process.argv.slice(2)); const targets = version ? [version] : selectReleases( index ? JSON.parse(readFileSync(index, "utf8")) : await (await fetch("https://nodejs.org/dist/index.json")).json(), PINNED, trackedMajors(PINNED, extra), ) .filter((r) => r.newer && r.latest) .map((r) => r.latest.version); if (!targets.length) { const text = `# Node release check\n\nPin: ${PINNED}. Nothing newer on a tracked line — up to date.\n`; writeFileSync(report, text); console.log(text); process.exit(0); } const sections = []; let anyChanged = false; for (const version of targets) { const { changed, section } = await checkRelease(version, cache); anyChanged ||= changed; sections.push(section); } const text = `# Node release check\n\nPin: ${PINNED}.\n\n${sections.join("\n\n")}\n`; writeFileSync(report, text); console.log(text); process.exit(anyChanged ? 1 : 0); } if (import.meta.url === `file://${process.argv[1]}`) main();