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 -4
View File
@@ -7,8 +7,12 @@
# runs pytest NATIVELY against a `postgres` service (not the local docker-compose.test.yml, whose # 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 # 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 # 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 # failed to come up is a hard error, never a silent skip.
# (Dockerfile `node:22-alpine`). #
# 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 name: gate
on: [push, pull_request] on: [push, pull_request]
@@ -16,7 +20,7 @@ on: [push, pull_request]
jobs: jobs:
frontend: frontend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: node:22-bookworm container: node:24-bookworm
defaults: defaults:
run: run:
working-directory: frontend 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 # 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 # 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. # 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: services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
+1
View File
@@ -0,0 +1 @@
24
+3 -1
View File
@@ -10,7 +10,9 @@ ARG GIT_SHA=unknown
ARG BUILD_DATE= ARG BUILD_DATE=
# Stage 1: build the frontend SPA # 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 WORKDIR /fe
# Deps first, keyed on the lockfile, so editing app source never re-resolves node_modules. # Deps first, keyed on the lockfile, so editing app source never re-resolves node_modules.
COPY frontend/package.json frontend/package-lock.json ./ COPY frontend/package.json frontend/package-lock.json ./
+11
View File
@@ -3,6 +3,17 @@
How to run Siftlode locally and pass the gate. Conventions for the backend code itself live in 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). [`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 ## Run it locally
The whole stack (Postgres + API + worker) comes up from the localdev compose file: The whole stack (Postgres + API + worker) comes up from the localdev compose file:
+12 -8
View File
@@ -5,6 +5,7 @@
"packages": { "packages": {
"": { "": {
"name": "siftlode-frontend", "name": "siftlode-frontend",
"license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@@ -26,7 +27,7 @@
"@playwright/test": "^1.61.1", "@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.3.3", "@tailwindcss/postcss": "^4.3.3",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/node": "^26.1.1", "@types/node": "^24.13.6",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
@@ -44,6 +45,9 @@
"typescript-eslint": "8.46.0", "typescript-eslint": "8.46.0",
"vite": "^5.3.4", "vite": "^5.3.4",
"vitest": "^3.2.7" "vitest": "^3.2.7"
},
"engines": {
"node": "24.x"
} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
@@ -2909,13 +2913,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "26.1.1", "version": "24.13.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", "integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~8.3.0" "undici-types": "~7.18.0"
} }
}, },
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
@@ -6409,9 +6413,9 @@
} }
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "8.3.0", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
+4 -1
View File
@@ -3,6 +3,9 @@
"private": true, "private": true,
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"type": "module", "type": "module",
"engines": {
"node": "24.x"
},
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"typecheck": "npm run typecheck:app && npm run typecheck:node && npm run typecheck:e2e", "typecheck": "npm run typecheck:app && npm run typecheck:node && npm run typecheck:e2e",
@@ -40,7 +43,7 @@
"@playwright/test": "^1.61.1", "@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.3.3", "@tailwindcss/postcss": "^4.3.3",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/node": "^26.1.1", "@types/node": "^24.13.6",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1", "@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);
});