Infrastructure hardening: CI enforcement, supply chain, updater, diagnostics #14

Merged
dtourolle merged 10 commits from chore/infra-hardening into master 2026-08-21 17:33:54 +00:00
10 Commits
Author SHA1 Message Date
dtourolle f11f5eddd5 docs(specs): record the Linux native-video compositing spike
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 15m3s
🏗️ Build and Test JellyTau / Supply Chain (pull_request) Successful in 38s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 20s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m11s
Adds the spike write-up and re-opens finding 2 of
playback-backend-unification.md, which concluded that video cannot unify
on a native engine because the webview owns the surface.

That finding's general form has since been falsified on Android, where
native video composites behind a transparent WebView and ships on by
default. The evidence behind it was also entirely about foreign-window
embedding -- mpv's render API, drawing into a GL context we own inside
Tauri's own GTK tree, was never tested. The spike tests that one claim
and comes back green on Linux for both X11 and Wayland, bar the Tauri
default_vbox() half of G1.

Findings 3-6 are deliberately left standing. Finding 3 in particular --
mpv has no adaptive bitrate -- is an independent disqualifier that a
green compositing result does not clear, and the spike says so rather
than reading as a green light.

The next-free-id line moves to UR-079 / IR-033 / DR-219: this branch
allocated UR-077 and UR-078 for the updater and diagnostics work, and
DR-215 through DR-218 with them, after that line was last written.

Authored in a parallel session working in the same checkout; committed
here so it travels with the rest of the branch.
2026-08-21 19:06:13 +02:00
dtourolle f3fa45f742 feat(diagnostics): persistent redacted logging and an exportable bundle
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 22m12s
🏗️ Build and Test JellyTau / Supply Chain (pull_request) Successful in 37s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 11s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m10s
The app forgot everything it did the moment it exited. The Rust half
logged through env_logger to stdout only -- invisible to anyone who
launched from a desktop icon, and on Android worse than that: stdout is
not logcat, so the backend produced no visible output at all on the
platform carrying this project's hardest bugs. The autoplay deadlock,
the truncated-stream restart and the background-audio stall were all
diagnosed by talking a user through `adb logcat`, because there was no
other way to see anything. A panic left nothing behind at all.

Logs now go to a size-capped rotating file, to logcat on Android, and to
the webview console in dev. A panic is recorded with its backtrace before
the process dies. The frontend's messages are forwarded into the same
file, so one timeline holds both halves of the app in order -- which is
what makes a race between them legible after the fact, and races between
them are the expensive bug class here.

Redaction runs in the log FORMATTER, not at export time. A credential
sitting in a file on the device is already a disclosure; stripping it on
the way out would be too late. The exporter redacts a second time to
cover files written by builds that predate this. api_key, X-Emby-Token,
Authorization, "AccessToken" and Token="..." all reduce to [REDACTED],
while host, item ids and filenames are deliberately kept -- a log scrubbed
of those is one nobody can debug anything from. Server URLs keep scheme
and host and drop any embedded user:pass@.

Two things the tests caught that review would not have:

  - redact_headers recursed on its own output. The replacement keeps the
    header NAME, so the next call matched the same header forever; the
    test died with a stack overflow. It is a forward scan now.
  - The frontend forwarder used `void plugin.error(...)`. `void` discards
    a promise's value but not its rejection, so in any webview without
    IPC -- a unit test, SSR, a browser preview -- every log line became an
    unhandled rejection. 20 of them showed up the first time coverage
    ran. Each call now attaches a catch.

Only info and above cross the IPC boundary: debug is per-tick player
state and forwarding it would be thousands of calls a minute for output
nobody reads. A failing forwarder never propagates and never prevents the
console write.

Nothing is transmitted anywhere. The export writes a zip and reports its
path; the user attaches it themselves, which is also what keeps this from
becoming telemetry. An Android share intent is explicitly out of scope --
it is Kotlin work that belongs with the other native code.

The panic hook chains to the previous hook rather than replacing it,
because utils/lock.rs installs a silencing hook around tests that provoke
poisoned locks on purpose.

Spec in docs/specs/diagnostics-and-logging.md; UR-078 / DR-218 / UT-209.

