Infrastructure hardening across five areas: CI enforcement, supply chain, release integrity, the upgrade path, and diagnostics. Each phase is independently reviewable and landed as its own commit.
What this fixes
The project already had the hard parts — pinned toolchain, tracked hooks, a traceability ratchet, a no-toolchain-installs rule. The gaps were in enforcement (configured gates that ran nowhere), supply chain (no scanning at all), release integrity (an advertised artifact that was never built), and feedback (an app that forgot everything on exit).
Several of these were found only by turning the gate on:
Found
How
8 vulnerabilities + 1 unsoundness in the Rust graph
first cargo deny run — all closed by a cargo update nobody had reason to run
bun run test:coverage broken for months
@vitest/coverage-v8 pins vitest exactly; ">=1.0.0 <5.0.0" resolved to a mismatched pair, so every run died on a missing export
Releases advertise an AppImage that was never built
appimage was absent from bundle.targets; the artifact step globbed for it, found nothing, and said nothing
publish-docs.yml installed mdBook at job time
breaks this repo's own 🔴 rule, and made docs publishing depend on GitHub's CDN
Traceability coverage was really 90%, not 88%
the extractor only read .ts/.svelte/.rs, so DR-205/206/207 carried TRACES comments nothing ever read
Phases
0–1 · Enforcement. Prettier sweep (199 files, isolated commit), then format:check, lint --max-warnings=159, bun run check and coverage thresholds became CI gates. no-console promoted to error — the logger migration it waited on had already finished. The coverage gate was verified to fail by raising it to 99, not assumed to work.
2 · Supply chain.cargo deny over advisories/licences/bans/sources, bun audit (advisory for now), SHA256SUMS + CycloneDX SBOM on every release, and workflows pinned to an immutable jellytau-builder:2026.08 instead of :latest. The graph is scoped to shipped targets so Apple-only findings are correctly absent rather than ignored by ID, and libmpv is pinned by rev rather than branch = "master" — it was the one unsigned, C-linking dependency free to change under any cargo update. LGPL-2.1 obligations for libmpv are written down rather than assumed.
3 · Updater. Desktop checks a signed manifest and installs in place; Android gets the releases page, because an app cannot overwrite its own APK. latest.json lives on a dedicated updater branch read over Gitea's raw-file URL — this instance serves /releases/download/<tag>/<asset> but 404s on /releases/latest/download/<asset> (verified against a real asset), and the docs branch is force-pushed, so neither could host it. Release notes now come from release:notes and the traceability graph instead of a fixed heredoc that linked "GitHub Issues" on a Gitea project.
4 · Diagnostics. Rotating, size-capped, redacted logs; logcat on Android, where env_logger's stdout went nowhere and the backend was therefore invisible on the platform carrying this project's hardest bugs. Panic capture with backtrace. Frontend messages forwarded into the same file, so one timeline holds both halves of the app — which is what makes a race between them legible after the fact. Redaction runs in the log formatter, not at export: a credential in a file on the device is already a disclosure. Spec in docs/specs/diagnostics-and-logging.md.
5 · Governance.SECURITY.md, CONTRIBUTING.md, code of conduct, issue/PR templates, and docs/build/ci-operations.md (builder-image procedure, secrets, what losing the signing key would mean).
Two bugs the tests caught that review would not have
redact_headers recursed on its own output — the replacement keeps the header name, so it matched forever and the test died with a stack overflow. Rewritten as a forward scan.
The frontend log forwarder used void plugin.error(...). void discards a promise's value but not its rejection, so in any webview without IPC every log line became an unhandled rejection — 20 surfaced the first time coverage ran.
The builder image :2026.08 is already built and pushed, with cargo-deny, cargo-cyclonedx and mdbook verified inside it — CI will not fail on the new tag.
Before merging
Branch protection on master, requiring Run Tests, Android Compile Check, Supply Chain and the traceability job. Until that is on, every gate here is advisory.
Back up the updater signing key. Losing it means installed desktop clients can never be updated again, only reinstalled by hand. It is in Gitea secrets and at ~/.tauri/jellytau.key.
Also included: spec updates
Three commits here come from a parallel session working in the same checkout, carried along so the docs travel with the code:
32043a21 — shipped specs folded into the architecture docs and deleted
96abc3af — "a spec becomes an architecture doc" made the written rule
f11f5edd — the Linux native-video compositing spike, which re-opens finding 2 of playback-backend-unification.md. Its general form was already falsified on Android, and the original evidence only ever covered foreign-window embedding — never mpv's render API drawing into a GL context inside Tauri's own GTK tree. That comes back green on X11 and Wayland. Findings 3–6 are deliberately left standing; finding 3 (mpv has no adaptive bitrate) is an independent disqualifier a green compositing result does not clear.
The spec index's next-free-id line moved to UR-079 / IR-033 / DR-219 — this branch consumed UR-077/078 and DR-215–218 after that line was last written.
Infrastructure hardening across five areas: CI enforcement, supply chain, release integrity, the upgrade path, and diagnostics. Each phase is independently reviewable and landed as its own commit.
## What this fixes
The project already had the hard parts — pinned toolchain, tracked hooks, a traceability ratchet, a no-toolchain-installs rule. The gaps were in **enforcement** (configured gates that ran nowhere), **supply chain** (no scanning at all), **release integrity** (an advertised artifact that was never built), and **feedback** (an app that forgot everything on exit).
Several of these were found only by turning the gate on:
| Found | How |
|---|---|
| **8 vulnerabilities + 1 unsoundness** in the Rust graph | first `cargo deny` run — all closed by a `cargo update` nobody had reason to run |
| **`bun run test:coverage` broken for months** | `@vitest/coverage-v8` pins `vitest` exactly; `">=1.0.0 <5.0.0"` resolved to a mismatched pair, so every run died on a missing export |
| **Releases advertise an AppImage that was never built** | `appimage` was absent from `bundle.targets`; the artifact step globbed for it, found nothing, and said nothing |
| **`publish-docs.yml` installed mdBook at job time** | breaks this repo's own 🔴 rule, and made docs publishing depend on GitHub's CDN |
| **Traceability coverage was really 90%, not 88%** | the extractor only read `.ts`/`.svelte`/`.rs`, so `DR-205`/`206`/`207` carried TRACES comments nothing ever read |
## Phases
**0–1 · Enforcement.** Prettier sweep (199 files, isolated commit), then `format:check`, `lint --max-warnings=159`, `bun run check` and coverage thresholds became CI gates. `no-console` promoted to `error` — the logger migration it waited on had already finished. The coverage gate was verified to *fail* by raising it to 99, not assumed to work.
**2 · Supply chain.** `cargo deny` over advisories/licences/bans/sources, `bun audit` (advisory for now), `SHA256SUMS` + CycloneDX SBOM on every release, and workflows pinned to an immutable `jellytau-builder:2026.08` instead of `:latest`. The graph is scoped to shipped targets so Apple-only findings are correctly *absent* rather than ignored by ID, and `libmpv` is pinned by rev rather than `branch = "master"` — it was the one unsigned, C-linking dependency free to change under any `cargo update`. LGPL-2.1 obligations for libmpv are written down rather than assumed.
**3 · Updater.** Desktop checks a signed manifest and installs in place; Android gets the releases page, because an app cannot overwrite its own APK. `latest.json` lives on a dedicated `updater` branch read over Gitea's raw-file URL — this instance serves `/releases/download/<tag>/<asset>` but 404s on `/releases/latest/download/<asset>` (verified against a real asset), and the docs branch is force-pushed, so neither could host it. Release notes now come from `release:notes` and the traceability graph instead of a fixed heredoc that linked "GitHub Issues" on a Gitea project.
**4 · Diagnostics.** Rotating, size-capped, redacted logs; **logcat on Android**, where `env_logger`'s stdout went nowhere and the backend was therefore invisible on the platform carrying this project's hardest bugs. Panic capture with backtrace. Frontend messages forwarded into the same file, so one timeline holds both halves of the app — which is what makes a race between them legible after the fact. Redaction runs in the log *formatter*, not at export: a credential in a file on the device is already a disclosure. Spec in `docs/specs/diagnostics-and-logging.md`.
**5 · Governance.** `SECURITY.md`, `CONTRIBUTING.md`, code of conduct, issue/PR templates, and `docs/build/ci-operations.md` (builder-image procedure, secrets, what losing the signing key would mean).
## Two bugs the tests caught that review would not have
- `redact_headers` recursed on its own output — the replacement keeps the header *name*, so it matched forever and the test died with a stack overflow. Rewritten as a forward scan.
- The frontend log forwarder used `void plugin.error(...)`. `void` discards a promise's value but **not its rejection**, so in any webview without IPC every log line became an unhandled rejection — 20 surfaced the first time coverage ran.
## Verification
`bun run check` (0 errors) · 1079 frontend tests + coverage thresholds · 759 Rust tests · `cargo clippy -D warnings` · `cargo fmt --check` · `cargo deny check` (all four clean) · `cargo check --target aarch64-linux-android` (confirming the updater plugins are absent there) · `check:boundary` · `check:links` · traceability 90% ≥ 89%.
The builder image `:2026.08` is already built and pushed, with `cargo-deny`, `cargo-cyclonedx` and `mdbook` verified inside it — CI will not fail on the new tag.
## Before merging
- [ ] **Branch protection on `master`**, requiring `Run Tests`, `Android Compile Check`, `Supply Chain` and the traceability job. Until that is on, every gate here is advisory.
- [ ] **Back up the updater signing key.** Losing it means installed desktop clients can never be updated again, only reinstalled by hand. It is in Gitea secrets and at `~/.tauri/jellytau.key`.
## Also included: spec updates
Three commits here come from a parallel session working in the same checkout, carried along so the docs travel with the code:
- `32043a21` — shipped specs folded into the architecture docs and deleted
- `96abc3af` — "a spec becomes an architecture doc" made the written rule
- `f11f5edd` — the Linux native-video compositing spike, which re-opens finding 2 of `playback-backend-unification.md`. Its general form was already falsified on Android, and the original evidence only ever covered foreign-window embedding — never mpv's render API drawing into a GL context inside Tauri's own GTK tree. That comes back green on X11 and Wayland. Findings 3–6 are deliberately left standing; finding 3 (mpv has no adaptive bitrate) is an independent disqualifier a green compositing result does not clear.
The spec index's next-free-id line moved to **UR-079 / IR-033 / DR-219** — this branch consumed UR-077/078 and DR-215–218 after that line was last written.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Infrastructure hardening across five areas: CI enforcement, supply chain, release integrity, the upgrade path, and diagnostics. Each phase is independently reviewable and landed as its own commit.
What this fixes
The project already had the hard parts — pinned toolchain, tracked hooks, a traceability ratchet, a no-toolchain-installs rule. The gaps were in enforcement (configured gates that ran nowhere), supply chain (no scanning at all), release integrity (an advertised artifact that was never built), and feedback (an app that forgot everything on exit).
Several of these were found only by turning the gate on:
cargo denyrun — all closed by acargo updatenobody had reason to runbun run test:coveragebroken for months@vitest/coverage-v8pinsvitestexactly;">=1.0.0 <5.0.0"resolved to a mismatched pair, so every run died on a missing exportappimagewas absent frombundle.targets; the artifact step globbed for it, found nothing, and said nothingpublish-docs.ymlinstalled mdBook at job time.ts/.svelte/.rs, soDR-205/206/207carried TRACES comments nothing ever readPhases
0–1 · Enforcement. Prettier sweep (199 files, isolated commit), then
format:check,lint --max-warnings=159,bun run checkand coverage thresholds became CI gates.no-consolepromoted toerror— the logger migration it waited on had already finished. The coverage gate was verified to fail by raising it to 99, not assumed to work.2 · Supply chain.
cargo denyover advisories/licences/bans/sources,bun audit(advisory for now),SHA256SUMS+ CycloneDX SBOM on every release, and workflows pinned to an immutablejellytau-builder:2026.08instead of:latest. The graph is scoped to shipped targets so Apple-only findings are correctly absent rather than ignored by ID, andlibmpvis pinned by rev rather thanbranch = "master"— it was the one unsigned, C-linking dependency free to change under anycargo update. LGPL-2.1 obligations for libmpv are written down rather than assumed.3 · Updater. Desktop checks a signed manifest and installs in place; Android gets the releases page, because an app cannot overwrite its own APK.
latest.jsonlives on a dedicatedupdaterbranch read over Gitea's raw-file URL — this instance serves/releases/download/<tag>/<asset>but 404s on/releases/latest/download/<asset>(verified against a real asset), and the docs branch is force-pushed, so neither could host it. Release notes now come fromrelease:notesand the traceability graph instead of a fixed heredoc that linked "GitHub Issues" on a Gitea project.4 · Diagnostics. Rotating, size-capped, redacted logs; logcat on Android, where
env_logger's stdout went nowhere and the backend was therefore invisible on the platform carrying this project's hardest bugs. Panic capture with backtrace. Frontend messages forwarded into the same file, so one timeline holds both halves of the app — which is what makes a race between them legible after the fact. Redaction runs in the log formatter, not at export: a credential in a file on the device is already a disclosure. Spec indocs/specs/diagnostics-and-logging.md.5 · Governance.
SECURITY.md,CONTRIBUTING.md, code of conduct, issue/PR templates, anddocs/build/ci-operations.md(builder-image procedure, secrets, what losing the signing key would mean).Two bugs the tests caught that review would not have
redact_headersrecursed on its own output — the replacement keeps the header name, so it matched forever and the test died with a stack overflow. Rewritten as a forward scan.void plugin.error(...).voiddiscards a promise's value but not its rejection, so in any webview without IPC every log line became an unhandled rejection — 20 surfaced the first time coverage ran.Verification
bun run check(0 errors) · 1079 frontend tests + coverage thresholds · 759 Rust tests ·cargo clippy -D warnings·cargo fmt --check·cargo deny check(all four clean) ·cargo check --target aarch64-linux-android(confirming the updater plugins are absent there) ·check:boundary·check:links· traceability 90% ≥ 89%.The builder image
:2026.08is already built and pushed, withcargo-deny,cargo-cyclonedxandmdbookverified inside it — CI will not fail on the new tag.Before merging
master, requiringRun Tests,Android Compile Check,Supply Chainand the traceability job. Until that is on, every gate here is advisory.~/.tauri/jellytau.key.Also included: spec updates
Three commits here come from a parallel session working in the same checkout, carried along so the docs travel with the code:
32043a21— shipped specs folded into the architecture docs and deleted96abc3af— "a spec becomes an architecture doc" made the written rulef11f5edd— the Linux native-video compositing spike, which re-opens finding 2 ofplayback-backend-unification.md. Its general form was already falsified on Android, and the original evidence only ever covered foreign-window embedding — never mpv's render API drawing into a GL context inside Tauri's own GTK tree. That comes back green on X11 and Wayland. Findings 3–6 are deliberately left standing; finding 3 (mpv has no adaptive bitrate) is an independent disqualifier a green compositing result does not clear.The spec index's next-free-id line moved to UR-079 / IR-033 / DR-219 — this branch consumed UR-077/078 and DR-215–218 after that line was last written.
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.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.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.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.