build: pin one Node major across the dev, CI and build machines

The server gate ran node:22 while the dev gate ran node:24, and the two disagree about
Intl.NumberFormat's compact currency padding and about which realm's Blob a Response hands
back — so the frontend job had been red for 73 of its last 90 runs while the local gate was
green, and nothing said so.

Align everything on 24, with `.nvmrc` as the single source of truth: the CI container, the
prod build stage, `engines`, and `@types/node` (which was two majors ahead of the runtime, so
tsc was green over APIs the shipped engine does not have). The backend job's node only runs
the checkout action, so it tracks the pin as hygiene — node 20 is EOL — but deliberately
stays outside the contract.

The pin is held by a contract test that reads every one of those places AND the engine
running it, so the next drift is a red lane on its first run instead of a CI log nobody reads.

Verified on node:24-bookworm (the CI lane: 228 files / 1552 tests, typecheck, lint,
format:check, knip all exit 0) and on node:24-alpine (the prod build stage, v24.21.0, SPA
built). Each assertion of the new test was mutation-checked red.
This commit is contained in:
2026-09-20 18:06:24 +02:00
parent ec63056bd7
commit efe106dfda
7 changed files with 123 additions and 14 deletions
+12 -8
View File
@@ -5,6 +5,7 @@
"packages": {
"": {
"name": "siftlode-frontend",
"license": "AGPL-3.0-only",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -26,7 +27,7 @@
"@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.3.3",
"@testing-library/react": "^16.3.2",
"@types/node": "^26.1.1",
"@types/node": "^24.13.6",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
@@ -44,6 +45,9 @@
"typescript-eslint": "8.46.0",
"vite": "^5.3.4",
"vitest": "^3.2.7"
},
"engines": {
"node": "24.x"
}
},
"node_modules/@alloc/quick-lru": {
@@ -2909,13 +2913,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"version": "24.13.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz",
"integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
"undici-types": "~7.18.0"
}
},
"node_modules/@types/prop-types": {
@@ -6409,9 +6413,9 @@
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
+4 -1
View File
@@ -3,6 +3,9 @@
"private": true,
"license": "AGPL-3.0-only",
"type": "module",
"engines": {
"node": "24.x"
},
"scripts": {
"dev": "vite",
"typecheck": "npm run typecheck:app && npm run typecheck:node && npm run typecheck:e2e",
@@ -40,7 +43,7 @@
"@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.3.3",
"@testing-library/react": "^16.3.2",
"@types/node": "^26.1.1",
"@types/node": "^24.13.6",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
@@ -0,0 +1,80 @@
import { readFileSync } from "node:fs";
import { expect, it } from "vitest";
// Four machines build or check this app, and for months they did not agree on which Node they were.
//
// On 2026-09-20 the server gate had been red for 73 of its last 90 frontend runs while the local gate
// was green, and nothing told anyone: the dev machine ran Node 24, the CI container `node:22-bookworm`,
// the prod build image `node:22-alpine`. The three failures were all engine splits, not code bugs —
// `Intl.NumberFormat({notation:"compact", maximumFractionDigits:1})` renders "$160.0M" on 22 and
// "$160M" on 24 (the minimum is the engine's to choose when you only give it a maximum), and
// `Response.blob()` under jsdom hands back node's own Blob class on 22 but jsdom's on 24, so
// `toBeInstanceOf(Blob)` asserts a realm rather than a type. Both are the engine showing through the
// test — and the first one the engine showing through to the USER, since the browser that renders the
// money string splits along the same line the build image does.
//
// So the version is a contract, not a coincidence, and this test is where it is held. `.nvmrc` is the
// single source of truth; everything below must agree with it, including the engine actually running
// this test. That last assertion is the one that pays: it turns "the local gate and the shipping image
// have drifted apart" from something discovered months later in a CI log nobody reads into a red lane
// on the very first run after the drift.
//
// To move the version: edit `.nvmrc`, then follow this test's failures. Every place it names is a place
// that would otherwise have been forgotten.
const readRoot = (rel: string) => readFileSync(new URL(`../../../${rel}`, import.meta.url), "utf8");
function majorIn(source: string, pattern: RegExp, where: string): number {
const m = pattern.exec(source);
// A rename or a moved line lands here. Re-point the test — the contract did not go away with it.
expect(m, `${pattern} not found in ${where}`).not.toBeNull();
return Number(m![1]);
}
const pinned = majorIn(readRoot(".nvmrc"), /^v?(\d+)\b/, ".nvmrc");
const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as {
engines?: { node?: string };
devDependencies: Record<string, string>;
};
it("pins a plausible Node major", () => {
// Guards against a typo'd `.nvmrc` silently agreeing with nothing (`Number("v24.x") === NaN`).
expect(pinned).toBeGreaterThanOrEqual(24);
});
it("builds the shipped bundle on the pinned engine", () => {
// The image that compiles the SPA our users download.
expect(
majorIn(readRoot("Dockerfile"), /^FROM node:(\d+)-alpine AS frontend$/m, "Dockerfile"),
).toBe(pinned);
});
it("runs the server gate on the pinned engine", () => {
// The frontend job only — the backend job's node runs the checkout action, never our code, so it is
// deliberately not part of this contract (see the note in check.yml).
expect(
majorIn(
readRoot(".gitea/workflows/check.yml"),
/^\s*container: node:(\d+)-bookworm$/m,
"check.yml",
),
).toBe(pinned);
});
it("declares the pinned engine to npm and to tsc", () => {
expect(pkg.engines?.node).toBe(`${pinned}.x`);
// Types a major ahead of the runtime make tsc green over APIs the shipped engine does not have —
// the same defect as the gate split, just moved to compile time.
const types = pkg.devDependencies["@types/node"];
expect(types, "@types/node is gone from devDependencies").toBeTruthy();
expect(majorIn(types!, /^\D*(\d+)\./, "@types/node")).toBe(pinned);
});
it("is itself running on the pinned engine", () => {
const running = Number(process.versions.node.split(".")[0]);
expect(
running,
`this machine runs Node ${process.versions.node}, but the gate and the prod image ship Node ` +
`${pinned}.x. Install it (\`nvm use\` reads .nvmrc) — a green gate on another major does not ` +
`mean the build is green.`,
).toBe(pinned);
});