Verified: 1079 frontend tests and the coverage gate, 759 Rust tests,
clippy -D warnings, svelte-check 0 errors, and cargo check for
aarch64-linux-android.
2026-08-21 18:58:57 +02:00
dtourolle fb72bf3005 docs(ci): drop the runner-health references the amend missed
The commit that removed .gitea/workflows/runner-health.yml was amended
with a git add whose pathspec named the already-deleted file, so the add
aborted and only the deletion was staged -- leaving ci-operations.md
still describing a scheduled job that no longer exists.

The runner section now says what to check by hand and why there is no
job: on a single-slot runner a daily job takes the slot and pulls the
builder image to run df, and df inside a container does not reliably
describe the host disk.
2026-08-21 18:58:39 +02:00
dtourolle 6897b290ed docs: add the governance and CI-operations files the project never had
The repo had no SECURITY.md, CONTRIBUTING.md, code of conduct, or issue
and PR templates. For a client that handles Jellyfin credentials and
ships signed binaries, the missing one that actually matters is
SECURITY.md: there was no stated way to report a vulnerability
privately, so the only available channel was the public tracker.

CONTRIBUTING.md documents the gates as they now stand, including the
three ratchets and which direction each is allowed to move, and the two
rules that surprise people: bug fixes start with a failing test, and
Jellyfin's taxonomy stays in Rust.

The bug template asks the three playback questions -- streaming or
downloaded, transcoding or direct, music or video -- because those
answers decide which of several very different code paths a report is
about, and reconstructing them over several round trips is most of the
cost of a playback bug report.

docs/build/ci-operations.md is the missing operations manual: how to
change the builder image and in what order (image pushed before the
workflow that names it, or CI breaks), why tags are dated rather than
:latest or per-SHA, what each secret is for, and what losing the updater
private key would mean -- installed desktop clients only accept payloads
signed by the key matching the public key they shipped with, so losing it
means everyone reinstalls by hand.

Disk exhaustion on the runner is documented as a manual check rather than
a scheduled job. A daily job would occupy the only slot on a single-slot
runner and pull the whole builder image to run `df` -- and `df` inside a
container does not reliably describe the host's disk, so it would spend
real build capacity reporting a number that might be wrong. What the doc
records instead is the part that is actually hard to rediscover: the
symptoms (cargo dying mid-link, docker refusing to pull, actions/cache
quietly not saving) and that `docker volume prune` needs `-a` to touch
named volumes, which is how it filled up unnoticed.

Two things in these docs are stated plainly because they are true and
were not written down anywhere: without branch protection every gate in
the pipeline is advisory, and the Gitea instance -- canonical remote,
signing secrets, registry, runner -- is not backed up by anything in this
repository.
2026-08-21 18:45:40 +02:00
dtourolle 3211c96ecf feat(updater): in-app update on desktop, releases link on Android
Anyone who installed an AppImage or ran the Windows installer was frozen
on that version forever. Nothing in the app ever mentioned a new release
existed, and the release notes were the only announcement.

Desktop now checks a signed manifest, shows the version and its notes in
Settings, and installs and relaunches on request. The signature check is
the whole point: it is what stops a substituted download from being
installed by the app itself. Windows binaries stay unsigned for
SmartScreen purposes -- that is a code-signing certificate, a separate
problem -- but the update payload is verified against our own key.

Android is deliberately not wired to the updater. An app may not replace
its own APK; that is the package installer's job, and the plugin has no
Android implementation. It gets a link to the releases page instead of a
button that would throw.

The plugins are gated with a target-triple cfg rather than
cfg(desktop). Cargo only evaluates target cfgs in a [target.'cfg(..)']
table, so cfg(desktop) matches nothing, silently drops the dependency,
and fails much later with "Permission updater:default not found" -- which
is exactly what the first attempt here did.

Where the manifest lives took some finding. This Gitea serves
/releases/download/<tag>/<asset> but 404s on
/releases/latest/download/<asset> (verified against a real asset), so
there is no stable latest-release URL. The gitea-pages branch is
force-pushed wholesale by publish-docs.yml, so it cannot host the file
either. latest.json therefore gets its own orphan branch, read over the
raw-file URL, and is published from a scratch repo in RUNNER_TEMP rather
than by switching branches in the checkout -- doing that would have left
the following steps standing on a one-commit history, and the next step
but one runs release:notes against the real commit range.

