From e5c9722b079346d8da0a969dcd07204e1a204ede Mon Sep 17 00:00:00 2001 From: Xiangzhe Date: Fri, 14 Aug 2026 01:10:34 -0300 Subject: [PATCH] fix(deps): pin next to an exact version so a fresh upstream release cannot break installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The published package ships a PREBUILT .next directory, and next start reads build manifests whose shape changes between minors — so the runtime version must be the one that produced the build. With "next": "^16.2.11", every `npm i -g omniroute` resolved whatever Next was latest at INSTALL time. Next 16.3.1 was published 2026-08-13T22:45Z and added `validationLevel` to its server config schema (0 occurrences in 16.2.12, 74 in 16.3.1). Any install after that timestamp boots a 16.2.12-built .next on the 16.3.1 runtime and crashes immediately: TypeError: Cannot read properties of undefined (reading 'validationLevel') Reproduced on the 192.168.0.17 VPS: a fresh global install of the 3.8.50 tarball crashed in a restart loop; the previous install (next 16.3.0) is healthy, and nothing in this repo changed between them. Published 3.8.49 carries the same range, so new user installs are affected too. react/react-dom were already pinned exactly for this reason; this extends the invariant to next, syncs the lockfile range, and adds tests/unit/next-version-pinned.test.ts as the regression guard (asserts the build-coupled deps are exact and that package.json matches the lockfile version the build actually uses). --- package-lock.json | 2 +- package.json | 2 +- tests/unit/next-version-pinned.test.ts | 64 ++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 tests/unit/next-version-pinned.test.ts diff --git a/package-lock.json b/package-lock.json index 877f9a8efa..91dd584ddf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,7 +56,7 @@ "material-symbols": "^0.45.2", "mermaid": "^11.15.0", "monaco-editor": "^0.56.0", - "next": "^16.2.11", + "next": "16.2.12", "next-intl": "^4.12.0", "next-themes": "^0.4.6", "node-machine-id": "^1.1.12", diff --git a/package.json b/package.json index b627f147c2..624ef73d55 100644 --- a/package.json +++ b/package.json @@ -298,7 +298,7 @@ "material-symbols": "^0.45.2", "mermaid": "^11.15.0", "monaco-editor": "^0.56.0", - "next": "^16.2.11", + "next": "16.2.12", "next-intl": "^4.12.0", "next-themes": "^0.4.6", "node-machine-id": "^1.1.12", diff --git a/tests/unit/next-version-pinned.test.ts b/tests/unit/next-version-pinned.test.ts new file mode 100644 index 0000000000..8cb37b0042 --- /dev/null +++ b/tests/unit/next-version-pinned.test.ts @@ -0,0 +1,64 @@ +/** + * next-version-pinned.test.ts — `next` must be pinned to an exact version. + * + * The published package ships a PREBUILT `.next` directory. That build output is + * tightly coupled to the exact Next.js runtime that produced it: `next start` + * reads build manifests whose shape changes between minors. With a caret range, + * `npm i -g omniroute` resolves whatever Next is latest at INSTALL time, so a + * fresh upstream release silently breaks every new install even though nothing + * in this repo changed. + * + * That is not hypothetical: Next 16.3.1 (published 2026-08-13T22:45Z) added + * `validationLevel` to its server config schema, and a `.next` built by 16.2.12 + * crashes at boot with "Cannot read properties of undefined (reading + * 'validationLevel')" — the symbol has 0 occurrences in 16.2.12 and 74 in + * 16.3.1. Range `^16.2.11` picked it up on the VPS install (2026-08-14). + * + * `react`/`react-dom` are already pinned exactly for the same reason; this test + * extends that invariant to `next` and keeps package.json in sync with the + * lockfile version the build actually uses. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +const ROOT = new URL("../../", import.meta.url); +const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[\w.]+)?$/; + +/** Deps whose published artifact is coupled to the exact installed runtime. */ +const MUST_BE_EXACT = ["next", "react", "react-dom"]; + +async function readJson(relative: string): Promise> { + return JSON.parse(await readFile(fileURLToPath(new URL(relative, ROOT)), "utf8")); +} + +test("build-coupled dependencies are pinned to exact versions", async () => { + const pkg = await readJson("package.json"); + const deps = (pkg.dependencies ?? {}) as Record; + + const ranged = MUST_BE_EXACT.filter((name) => deps[name] && !EXACT_VERSION.test(deps[name])); + + assert.deepEqual( + ranged, + [], + "these ship a prebuilt artifact and must be pinned exactly (a range lets a fresh " + + `upstream release break new installs): ${ranged.map((n) => `${n}@${deps[n]}`).join(", ")}` + ); +}); + +test("pinned next version matches the lockfile version the build uses", async () => { + const pkg = await readJson("package.json"); + const lock = await readJson("package-lock.json"); + + const declared = ((pkg.dependencies ?? {}) as Record).next; + const packages = (lock.packages ?? {}) as Record; + const locked = packages["node_modules/next"]?.version; + + assert.ok(locked, "next missing from package-lock.json"); + assert.equal( + declared, + locked, + `package.json declares next@${declared} but the lockfile builds with next@${locked}` + ); +});