diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index 3214e781..21684623 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -7,16 +7,38 @@ # 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] +# Branch pushes only. A release pushes `main` and then the `v` tag, and a bare `on: [push]` +# treated the tag as a second push of the IDENTICAL commit — every release ran this whole gate +# twice, to the same verdict, for nothing (runs #81-#90 are five such pairs). `branches: ['**']` +# keeps every branch covered and drops tag refs. +# +# It has to be `branches`, NOT the `tags-ignore` this obviously wants to be. Gitea's matchPushEvent +# requires EVERY named filter to match, and each one bails on the wrong ref kind: `tags-ignore` +# starts with `if !refName.IsTag() { break }`, so a filter meant to skip tags rejects every BRANCH +# push and the gate stops running entirely. `branches` bails the same way on a tag, which is the +# direction we want. (Measured in go-gitea v1.27.3 modules/actions/workflows.go, not assumed — it +# differs from GitHub, where `tags-ignore` alone leaves branches unfiltered.) +# +# If that filter were ever wrong the gate would not run at all — which is why `siftlode release` +# now ends by reading the verdict back for the sha it pushed, and says so out loud when there is +# no run for it. A gate that silently stops running is the failure this repo already lived through. +on: + push: + branches: ["**"] + pull_request: jobs: frontend: runs-on: ubuntu-latest - container: node:22-bookworm + container: node:24-bookworm defaults: run: working-directory: frontend @@ -34,7 +56,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/CHANGELOG.md b/CHANGELOG.md index 90c5ed72..9383445c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ edit the release notes and re-run the generator. Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading). +## 0.93.2 — 2026-09-20 + +An amount on a Plex film reads the same in every browser. + +### Fixes + +- A film's budget and revenue could read "$160.0M" in one browser and "$160M" in another, from the same number: asked only for a limit of one decimal, a browser is free to pad up to it or not. The amount is now written the same way everywhere. + +### Internal + +- The machine that builds a release, the one that checks it and the one it is written on had drifted onto three different versions of the JavaScript engine — the reason those checks quietly disagreed for a month. They are held to one version now, and a check fails the moment any of them moves away from the others. +- Those checks no longer run twice over the same code on every release, and their verdict is read back as part of releasing instead of waiting on a page nobody opens. + ## 0.93.1 — 2026-09-20 The report composer's preview works again. 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/VERSION b/VERSION index a16a6df5..827e9630 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.93.1 +0.93.2 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/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 0614e766..7475f54e 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -35,6 +35,11 @@ const money = (n: number) => notation: "compact", style: "currency", currency: "USD", + // The minimum is load-bearing, not a default written out: given only a maximum, a compact + // format is free to pad to it. The very same options render "$160.0M" on Node 22 and "$160M" + // on Node 24 (measured 2026-09-20 — the server gate was red on exactly this while the local one + // was green), and browsers split along the same line. Pin it rather than inherit the engine's. + minimumFractionDigits: 0, maximumFractionDigits: 1, }).format(n); diff --git a/frontend/src/lib/api/core.test.ts b/frontend/src/lib/api/core.test.ts index c71b2b01..2f23ca1f 100644 --- a/frontend/src/lib/api/core.test.ts +++ b/frontend/src/lib/api/core.test.ts @@ -221,7 +221,7 @@ describe("req — a file, not a parsed answer", () => { // A zip. Without the `raw` branch this falls into `r.json()`, throws on the first byte, and the // download dies as an opaque failure — which no lane would see, because the only consumer test // mocks the api method wholesale. - const zip = new Response(new Blob(["PK"]), { + const zip = new Response("PK", { status: 200, headers: { "content-type": "application/zip", @@ -234,7 +234,16 @@ describe("req — a file, not a parsed answer", () => { expect(r).toBe(zip); expect(r.headers.get("content-disposition")).toContain("siftlode-debug-260919.zip"); - expect(await r.blob()).toBeInstanceOf(Blob); // the body is still there to be read + // NOT `toBeInstanceOf(Blob)`: that asserts which realm built the body, not that there is one. + // Under jsdom the ambient `Blob` is jsdom's class, while `Response.blob()` hands back node's own + // on Node 22 and jsdom's on Node 24 — so the same assertion passed here and failed on the server + // gate (measured 2026-09-20). Nor `.text()`, for the mirror-image reason: jsdom's Blob has no + // such method, so reading the bytes back is green on 22 and red on 24. The brand and the size + // are the two things both realms agree on, and they are what this test means. + const body = await r.blob(); + expect(Object.prototype.toString.call(body)).toBe("[object Blob]"); + expect(body.size).toBe(4); // the body is still there to be read, all four of its bytes + expect(body.type).toBe("application/zip"); }); it("still refuses before the body is touched, so a refusal is shown and not saved", async () => { diff --git a/frontend/src/lib/api/diagnostics.dom.test.ts b/frontend/src/lib/api/diagnostics.dom.test.ts index 68ba8010..af46e429 100644 --- a/frontend/src/lib/api/diagnostics.dom.test.ts +++ b/frontend/src/lib/api/diagnostics.dom.test.ts @@ -42,7 +42,10 @@ describe("debugExport", () => { expect(out.name).toBe("siftlode-debug-20260919-1402-abc123.zip"); // The body is read as bytes, not parsed: `r.json()` on a zip would throw on its first one. - expect(out.blob).toBeInstanceOf(Blob); + // Brand, not `toBeInstanceOf(Blob)` — that asks which realm built the body: under jsdom the + // ambient `Blob` is jsdom's class, while `Response.blob()` returns node's own on Node 22 and + // jsdom's on Node 24, so the instance check passed locally and failed on the server gate. + expect(Object.prototype.toString.call(out.blob)).toBe("[object Blob]"); expect(out.blob.size).toBe(4); }); 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); +}); diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 5757d9ab..611928f1 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,18 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.93.2", + date: "2026-09-20", + summary: "An amount on a Plex film reads the same in every browser.", + fixes: [ + 'A film\'s budget and revenue could read "$160.0M" in one browser and "$160M" in another, from the same number: asked only for a limit of one decimal, a browser is free to pad up to it or not. The amount is now written the same way everywhere.', + ], + chores: [ + "The machine that builds a release, the one that checks it and the one it is written on had drifted onto three different versions of the JavaScript engine — the reason those checks quietly disagreed for a month. They are held to one version now, and a check fails the moment any of them moves away from the others.", + "Those checks no longer run twice over the same code on every release, and their verdict is read back as part of releasing instead of waiting on a page nobody opens.", + ], + }, { version: "0.93.1", date: "2026-09-20",