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
|
# 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]
|
# 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:
|
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 +56,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
|
||||||
|
|||||||
@@ -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).
|
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
|
## 0.93.1 — 2026-09-20
|
||||||
|
|
||||||
The report composer's preview works again.
|
The report composer's preview works again.
|
||||||
|
|||||||
+3
-1
@@ -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 ./
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Generated
+12
-8
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ const money = (n: number) =>
|
|||||||
notation: "compact",
|
notation: "compact",
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
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,
|
maximumFractionDigits: 1,
|
||||||
}).format(n);
|
}).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
|
// 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
|
// download dies as an opaque failure — which no lane would see, because the only consumer test
|
||||||
// mocks the api method wholesale.
|
// mocks the api method wholesale.
|
||||||
const zip = new Response(new Blob(["PK"]), {
|
const zip = new Response("PK", {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": "application/zip",
|
"content-type": "application/zip",
|
||||||
@@ -234,7 +234,16 @@ describe("req — a file, not a parsed answer", () => {
|
|||||||
|
|
||||||
expect(r).toBe(zip);
|
expect(r).toBe(zip);
|
||||||
expect(r.headers.get("content-disposition")).toContain("siftlode-debug-260919.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 () => {
|
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");
|
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.
|
// 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);
|
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[] = [
|
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",
|
version: "0.93.1",
|
||||||
date: "2026-09-20",
|
date: "2026-09-20",
|
||||||
|
|||||||
Reference in New Issue
Block a user