release: 0.93.2 (bug/ci-node-version-split)
gate / frontend (push) Successful in 3m51s
gate / backend (push) Successful in 6m7s
gate / frontend (push) Successful in 3m51s
gate / backend (push) Successful in 6m7s
This commit is contained in:
@@ -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<ver>` 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+3
-1
@@ -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 ./
|
||||
|
||||
@@ -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:
|
||||
|
||||
Generated
+12
-8
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user