Every workflow pinned node-version '20'. Node 20 reached end-of-life on
2026-04-30, so for three months CI has been building the artifacts that
go into released images on a runtime that no longer gets security fixes.
Vite 8 also floors at ^20.19, which setup-node's '20' happens to satisfy
today only because it resolves to the latest 20.x -- a floor we were
clearing by accident is not one worth keeping.
24 rather than 22: it is Active LTS until 2026-10-20 and supported
through 2028-04-30, so this pin outlives the 22 line by a year. Node 26
does not ship until October.
The legacy client declared engines "20.x", which would have made every
npm ci in that package print EBADENGINE against the new runner. It is
widened to the floor of the Vite 7 it already runs on, so it now
describes what the package actually supports instead of naming a single
line.
Verified locally on Node 24.13.0: lefthook ci green, both clients
install clean and build, client_v2's unit tests and the
tests_frontend_v2 Playwright suite pass. tests_frontend needs the built
image so it is left to CI; its only dependency declares node >=18.
The setup-node pin itself is untouched and still on v4.0.3, whose action
runtime is the other deprecated Node 20 in the logs -- GitHub already
force-runs it on 24. That is an action upgrade, and a separate change.
The library screenshot shows one of the client's four pages. The two
that are hardest to guess at from a feature list -- spools as draggable
cards grouped by location, and labels built from arbitrary spool fields
-- now sit under it in a two-column table, so they cost one screen's
height between them rather than one each.
They are captured at a narrower viewport than the library shot rather
than scaled down from it: at half width a 1440px page reduces to
unreadable, whereas 900px reflows the dashboard to two columns and the
designer to a tighter grid, so the text survives the downscale. Both are
cropped to a 3:2 aspect and have the nav bar removed -- it is already in
the shot above -- so the pair sits level.
Fixed along the way, since it would otherwise have been immortalised in
the dashboard screenshot: the card separators lost a space where they
met an {#if} boundary, which Svelte trims, rendering "Bambu Lab -Bambu
Green" and "1 kg- Last used 2 days ago". The optional parts are now
built as one expression, which has no block edge to trim.
demo_seed gains a note on why label designs aren't seeded: they're a
blob of a client-side schema, so writing one from Python would duplicate
it. The designer's own "Create your first label" is the honest route.
Grouped by filament, a row had nothing of its own to put in the bold
title slot -- #id, fill, weight and location all have columns -- so it
borrowed the comment when one was set, and dropped the usage status to a
quiet second line behind it.
That assumed comments are short labels. The field is a free-form
textarea: people keep order URLs, multi-line notes and pasted receipts
there. Whatever is in it got the widest, loudest slot in the row, pushed
out "used N ago" -- the one thing every row can say -- and, since .name
is nowrap, ran any newlines together into one line before ellipsing
mid-word.
Rows now always show the usage status. The comment is unabridged in the
inspector, which is the only place it was ever readable in full.
The screenshot is regenerated against the new behaviour, and demo_seed's
comments go back to natural length -- they were written short only to
survive the row.
The old one was seeded with stress_seed.py, which builds a filament name
from one random pool and its color hex from another, so the picture
contradicted itself: "PETG Copper Bronze" had a blue swatch and "PVA
Sparkle Silver" a tan one. That is the first thing a visitor sees.
scripts/demo_seed.py imports every filament from SpoolmanDB instead,
issuing the same request the client's Add-spools flow does, so names,
colors, densities and temperatures come from one source and agree with
each other. Twenty filaments across eight real vendors are pinned by
external id, chosen for material spread and a varied swatch column; if
one leaves the catalog the script fails with the list rather than
quietly drifting.
The spools on top of them are still made up, because that half really is
per-user data -- partial weights, locations, lot numbers, prices,
first/last-used dates, a few archived and a few still sealed -- plus the
handful of extra fields a real user defines, so the inspector shows
content where it used to show its empty state.
Two details are shaped by how the list renders rather than by realism:
spools consume a whole number of grams, since a decimal wraps onto a
second line in the narrow weight column, and comments stay under ~20
characters, since the list shows a comment in place of "used N ago" and
truncated the longer ones mid-word.
lefthook's ci hook grew a client_v2 group, but the style job only ran
npm ci in client/, so all three of its jobs died at exit 127 on missing
./node_modules/.bin binaries -- eslint, prettier and svelte-kit. The
hook is invoked from the repo root and the paths are hardcoded (for
Windows' sake), so nothing resolved them from client/'s tree. That has
been failing every run since the group was added, on a branch where
client_v2 is the client that ships, so its lint, format and type checks
were the ones not running.
client_v2/docs is gitignored scratch space that prettier still walked,
which fails the hook locally on files CI never sees; the ignore files
now agree.
Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
Brings the branch up to 0.25.0 and picks up cosign image signing, the
armv7/arm64 smoke tests, the armv7 greenlet/libstdc++ fix, the
PUID/PGID and Kubernetes SPOOLMAN_PORT entrypoint guards, and the
externaldb unwritable-path fix.
The only conflict was publish-images' needs list, where both sides had
added a job: test-frontend-v2 here, test-multiarch-smoke on master.
Resolved as the union, so nothing publishes until every test job has
passed. The rest of the graph is unchanged, and the new smoke tests
inherit client_v2 coverage for free -- build-arm64 and build-armv7
already download the client_v2 artifact, so booting those images to
/health now also proves the default client is present in them.
Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
The screenshot was the 2023 React client -- a different application from
the one a new visitor now installs. The replacement is client_v2's
library with a spool's details open beside it, which is where anyone
using Spoolman spends their time, and it lives in the repo rather than
on user-content URLs so it can be regenerated with the client.
The feature list gains search/group/filter, which client_v2 makes a
first-class part of the library rather than per-column table filters.
Installation now mentions SPOOLMAN_LEGACY_CLIENT. Someone whose setup
the new client misbehaves on will look here before .env.example, and
the useful thing to know is that both clients ship in every release and
share the API, so falling back costs nothing.
Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
Mirrors tests_frontend, but drives the Svelte client instead of the legacy
React one: same production image, same PostgreSQL-backed compose stack, same
"only the first navigation uses a URL" discipline.
- smoke.spec.ts clicks through every top-bar tab, asserting each route's title
and the app shell, with no browser console errors; a second test proves the
library reached the API rather than falling into its offline state.
- crud.spec.ts walks the first-use happy path. client_v2 folds the legacy
client's three create forms into one "Add spools" modal, so a manufacturer,
a filament and a spool are created by a single submit, then verified back in
the library list and on the locations page.
Two client_v2-specific helpers earn their keep: the top bar renders its nav
twice (desktop + mobile, one hidden by CSS) so nav locators need narrowing to
the visible one, and NumberInput's accessible name picks up its stepper and
help buttons, so numeric fields are found via their form item instead of
getByLabel.
The suite publishes on host port 8001 so it can run alongside the legacy one.
CI gains a test-frontend-v2 job cloned from test-frontend, gating
publish-images alongside it.
Also fixes tests_frontend/run.py: it only built client/dist, but the Dockerfile
copies client_v2/build unconditionally, so the image could not be built from a
clean checkout.
Published images carry no signature, so there is no way for a user to verify
that the image they pull was built by this workflow from this repository.
Sign them keyless, using the Github OIDC token, so verification needs no key
material on either side. The manifest is signed by digest rather than by tag:
every tag produced for a release points at the same digest, and the same
digest is valid in both registries the build pushes to, so signing each tag
would only repeat the same signature.
Verify with:
cosign verify ghcr.io/donkie/spoolman:<version> \
--certificate-identity-regexp '^https://github\.com/Donkie/Spoolman/\.github/workflows/ci\.yml@refs/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
Build the new Svelte client (client_v2) in CI, bake it into the Docker
image alongside the legacy React client, and serve it by default. Set
SPOOLMAN_LEGACY_CLIENT=TRUE to fall back to the old client.
SinglePageApplication is generalized to serve either client: the React
client keeps its index.html + base-path rewriting, while the Svelte
client uses a 200.html SPA fallback, serves prerendered per-route pages,
and serves relative-path assets verbatim. The tweaked-HTML store is moved
off self.html, which StaticFiles uses as its HTML-mode flag.
The Playwright frontend suite targets the React DOM, so it's pinned to
the legacy client via SPOOLMAN_LEGACY_CLIENT for now.
Claude-Session: https://claude.ai/code/session_01VQQtwPBPiMCoZCpBbbqS64
Weblate opens a PR for every translation sync. These are machine-generated,
always scoped to client/public/locales/, and gated by the full CI suite, so
hand-merging each one is pure friction. This enables GitHub auto-merge on
Weblate-authored PRs so they merge themselves once required checks pass.
A scope-guard step reads the PR's file list from the API and refuses to enable
auto-merge unless every changed file is a .json under client/public/locales/,
so the trust is in the diff, not just the author: a compromised Weblate token
can open a PR but can only be auto-merged if it is confined to translations.
Claude-Session: https://claude.ai/code/session_01BQjJiwEbu6bHbeDjPPDm3V
Non-English locales are managed exclusively through Weblate; any manual
edit to an existing translation file gets silently overwritten on the
next Weblate sync. This workflow fails any PR (except Weblate's own,
detected via PR author login) that modifies or deletes an existing
non-English file under client/public/locales/. Adding a brand-new
language file is still allowed so contributors can bootstrap a new
language alongside the client/src/i18n.ts entry.
Claude-Session: https://claude.ai/code/session_01BQjJiwEbu6bHbeDjPPDm3V
Add a browser-driven integration suite (tests_frontend/) that runs against
the real production image + PostgreSQL and drives the UI with Playwright:
a smoke test that navigates every page via the sidebar with no console
errors, and a CRUD test that creates a vendor -> filament -> spool entirely
through the UI. Wired up as `poe itest-frontend` and a new `test-frontend`
CI job (reusing the image built for the backend tests, gating releases).
Navigation goes through real UI buttons (language-independent link targets)
and the app language is forced to English so label matchers are stable
regardless of the runner's browser locale.
This immediately caught a real regression: Starlette 1.3.1 (pulled by a
recent dep bump) removed the `method` kwarg from FileResponse, so
SinglePageApplication 500'd on every static asset and the whole UI was dead.
Drop the removed kwarg — Starlette now derives HEAD handling from the scope.
Claude-Session: https://claude.ai/code/session_01BQjJiwEbu6bHbeDjPPDm3V
This updates all dependencies, as well as makes it compatible with latest PDM which now complains about the cross_platform strategy choice, we instead use inherit_metadata now.