// Bump the Node compat target: moves .node-version and the two fields that must not // drift from it (the root package.json's volta.node, this package's nibling.node), // then re-records the surface manifest and goldens under the new Node. // // node scripts/bump-node.mjs // is a plain Node version, e.g. 24.22.2 (no leading "v"). // 1. Writes to .node-version, the root package.json's volta.node, and // this package's nibling.node. // 2. `volta fetch node@` if Volta doesn't have it installed yet. // 3. `surface.mjs record --node=`, writing compat/node-.json. // 4. Re-records every golden with `--write`. golden.mjs refuses to write under any // Node but the one .node-version just moved to, checked against its own // process.execPath — so this runs golden.mjs through the new Node's own binary // (found under Volta's tool image) rather than loosening that guard. // 5. Runs tests/surface.spec.ts. Clean, there is nothing to triage yet. Otherwise // runs `surface.mjs baseline` to list the new gaps as "untriaged" and says so: // open compat/divergences.json, tag each one, and re-run the spec until green. // Finishes by printing `surface.mjs delta `, the summary of what moved. import { execFileSync } from "node:child_process"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; const ROOT = resolve(fileURLToPath(new URL("..", import.meta.url))); const REPO_ROOT = resolve(ROOT, "../.."); const version = process.argv[2]?.replace(/^v/, ""); if (!version || !/^\d+\.\d+\.\d+$/.test(version)) { console.error("usage: node scripts/bump-node.mjs (e.g. 24.22.2)"); process.exit(1); } const nodeVersionPath = resolve(REPO_ROOT, ".node-version"); const rootPkgPath = resolve(REPO_ROOT, "package.json"); const kernelPkgPath = resolve(ROOT, "package.json"); const platformPath = resolve(ROOT, "src/shared/platform.ts"); const readJson = (p) => JSON.parse(readFileSync(p, "utf8")); const writeJson = (p, v) => writeFileSync(p, `${JSON.stringify(v, null, 2)}\n`); const from = readFileSync(nodeVersionPath, "utf8").trim(); if (from === version) { console.error(`.node-version is already ${version}`); process.exit(1); } // 1. Move the pin and every mirror pinned-node.mjs checks against it together, so // nothing in between sees a half-moved pin (it would throw on exactly that). writeFileSync(nodeVersionPath, `${version}\n`); const rootPkg = readJson(rootPkgPath); rootPkg.volta.node = version; writeJson(rootPkgPath, rootPkg); const kernelPkg = readJson(kernelPkgPath); kernelPkg.nibling = { ...kernelPkg.nibling, node: version }; writeJson(kernelPkgPath, kernelPkg); // src/shared/platform.ts's NODE_VERSION ships inside the browser runtime bundle, so it // can't be derived at build time the way build.mjs's NODE_NOTICE is; pinned-node.mjs // only checks it matches, so the literal has to move here. const platformSrc = readFileSync(platformPath, "utf8"); const platformMatch = platformSrc.match(/export const NODE_VERSION = "([^"]+)";/); if (!platformMatch) throw new Error(`${platformPath}: NODE_VERSION literal not found in the expected shape`); writeFileSync(platformPath, platformSrc.replace(platformMatch[0], `export const NODE_VERSION = "${version}";`)); console.log(`.node-version, volta.node, nibling.node, platform.ts NODE_VERSION: ${from} -> ${version}`); // 2. Make sure Volta has that Node installed. const bin = resolve(homedir(), ".volta/tools/image/node", version, "bin/node"); if (!existsSync(bin)) { console.log(`volta fetch node@${version}…`); execFileSync("volta", ["fetch", `node@${version}`], { stdio: "inherit" }); } if (!existsSync(bin)) throw new Error(`volta fetch node@${version} did not install ${bin}`); // 3. Record the surface manifest under the new Node, by binary path — record() only // requires the pin to match when it's running on its own process.execPath. execFileSync(process.execPath, [resolve(ROOT, "scripts/surface.mjs"), "record", `--node=${bin}`], { cwd: ROOT, stdio: "inherit" }); // 4. Re-record goldens under the new Node. --write checks process.execPath against the // freshly-moved pin, so run golden.mjs through itself instead of this script's // own (still-old) Node. execFileSync(bin, [resolve(ROOT, "scripts/golden.mjs"), "--write"], { cwd: ROOT, stdio: "inherit" }); // 5. See whether the new surface needs triage. A clean run means no new gaps; a failing // one gets baselined so the untriaged entries are visible, and the user is told to // finish the job. const PORT = process.env.PORT ?? "8951"; const playwright = resolve(ROOT, "node_modules/.bin/playwright"); let specClean = true; try { execFileSync(playwright, ["test", "tests/surface.spec.ts", "--timeout=20000", "--global-timeout=220000"], { cwd: ROOT, env: { ...process.env, PORT }, stdio: "inherit", }); } catch { specClean = false; } if (specClean) { console.log("tests/surface.spec.ts is clean under the new Node: nothing to triage."); } else { console.log("tests/surface.spec.ts failed: new gaps need a reason in compat/divergences.json."); execFileSync(process.execPath, [resolve(ROOT, "scripts/surface.mjs"), "baseline"], { cwd: ROOT, stdio: "inherit" }); console.log( `Open compat/divergences.json and tag every "untriaged" entry (todo, refuse, internal, ` + `deprecated, or accepted — see tests/surface.spec.ts's header), then re-run ` + `\`PORT=${PORT} npx playwright test tests/surface.spec.ts\` until it passes.`, ); } // 6. The summary: what the surface gained or lost between the two pins. console.log(`\nsurface.mjs delta ${from} ${version}:`); execFileSync(process.execPath, [resolve(ROOT, "scripts/surface.mjs"), "delta", from, version], { cwd: ROOT, stdio: "inherit" });