Also fixed, all of it release-integrity:

  - "appimage" is in bundle.targets. The release notes have advertised an
    AppImage for months; tauri.conf.json never built one, the artifact
    step globbed for *.AppImage, found nothing, and said nothing. The
    step now fails instead.
  - The .AppImage.tar.gz/.sig pair and the NSIS .sig are collected. A
    manifest referencing a signature that was never uploaded fails only
    on the user's machine, so the manifest step also refuses to write an
    entry with an empty signature.
  - Release notes are generated by release:notes from the traceability
    graph, which is what CLAUDE.md has asked for all along, instead of a
    fixed heredoc that said "see CHANGELOG.md for detailed changes" and
    linked "GitHub Issues" on a Gitea-hosted project.
  - The notes tell users how to verify a download with SHA256SUMS.

Requirements UR-077 / DR-217, tests UT-208 (12 cases over the version
comparison and the platform decision, including that a pre-release does
not offer itself as an upgrade to the matching release).

Verified: 1070 frontend tests, cargo check for both the host and
aarch64-linux-android (confirming the plugins are absent there), clippy
-D warnings, svelte-check 0 errors.
2026-08-21 18:41:50 +02:00
dtourolle 96abc3afef docs(specs): make "a spec becomes an architecture doc" the written rule
The sixteen specs folded in last commit were folded because someone noticed
they had gone stale, not because anything said they should be. Without the rule
written down the directory drifts straight back to a mix of promises and
descriptions, and neither can be trusted: you cannot tell from a file whether it
describes the build or proposes a change to it.

So: docs/specs/ holds only unshipped work, there is no "Implemented" resting
state, and the fold-in and the deletion happen in the same commit.

The template now asks for the destination architecture doc **up front**, which
is a design check rather than bookkeeping — a feature that fits no existing doc
usually has an unclear layer assignment, and it is cheaper to find that out at
spec time. It also tells the author which half of what they are writing is
durable (invariants, rejected alternatives, the defect a decision prevents) and
which half dies with the file (phases, migration steps, acceptance criteria).

The review checklist gains a Lifecycle section, including the case that gets
lost otherwise: out-of-scope work worth doing has to be written where it will
still be found after the spec is gone.
2026-08-21 18:29:09 +02:00
dtourolle f6653e6a8b ci(security): add a supply-chain gate, checksums and an SBOM
The project shipped signed Android builds and unsigned desktop binaries
with no vulnerability scanning of any kind. Nothing checked the ~500
crate Rust graph or the JS packages against an advisory feed, and nothing
checked that what we redistribute inside an MIT bundle permits it.

The first cargo-deny run found eight vulnerabilities and one
unsoundness -- bytes, four in rustls-webpki, time, two in quick-xml and
rand -- every one of them closed by a `cargo update` nobody had a reason
to run. That update is in this commit; 740 Rust tests and clippy
-D warnings pass on the new lockfile.

Two structural fixes matter as much as the gate itself:

  - deny.toml scopes the graph to the targets we actually ship. Without
    it the Apple targets pull in plist -> quick-xml and report two DoS
    advisories against a crate that is in no binary we release. Ignoring
    those by ID would silence them everywhere, including where they
    would matter; scoping makes them correctly absent.

  - libmpv is pinned by rev instead of branch = "master". A branch means
    the revision is whatever Cargo.lock happens to hold and any
    `cargo update` silently substitutes new upstream code -- in the one
    dependency that is not from crates.io and that links a C library
    into the player. The rev is the commit already locked, so this pins
    current behaviour rather than changing it.

Licence findings are recorded rather than waved through. libmpv and
libmpv-sys are LGPL-2.1, satisfied here by dynamic linking against the
system library; deny.toml carries the two obligations that follow (keep
the linkage dynamic, ship libmpv's licence text with any bundle carrying
the .so). MPL-2.0 crates are file-level copyleft and fine unmodified.

Releases now publish SHA256SUMS (verified in-job with `sha256sum -c`
before upload) and a CycloneDX SBOM for both halves, so "does this
release contain <vulnerable crate>?" has an answer that is not "rebuild
the tag and re-resolve it".

Workflows pin jellytau-builder:2026.08 instead of :latest. While every
job said :latest, rebuilding the image changed what every build compiled
against, including rebuilds of old release tags.

