Compare commits

...
9 Commits
Author SHA1 Message Date
peter b5078003ce release: 0.93.2 (bug/ci-node-version-split)
gate / frontend (push) Successful in 3m51s
gate / backend (push) Successful in 6m7s
2026-09-20 21:49:56 +02:00
peter f2c32cfb8e chore(release): 0.93.2 — a Plex amount reads the same in every browser 2026-09-20 21:49:36 +02:00
peter 1453fa4a33 ci: run the gate on branch pushes only, not on release tags
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 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.
2026-09-20 19:47:19 +02:00
peter efe106dfda build: pin one Node major across the dev, CI and build machines
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.
2026-09-20 18:06:24 +02:00
peter ec63056bd7 test(api): assert the export body's brand, not which realm built its Blob
`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.
2026-09-20 16:33:26 +02:00
peter 09becb3d00 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.
2026-09-20 16:33:26 +02:00
peter 8d2ec5e867 release: 0.93.1 (bug/preview-crashes-the-tracker)
gate / frontend (push) Failing after 4m10s
gate / backend (push) Successful in 5m50s
2026-09-20 07:55:37 +02:00
peter 0e9839e73d chore(release): 0.93.1 — the report preview works again 2026-09-20 07:51:16 +02:00
peter 05ab963951 fix(issues): the preview must not ask the tracker for a repository context
The composer's preview did not fail a call — it killed the Gitea process, and
with it the git server and the container registry. `POST /api/v1/markdown` with
`{"Mode": "gfm", "Context": "<repo>"}` makes Gitea resolve every hex-shaped word
as a commit link, and on the API's render path the commit checker it asks is nil:

  panic: runtime error: invalid memory address or nil pointer dereference
  renderhelper.(*commitChecker).IsCommitIDExisting(0x0)
   <- markup.hashCurrentPatternProcessor (html_commit.go:217)

Every report carries hex — a tab id, a request id — so this was not an edge case
but the only case: the preview answered "the tracker isn't answering" every time,
because the tracker had just died.

Measured on throwaway instances, not argued. Same body, same call:
  gfm + Context + "5645b1c0ae01"  -> 1.26.4 CRASH, 1.27.1 CRASH, 1.27.3 200
  gfm + Context, no hex           -> 200
  gfm, no Context                 -> 200, and the HTML keeps its tables, task
                                     lists and chroma-highlighted code blocks
Upstream fixed it in 1.27.2 ("fix: avoid markup render panic", #38698), but a
self-hoster runs whatever version they run, so the payload is what gets fixed.

Sending was never affected: the created issue's own page renders the same body
fine (measured) — a real repo context has a real commit checker. The only loss is
that a hash or a `#123` is not yet a link in the preview; the issue page links it.

