From efe106dfda0dd17e2408cc1469ad487369bf7024 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 20 Sep 2026 18:06:24 +0200 Subject: [PATCH] build: pin one Node major across the dev, CI and build machines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitea/workflows/check.yml | 16 +++- .nvmrc | 1 + Dockerfile | 4 +- docs/development.md | 11 +++ frontend/package-lock.json | 20 +++-- frontend/package.json | 5 +- frontend/src/lib/nodeEngine.contract.test.ts | 80 ++++++++++++++++++++ 7 files changed, 123 insertions(+), 14 deletions(-) create mode 100644 .nvmrc create mode 100644 frontend/src/lib/nodeEngine.contract.test.ts diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index 3214e781..ee17d5ee 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -7,8 +7,12 @@ # runs pytest NATIVELY against a `postgres` service (not the local docker-compose.test.yml, whose # bind-mount is unreliable under a mounted-socket runner); fidelity is kept by installing the SAME pinned # requirements, Postgres 16, and ffmpeg, and by setting SIFTLODE_TEST_REQUIRE_DB=1 so a DB/ffmpeg that -# failed to come up is a hard error, never a silent skip. node 22 matches the prod build image -# (Dockerfile `node:22-alpine`). +# failed to come up is a hard error, never a silent skip. +# +# The frontend container's node major is NOT free: it must equal the prod build image's and the dev +# machine's, because vitest/tsc read the engine's own Intl and web-API implementations and those split +# between majors. `.nvmrc` is the single source of truth and +# `frontend/src/lib/nodeEngine.contract.test.ts` fails this gate if any of them drift apart. name: gate on: [push, pull_request] @@ -16,7 +20,7 @@ on: [push, pull_request] jobs: frontend: runs-on: ubuntu-latest - container: node:22-bookworm + container: node:24-bookworm defaults: run: working-directory: frontend @@ -34,7 +38,11 @@ jobs: # A combined python+node image: python 3.12 (matches the prod Dockerfile) for the tests, and node # so the JS actions (checkout) run. actions/setup-python is NOT usable here — the runner can't reach # GitHub's python-versions download, so it must be baked into the image. - container: nikolaik/python-nodejs:python3.12-nodejs20 + # + # This node runs the checkout action, never our code, so it is deliberately OUTSIDE the engine + # contract the frontend job is held to — it tracks `.nvmrc` as hygiene (node 20 went EOL in April + # 2026), but a missing upstream tag here must never be able to block the gate. + container: nikolaik/python-nodejs:python3.12-nodejs24 services: postgres: image: postgres:16-alpine diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..a45fd52c --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/Dockerfile b/Dockerfile index 67eb44ae..d6b362c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,9 @@ ARG GIT_SHA=unknown ARG BUILD_DATE= # Stage 1: build the frontend SPA -FROM node:22-alpine AS frontend +# The major is pinned to `.nvmrc` and held there by frontend/src/lib/nodeEngine.contract.test.ts: +# this image, the CI gate's image and the dev machine must be ONE engine (see that test for why). +FROM node:24-alpine AS frontend WORKDIR /fe # Deps first, keyed on the lockfile, so editing app source never re-resolves node_modules. COPY frontend/package.json frontend/package-lock.json ./ diff --git a/docs/development.md b/docs/development.md index c82a5d9a..0e7ae2f2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -3,6 +3,17 @@ How to run Siftlode locally and pass the gate. Conventions for the backend code itself live in [`backend/app/README.md`](../backend/app/README.md). +## Node + +One major, everywhere. `.nvmrc` pins it; the CI container, the `Dockerfile`'s frontend build stage, +`frontend/package.json`'s `engines` and `@types/node` must all match it — and so must the machine you +run the gate on. `frontend/src/lib/nodeEngine.contract.test.ts` fails the gate the moment any of them +drift apart, including when yours is the odd one out. `nvm use` in the repo root picks it up. + +This is a contract, not a preference: tsc and vitest read the engine's own `Intl` and web APIs, and +those change between majors — the same currency format renders `$160M` on one and `$160.0M` on the +next. A gate that is green on a major you do not ship is green about nothing. + ## Run it locally The whole stack (Postgres + API + worker) comes up from the localdev compose file: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6eb41273..8d6a49e1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" }, diff --git a/frontend/package.json b/frontend/package.json index bb308fbf..ab3e43d6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/lib/nodeEngine.contract.test.ts b/frontend/src/lib/nodeEngine.contract.test.ts new file mode 100644 index 00000000..90ae85bf --- /dev/null +++ b/frontend/src/lib/nodeEngine.contract.test.ts @@ -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; +}; + +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); +});