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); +});