The test pins the payload dict, because the defect is in what we send.
2026-09-20 07:51:08 +02:00
15 changed files with 237 additions and 23 deletions
+31 -5
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
24
+21
View File
@@ -7,6 +7,27 @@ 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
The report composer's preview works again.
### Fixes
- Looking at a report before sending it said "the issue tracker isn't answering" — every time, for everyone. The preview asked the tracker to resolve links against the repository, and a report always carries something that looks like a commit hash: an id of the tab, of a request. On a tracker older than Gitea 1.27.2 that request does not fail, it stops the tracker — so the preview now asks for the rendering without the repository, which shows the same tables, lists and code, and can no longer take a tracker down. Sending a report was never affected.
## 0.93.0 — 2026-09-20 ## 0.93.0 — 2026-09-20
A way to report a fault from inside the app: record what you did, mark the steps, attach screenshots — and the app writes the report for you, with the logs of exactly that session attached. A way to report a fault from inside the app: record what you did, mark the steps, attach screenshots — and the app writes the report for you, with the logs of exactly that session attached.
+3 -1
View File
@@ -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 ./
+1 -1
View File
@@ -1 +1 @@
0.93.0 0.93.2
+17 -4
View File
@@ -3,7 +3,7 @@ before a line of this was written (plans/e16-debug-issue.md, S0).
Configured by an admin (sysconfig `issues_gitea_url` / `_repo` / `_token`, the token encrypted at Configured by an admin (sysconfig `issues_gitea_url` / `_repo` / `_token`, the token encrypted at
rest). The repository must be PRIVATE: an issue carries its reporter's log — page titles, filter rest). The repository must be PRIVATE: an issue carries its reporter's log — page titles, filter
values, at Teljes what they typed into search — and a public repository would publish it. That is values, at Teljes (TRACE) what they typed into search — and a public repository would publish it. That is
checked on every send, not only in the admin's "test connection", because a repository can be made checked on every send, not only in the admin's "test connection", because a repository can be made
public after the fact. public after the fact.
@@ -154,9 +154,22 @@ def check(db: Session) -> dict:
def preview(db: Session, text: str) -> str: def preview(db: Session, text: str) -> str:
"""The body as Gitea itself will render it (its markdown flavour, its sanitiser).""" """The body as Gitea itself will render it (its markdown flavour, its sanitiser).
with _client(_settings(db)) as (client, repo):
r = _call(client, "POST", "/markdown", json={"Text": text, "Mode": "gfm", "Context": repo}) **No `Context`, deliberately — a repository context here does not fail the call, it kills the
tracker.** With one, Gitea's post-processor turns every hex-shaped word into a commit link, and
on the API's render path the commit checker it asks is nil: the whole Gitea process segfaults
and restarts, taking the git server (and, here, the container registry) down with it. Every
report carries hex — a tab id, a request id — so this was not an edge case but the only case.
Measured on throwaway instances (2026-09-20): `{"Mode": "gfm", "Context": "<repo>"}` with
`5645b1c0ae01` in the text crashes 1.26.4 and 1.27.1 and is fixed in 1.27.3 — but a self-hoster
runs whatever version they run, so the payload stays safe here rather than relying on theirs.
Dropping `Context` is not a downgrade of the preview: measured against the same body, the HTML
keeps its tables, its task lists and its highlighted code blocks. It only stops resolving
repo-relative links (a hash, a `#123`), which the issue page itself still resolves.
"""
with _client(_settings(db)) as (client, _repo):
r = _call(client, "POST", "/markdown", json={"Text": text, "Mode": "gfm"})
return r.text return r.text
+16
View File
@@ -786,6 +786,7 @@ class FakeGitea:
self.redirect = False # an http:// address behind a proxy that sends it to https self.redirect = False # an http:// address behind a proxy that sends it to https
self.garbage_asset = False # a 2xx that is not the API's answer (a proxy's page) self.garbage_asset = False # a 2xx that is not the API's answer (a proxy's page)
self.patched_body: str | None = None self.patched_body: str | None = None
self.markdown_payloads: list[dict] = [] # what the preview asked the tracker to render
def handler(self, request: httpx.Request) -> httpx.Response: def handler(self, request: httpx.Request) -> httpx.Response:
path = request.url.path.removeprefix("/api/v1") path = request.url.path.removeprefix("/api/v1")
@@ -826,6 +827,7 @@ class FakeGitea:
self.patched_body = json.loads(request.content)["body"] self.patched_body = json.loads(request.content)["body"]
return httpx.Response(201, json={"number": 7}) return httpx.Response(201, json={"number": 7})
if request.method == "POST" and path == "/markdown": if request.method == "POST" and path == "/markdown":
self.markdown_payloads.append(json.loads(request.content))
return httpx.Response(200, text="<h3>Steps</h3>") return httpx.Response(200, text="<h3>Steps</h3>")
return httpx.Response(404, json={"message": "not found"}) return httpx.Response(404, json={"message": "not found"})
@@ -1180,6 +1182,20 @@ def test_the_preview_is_the_trackers_own_rendering(gitea_fake, db, make_user, as
assert stranger.post(f"/api/debug-sessions/{sid}/issue").status_code == 404 assert stranger.post(f"/api/debug-sessions/{sid}/issue").status_code == 404
def test_the_preview_never_asks_the_tracker_for_a_repository_context(
gitea_fake, db, make_user, as_user
):
"""A `Context` makes Gitea hunt for commit links, and on the API's render path that hunt
dereferences a nil checker: the tracker's whole process segfaults. Every report carries a
hex-shaped id, so the payload — not the text — is what has to stay safe (measured against
1.26.4 / 1.27.1, fixed upstream in 1.27.3; a self-hoster's version is not ours to pick)."""
owner = as_user(make_user())
sid = _start(owner)["id"]
text = "| tab | 5645b1c0ae01 |"
assert owner.post(f"/api/debug-sessions/{sid}/preview", json={"text": text}).status_code == 200
assert gitea_fake.markdown_payloads == [{"Text": text, "Mode": "gfm"}]
def test_the_admin_connection_test_names_what_is_wrong(gitea_fake, db, make_user, as_user): def test_the_admin_connection_test_names_what_is_wrong(gitea_fake, db, make_user, as_user):
admin = as_user(make_user(role="admin")) admin = as_user(make_user(role="admin"))
r = admin.post("/api/admin/config/test-issues") r = admin.post("/api/admin/config/test-issues")
+11
View File
@@ -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:
+12 -8
View File
@@ -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"
}, },
+4 -1
View File
@@ -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",
+5
View File
@@ -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);
+11 -2
View File
@@ -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 () => {
+4 -1
View File
@@ -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);
});
+20
View File
@@ -14,6 +14,26 @@ 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",
date: "2026-09-20",
summary: "The report composer's preview works again.",
fixes: [
'Looking at a report before sending it said "the issue tracker isn\'t answering" — every time, for everyone. The preview asked the tracker to resolve links against the repository, and a report always carries something that looks like a commit hash: an id of the tab, of a request. On a tracker older than Gitea 1.27.2 that request does not fail, it stops the tracker — so the preview now asks for the rendering without the repository, which shows the same tables, lists and code, and can no longer take a tracker down. Sending a report was never affected.',
],
},
{ {
version: "0.93.0", version: "0.93.0",
date: "2026-09-20", date: "2026-09-20",