From 09becb3d000ee999826438c8c2544abcf66b473a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 20 Sep 2026 16:33:26 +0200 Subject: [PATCH 1/5] fix(plex): pin the compact currency format instead of inheriting the engine's `{ notation: "compact", maximumFractionDigits: 1 }` leaves the MINIMUM to the engine, and engines disagree: the same options render "$160.0M" on Node 22 and "$160M" on Node 24 (measured 2026-09-20 in node:22-bookworm, the image the server gate runs, against the local Node 24.17). Browsers split along the same line, so a viewer on an older engine saw the padded form. `minimumFractionDigits: 0` makes both render "$160M"; the plain `compact` helper (vote counts) was measured too and already agrees on both. This is why the server gate's frontend job was red on PlexInfo since 09-17 while the local gate was green: the two run different Node versions. --- frontend/src/components/PlexInfo.tsx | 5 +++++ 1 file changed, 5 insertions(+) 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); From ec63056bd730233e283b01a085550a1ca582e456 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 20 Sep 2026 16:33:26 +0200 Subject: [PATCH 2/5] test(api): assert the export body's brand, not which realm built its Blob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `expect(x).toBeInstanceOf(Blob)` asks which realm constructed the body, not whether 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 both assertions passed locally and failed on the server gate, for a reason that says nothing about the code under test. `.text()` is the mirror image: jsdom's Blob does not implement it, so reading the bytes back is green on 22 and red on 24. Both now assert the brand (`[object Blob]`, agreed by both realms) plus size and type. Mutation-checked in node:22-bookworm: dropping `{ raw: true }` from `debugExport` still turns the test red, so the repaired assertion still catches the defect it was written for. core.test.ts also stops building its Response from a jsdom `Blob`: on Node 22 undici does not recognise it as a body and stringifies it, so the fixture's body was the 13 bytes of "[object Blob]" rather than the zip header. A string body is what the sibling test already used. --- frontend/src/lib/api/core.test.ts | 13 +++++++++++-- frontend/src/lib/api/diagnostics.dom.test.ts | 5 ++++- 2 files changed, 15 insertions(+), 3 deletions(-) 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); }); From efe106dfda0dd17e2408cc1469ad487369bf7024 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 20 Sep 2026 18:06:24 +0200 Subject: [PATCH 3/5] 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); +}); From 1453fa4a33894937e519c032e767cacb5c803aa2 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 20 Sep 2026 19:47:19 +0200 Subject: [PATCH 4/5] ci: run the gate on branch pushes only, not on release tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 the whole gate twice to the same verdict (runs #81-#90 are five such pairs). 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, so `tags-ignore` rejects every BRANCH push and silently disables the gate. Measured in go-gitea v1.27.3, where it differs from GitHub. A trigger filter that stops firing is invisible, so the ops `release` now reads the verdict back for the sha it pushed and says so when there is no run for it. --- .gitea/workflows/check.yml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index ee17d5ee..21684623 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -15,7 +15,25 @@ # `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: From f2c32cfb8eb6796878b14f39fd44f56e1636ae2a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 20 Sep 2026 21:49:36 +0200 Subject: [PATCH 5/5] =?UTF-8?q?chore(release):=200.93.2=20=E2=80=94=20a=20?= =?UTF-8?q?Plex=20amount=20reads=20the=20same=20in=20every=20browser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 13 +++++++++++++ VERSION | 2 +- frontend/src/lib/releaseNotes.ts | 12 ++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) 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/VERSION b/VERSION index a16a6df5..827e9630 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.93.1 +0.93.2 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",