diff --git a/.tangled/workflows/node-release.yml b/.tangled/workflows/node-release.yml new file mode 100644 index 0000000..c7b6467 --- /dev/null +++ b/.tangled/workflows/node-release.yml @@ -0,0 +1,35 @@ +# Daily check for a Node release on a tracked LTS line newer than the repo's pin, run +# through the existing compat checks (packages/kernel/scripts/{surface,golden, +# node-release-check}.mjs) without moving the pin. This detects and gates; it does not +# bump or commit — a red run means there's a new Node to triage by hand. +# +# `when`/`engine`/`dependencies`/`steps` field names and syntax matched against the +# Tangled docs, fetched 2026-11-01: +# https://tangled.org/docs/spindles/workflows/ +# https://tangled.org/docs/spindles/writing-workflows/ +# +# The `origin` remote isn't live on Tangled yet, so this can't be run there. Verified +# instead by parsing this file as YAML and running each step's command locally, in the +# same order, against a live candidate (see the task's verification notes). + +when: + - event: schedule + schedule: + - cron: "H 7 * * *" + - event: manual + +engine: nixery +dependencies: + nixpkgs: + - nodejs_24 + - pnpm + +steps: + - name: "Install" + command: pnpm install --frozen-lockfile + - name: "Build kernel" + command: pnpm --filter @nibling/kernel build + - name: "Record kernel fixtures" + command: pnpm --filter @nibling/kernel fixtures + - name: "Check Node release" + command: node packages/kernel/scripts/node-release-check.mjs --report=node-release-report.md diff --git a/packages/kernel/README.md b/packages/kernel/README.md index 4af69d9..1856f0a 100644 --- a/packages/kernel/README.md +++ b/packages/kernel/README.md @@ -328,6 +328,8 @@ node scripts/surface.mjs record [--node=] # every builtin's API surface → node scripts/surface.mjs delta # what a Node release added/changed/removed, from two records node scripts/surface.mjs baseline # rewrite compat/divergences.json from the last surface spec run node scripts/bump-node.mjs # move the pin to , re-record the manifest and goldens under it +node scripts/node-releases.mjs [--index=] [--json] [extraMajor…] # newest LTS per tracked major vs. the pin +node scripts/node-release-check.mjs [version] [--report=] # fetch + surface delta + golden --check for a candidate ``` Goldens and surface records refuse to run on any Node but the repo's `.node-version`. diff --git a/packages/kernel/scripts/node-release-check.mjs b/packages/kernel/scripts/node-release-check.mjs new file mode 100644 index 0000000..249fcf2 --- /dev/null +++ b/packages/kernel/scripts/node-release-check.mjs @@ -0,0 +1,156 @@ +// 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(); diff --git a/packages/kernel/scripts/node-releases.mjs b/packages/kernel/scripts/node-releases.mjs new file mode 100644 index 0000000..fcdd9ec --- /dev/null +++ b/packages/kernel/scripts/node-releases.mjs @@ -0,0 +1,64 @@ +// Checks nodejs.org's release index for LTS releases newer than the pin, on the pin's +// major and any extra majors given on the command line. Detection only: this never +// downloads a tarball or runs a check, see node-release-check.mjs for that. +// +// node scripts/node-releases.mjs [--index=] [--json] [extraMajor…] +// Reads https://nodejs.org/dist/index.json (or --index for an offline/deterministic +// run) and, for each tracked major, reports the newest release with `lts !== false` +// and whether it is newer than the pin. --json prints the raw report instead of +// lines of text. Exits 0 either way; callers decide what "newer" means for them. +import { readFileSync } from "node:fs"; +import semver from "semver"; +import { PINNED } from "./pinned-node.mjs"; + +const INDEX_URL = "https://nodejs.org/dist/index.json"; + +/** The majors to check: the pin's major plus any extras, deduped and sorted. */ +export function trackedMajors(pinned, extra = []) { + return [...new Set([semver.major(pinned), ...extra.map(Number)])].sort((a, b) => a - b); +} + +/** Pure: pick the newest LTS release per tracked major from a raw index.json array, and + * say whether it is newer than the pin. `newer` on the pin's own major is a real + * semver comparison; on an extra major (which the pin can't be compared to) it is true + * whenever an LTS release exists, since any such line is one we don't have at all. */ +export function selectReleases(index, pinned, majors) { + const pinnedMajor = semver.major(pinned); + return majors.map((major) => { + const latest = index + .filter((r) => r.lts !== false) + .map((r) => ({ ...r, version: r.version.replace(/^v/, "") })) + .filter((r) => semver.major(r.version) === major) + .reduce((best, r) => (!best || semver.gt(r.version, best.version) ? r : best), null); + const newer = latest ? (major === pinnedMajor ? semver.gt(latest.version, pinned) : true) : false; + return { major, latest: latest ? { version: latest.version, date: latest.date, lts: latest.lts } : null, newer }; + }); +} + +async function fetchIndex(url) { + const res = await fetch(url); + if (!res.ok) throw new Error(`GET ${url}: ${res.status}`); + return res.json(); +} + +async function main() { + const args = process.argv.slice(2); + const indexFlag = args.find((a) => a.startsWith("--index="))?.slice("--index=".length); + const json = args.includes("--json"); + const extra = args.filter((a) => !a.startsWith("--")); + + const index = indexFlag ? JSON.parse(readFileSync(indexFlag, "utf8")) : await fetchIndex(INDEX_URL); + const report = selectReleases(index, PINNED, trackedMajors(PINNED, extra)); + + if (json) { + console.log(JSON.stringify({ pinned: PINNED, releases: report }, null, 2)); + } else { + console.log(`pin: ${PINNED}`); + for (const r of report) { + if (!r.latest) console.log(`${r.major}: no LTS release`); + else console.log(`${r.major}: latest LTS ${r.latest.version} (${r.latest.date})${r.newer ? " — newer than pin" : " — up to date"}`); + } + } +} + +if (import.meta.url === `file://${process.argv[1]}`) main(); diff --git a/packages/kernel/tests/fixtures/guests/builtin-fs-dir.cjs b/packages/kernel/tests/fixtures/guests/builtin-fs-dir.cjs index a00ff79..e1e9226 100644 --- a/packages/kernel/tests/fixtures/guests/builtin-fs-dir.cjs +++ b/packages/kernel/tests/fixtures/guests/builtin-fs-dir.cjs @@ -16,8 +16,11 @@ fs.open("/tmp/nib-dir-probe", "r", (err, fd) => { } catch (fe) { console.log("readFileSync", fe.code, fe.syscall); } - fs.open("/tmp/nib-dir-probe", "w", (we) => console.log("open w", we.code, we.syscall)); - fs.open("/", "r", (rootErr, rootFd) => console.log("open /", rootErr, typeof rootFd)); + // Chained: two opens in flight finish in either order on Node's threadpool. + fs.open("/tmp/nib-dir-probe", "w", (we) => { + console.log("open w", we.code, we.syscall); + fs.open("/", "r", (rootErr, rootFd) => console.log("open /", rootErr, typeof rootFd)); + }); }); }); });