Also folded in, because both were the same class of problem:

  - publish-docs.yml downloaded mdBook from GitHub releases into
    /usr/local/bin at job time -- a toolchain install in CI, which
    CLAUDE.md explicitly forbids, and a hard dependency on GitHub's CDN
    at publish time. It is in the builder image now.

  - extract-traces.ts only ever read .ts/.svelte/.rs, so every
    requirement implemented by *configuration* was invisible to the
    matrix that measures it. DR-205, DR-206, DR-207 and DR-215 all carry
    TRACES comments nothing read, and each counted as uncovered while
    being covered. Coverage was really 90%, not 88%; MIN_THRESHOLD moves
    to 89 accordingly. CI workflows stay excluded and there is a test
    saying why: traceability-check.yml quotes "a TRACES: comment" beside
    deliberately-undefined example IDs, which the extractor would read
    as real traces and then fail its own dangling-ID check.

Supply-chain requirement is DR-216.

🔴 The builder image must be rebuilt and pushed
(scripts/build-builder-image.sh 2026.08) before this reaches master --
the workflows now name a tag and tools that do not exist in the registry
yet.
2026-08-21 18:25:38 +02:00
dtourolle 32043a2152 docs: fold shipped specs into the architecture docs and delete them
A spec was a promise; sixteen of them had become descriptions of code that
already shipped, sitting beside four that describe work still outstanding, with
nothing in the file telling the two apart. Half the statuses were also wrong —
audio-equalizer read "Accepted" with the EQ live on both platforms, the native
video spec said the flag stays off after the default was flipped on.

The shipped designs move into docs/architecture, which is the maintained
description of the build, and the spec files go. Git history keeps the
originals; what a future change still needs is carried across:

- 01-rust-backend: favourites rewritten (the old section named a file that no
  longer exists and called shipped buttons "planned"), domain vocabulary owned
  by Rust (SearchScope, exclusions, the bitrate ladder), background workers
- 02-svelte-frontend: app shell and chrome, library mosaic, series/episode
  navigation, downloaded browse, safe-area insets, native-video store, logging
- 03-data-flow: locally-indexed search
- 05-platform-backends: audio settings on ExoPlayer, the equalizer's band
  vocabulary, native video compositing, the background-audio handoff
- 06-downloads-and-offline: one storage model, offline catalog visibility
- 09-security: path confinement and input binding

docs/specs/README.md now says what the directory is for and where each shipped
design went. Deferred work the specs recorded is kept beside the code it
concerns rather than lost: season-bounded autoplay, the two dead search
commands, why indexing is a full crawl.

requirements.md had fourteen stale statuses — Android audio parity still read
"Linux only", DR-150 still said the native-video default was off, DR-190 was
Proposed after DR-196 implemented it, and five tooling requirements were
Proposed after landing. Three unbuilt specs suggested requirement ids that have
since been allocated to other work; each now carries a warning.
2026-08-21 18:15:58 +02:00
dtourolle 8f5c9023d0 ci: make the frontend gates real, and fix the coverage script
The repo configured four frontend gates and enforced one of them. eslint
and prettier ran in no workflow and no hook; `bun run check` ran only in
build-release.yml, so a type error could sit on master until somebody cut
a tag; and `bun run test:coverage` had been dead for months.

CI (build-and-test.yml) now runs format:check, lint, check and coverage
alongside the existing boundary and doc-link tripwires.

The coverage script failure was a version mismatch, not a config problem:
@vitest/coverage-v8 resolved to 4.1.10, whose peer range pins vitest
exactly, while package.json asked for ">=1.0.0 <5.0.0" and got 4.0.16 --
every run died on a missing BaseCoverageProvider export. The loose range
is what allowed the pair to drift, so it is now ^4.1.10.

Two ratchets, same policy as MIN_THRESHOLD in traceability-check.yml:

  eslint  --max-warnings=159   (0 errors; 159 is today's backlog, only
                                ever lower it)
  vitest  thresholds           (statements 51 / branches 45 /
                                functions 46 / lines 52, measured at
                                54.6 / 48.7 / 49.6 / 55.1)

no-console is promoted from "off" to "error": the logger-facade
migration it was waiting on is finished -- 8 calls remained, 2 of them
real stragglers in the settings page, now on the facade the file already
imported. The sink itself, tests, and scripts/ are exempted; a CLI whose
stdout is the product is not a stray debug statement.

The threshold was verified to bite by raising it to 99 and watching the
run go red, not by assuming an unfailed gate works.

DR-205 moves to Done; the coverage gate is DR-215.
2026-08-21 18:11:26 +02:00
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
2026-08-21 17:41:44 +02:00