Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5078003ce | ||
|
|
f2c32cfb8e | ||
|
|
1453fa4a33 | ||
|
|
efe106dfda | ||
|
|
ec63056bd7 | ||
|
|
09becb3d00 | ||
|
|
8d2ec5e867 | ||
|
|
0e9839e73d | ||
|
|
05ab963951 |
@@ -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,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
@@ -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,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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
Reference in New Issue
Block a user