diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..0b79d1611 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Local Android release signing. +# +# Copy to `.env` and fill in. `.env` is gitignored and is the single source of +# truth for local release signing — scripts/write-keystore-properties.sh reads +# it and regenerates src-tauri/gen/android/keystore.properties before every +# release build, because `tauri android init` overwrites that file. +# +# Only needed for `bun run android:build:release`. Debug builds sign with the +# local debug keystore and need nothing here. +# +# CI does not use this file: build-release.yml reconstructs the keystore from +# the ANDROID_KEYSTORE_BASE64 secret and writes the same properties itself. + +# Key alias inside the keystore. +ANDROID_KEY_ALIAS=jellytau + +# Absolute path to the .jks. Keep it outside the repo, or in the gitignored +# android-keystore/ directory. +ANDROID_KEYSTORE_FILE=/absolute/path/to/jellytau-release.jks + +# Keystore and key passwords. These are secrets — never commit the filled-in .env. +ANDROID_KEYSTORE_PASSWORD= +ANDROID_KEY_PASSWORD= diff --git a/.gitea/ISSUE_TEMPLATE/bug.yaml b/.gitea/ISSUE_TEMPLATE/bug.yaml new file mode 100644 index 000000000..646dd4bc9 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug.yaml @@ -0,0 +1,103 @@ +name: Bug report +about: Something behaves incorrectly +title: "" +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Security vulnerabilities do **not** go here — see + [SECURITY.md](../../SECURITY.md). + + - type: textarea + id: what-happened + attributes: + label: What happened + description: What you did, what you expected, and what you got instead. + placeholder: | + 1. Opened an album from the Music library + 2. Tapped the third track + 3. Playback started from the first track instead + validations: + required: true + + - type: input + id: version + attributes: + label: JellyTau version + description: Settings scrolls to the bottom, or the filename you installed. + placeholder: "0.9.1" + validations: + required: true + + - type: dropdown + id: platform + attributes: + label: Platform + options: + - Linux (AppImage) + - Linux (deb) + - Linux (rpm) + - Linux (Arch package) + - Windows + - Android + validations: + required: true + + - type: markdown + attributes: + value: | + ### Playback questions + + If this involves playback, these three answers decide which of several + very different code paths you were on. "I don't know" is a fine answer. + + - type: dropdown + id: source + attributes: + label: Was the media streaming or downloaded? + options: + - Streaming from the server + - Downloaded for offline use + - Not playback-related + validations: + required: true + + - type: dropdown + id: transcode + attributes: + label: Was the server transcoding? + description: Jellyfin's dashboard shows this while something is playing. + options: + - Direct play + - Transcoding + - Don't know + - Not playback-related + + - type: dropdown + id: kind + attributes: + label: Music or video? + options: + - Music + - Video (movie) + - Video (TV episode) + - Not playback-related + + - type: textarea + id: logs + attributes: + label: Logs + description: | + Android: `adb logcat | grep -i jellytau`. + Linux: run from a terminal, or `RUST_LOG=debug jellytau` for more. + In the app, `localStorage.setItem("jellytau:logLevel","debug")` in the + webview console turns the frontend up too. + render: shell + + - type: textarea + id: server + attributes: + label: Jellyfin server + description: Version, and anything unusual about the library layout. + placeholder: "10.9.11, series stored without season folders" diff --git a/.gitea/ISSUE_TEMPLATE/feature.yaml b/.gitea/ISSUE_TEMPLATE/feature.yaml new file mode 100644 index 000000000..cb0be7865 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature.yaml @@ -0,0 +1,37 @@ +name: Feature request +about: Suggest something JellyTau should do +title: "" +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: What are you trying to do? + description: | + The situation, not the solution. "I listen to albums in a fixed order and + lose my place when I switch devices" tells us more than "add a sync + button", and often has a better answer than the one you had in mind. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: What would you like it to do? + validations: + required: true + + - type: dropdown + id: platform + attributes: + label: Which platforms does this matter on? + multiple: true + options: + - Linux + - Windows + - Android + + - type: textarea + id: alternatives + attributes: + label: Anything you have tried, or how other clients handle it diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..9851d64c1 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,22 @@ +## What and why + + + +## How it was verified + + + +## Checklist + +- [ ] `bun run check`, `bun run test`, `bun run format:check`, `bun run lint` +- [ ] `cargo fmt`, `cargo clippy --all-targets -- -D warnings`, `cargo test` +- [ ] `bun run check:boundary` — no Jellyfin taxonomy in the frontend +- [ ] New requirement-implementing code carries a `TRACES:` comment, and every + ID it names exists in `docs/requirements.md` (`bun run traces:validate`) +- [ ] **Bug fix:** a test that reproduces it was written *first* and observed + failing before the fix +- [ ] Android source edits were made in `src-tauri/android/src` and synced with + `scripts/sync-android-sources.sh` (never edit `gen/` directly) diff --git a/.gitea/workflows/build-and-test.yml b/.gitea/workflows/build-and-test.yml index fae13e8ad..9c013c252 100644 --- a/.gitea/workflows/build-and-test.yml +++ b/.gitea/workflows/build-and-test.yml @@ -13,12 +13,22 @@ on: - '**/*.md' workflow_dispatch: +env: + # Incremental state is never reused between CI runs -- pure disk cost. + CARGO_INCREMENTAL: 0 + jobs: test: name: Run Tests + # A release push triggers build-release.yml on the tag, which runs this exact + # test suite itself — and on a single-slot runner the two ~1h workflows would + # otherwise serialize/contend. Skip the duplicate for chore(release) commits. + # (head_commit is absent on pull_request/workflow_dispatch; startsWith(null,…) + # is false there, so those events still run.) + if: "!startsWith(github.event.head_commit.message, 'chore(release)')" runs-on: linux/amd64 container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 steps: - name: Checkout repository @@ -27,13 +37,22 @@ jobs: - name: Cache Rust dependencies uses: actions/cache@v3 with: + # Registry only -- never src-tauri/target. That directory is ~16 GB and + # was cached under five separate keys, which filled the runner's 74 GB + # disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026). + # registry/src is omitted too: cargo re-extracts it for free from + # registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted). path: | - ~/.cargo/registry - ~/.cargo/git - src-tauri/target - key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }} + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + # One shared key across every job. The old per-job keys existed to stop + # debug/release target artifacts clobbering each other; with target no + # longer cached, registry contents are target-independent, so all jobs + # want the same crates. First job to finish saves; the rest restore. + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} restore-keys: | - ${{ runner.os }}-cargo-host- + ${{ runner.os }}-cargo-registry- - name: Cache Node dependencies uses: actions/cache@v3 @@ -56,10 +75,68 @@ jobs: - name: Check frontend/backend boundary run: bash scripts/check-frontend-boundary.sh + # The docs are the maintained source of truth for architecture and + # process, and they cross-reference each other heavily. A rename that + # misses a link turns a doc into a dead end silently. Pure shell + git — + # no tool is installed at job time. + - name: Check documentation links + run: bash scripts/check-doc-links.sh + + # Formatting, linting and type-checking were all configured in this repo + # and enforced by nothing: .prettierrc described a tree where 199 files did + # not match it, eslint.config.js ran in no workflow and in no hook, and + # `bun run check` ran only in build-release.yml — i.e. a type error could + # sit on master until somebody cut a tag. These three steps are what make + # those configs load-bearing. All are project deps installed by + # `bun install`; nothing is fetched at job time. + - name: Check formatting + run: bun run format:check + + # RATCHET — this number only ever goes DOWN. Same policy as MIN_THRESHOLD + # in traceability-check.yml and the coverage thresholds in + # vitest.config.ts. 159 is what the tree carried when the gate went in; the + # backlog is real findings (dead bindings, unkeyed {#each}, `any` at the + # IPC boundary) that eslint.config.js documents rule by rule, each parked + # at "warn" until its class is cleared and it can be promoted to "error". + # Lower this as you clear them. Never raise it to make a build pass. + - name: Lint + run: bun run lint -- --max-warnings=159 + + - name: Check TypeScript + run: | + bunx svelte-kit sync + bun run check + + # Coverage rather than a bare `bun run test`: same suite, plus the + # thresholds in vitest.config.ts, so a large untested module or a deleted + # test fails here instead of being noticed months later. - name: Run frontend tests run: | bunx svelte-kit sync - bun run test + bun run test:coverage + + # CLAUDE.md has required `cargo fmt` + `cargo clippy` before every commit + # for as long as the rule has existed, but nothing in CI checked either, + # so the requirement rested entirely on memory. Both components are baked + # into the builder image (Dockerfile.builder: `rustup component add + # rustfmt clippy`) — nothing is installed at job time. + - name: Check Rust formatting + run: | + cd src-tauri + cargo fmt --all -- --check + + # Clippy is a hard gate. It was advisory while the tree carried a warning + # backlog; that backlog is gone (0 warnings on 1.97.1, the pinned + # toolchain), so a warning here is now new breakage rather than old noise. + # + # This only means anything because src-tauri/rust-toolchain.toml pins the + # compiler: clippy's lint set moves between releases, so an unpinned gate + # would fail on whatever the runner happened to install. The pin and this + # flag stand or fall together — if you unpin, drop this back to advisory. + - name: Run clippy + run: | + cd src-tauri + cargo clippy --all-targets -- -D warnings - name: Run Rust tests run: | @@ -79,7 +156,7 @@ jobs: runs-on: linux/amd64 needs: test container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 env: ANDROID_HOME: /opt/android-sdk ANDROID_SDK_ROOT: /opt/android-sdk @@ -93,13 +170,22 @@ jobs: - name: Cache Rust dependencies uses: actions/cache@v3 with: + # Registry only -- never src-tauri/target. That directory is ~16 GB and + # was cached under five separate keys, which filled the runner's 74 GB + # disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026). + # registry/src is omitted too: cargo re-extracts it for free from + # registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted). path: | - ~/.cargo/registry - ~/.cargo/git - src-tauri/target - key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }} + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + # One shared key across every job. The old per-job keys existed to stop + # debug/release target artifacts clobbering each other; with target no + # longer cached, registry contents are target-independent, so all jobs + # want the same crates. First job to finish saves; the rest restore. + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} restore-keys: | - ${{ runner.os }}-cargo-android- + ${{ runner.os }}-cargo-registry- - name: Cache Node dependencies uses: actions/cache@v3 @@ -122,3 +208,60 @@ jobs: export AR_aarch64_linux_android="$TC/llvm-ar" cd src-tauri cargo check --target aarch64-linux-android --lib + + # Supply-chain gate. Until this job existed the project had no vulnerability + # scanning of any kind: nothing checked the ~500-crate Rust graph or the JS + # dependencies against a CVE feed, and nothing checked that everything we + # redistribute is licence-compatible with shipping JellyTau under MIT. + # + # The first run of this found eight vulnerabilities and one unsoundness + # (bytes, four in rustls-webpki, time, two in quick-xml, rand) — all fixed by + # `cargo update`, none of which anybody had reason to run. + # + # Runs in parallel with android-check rather than after `test`: a dependency + # advisory has nothing to do with whether the tests pass, and finding out + # sooner is the point. + security: + name: Supply Chain + runs-on: linux/amd64 + container: + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Cache Rust dependencies + uses: actions/cache@v3 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + # cargo-deny is baked into the builder image. It fetches the RustSec + # advisory database at run time — that is *data*, like the crates + # `bun install` fetches, not a toolchain install, so the 🔴 rule in + # CLAUDE.md is not in play here. + # + # Config and every documented exception live in src-tauri/deny.toml. + # Vulnerabilities and unsoundness are hard failures with no override; + # unmaintained transitive crates that have no safe upgrade (Tauri's GTK3 + # stack, the unic-* tables) are ignored there by ID, each with a reason. + - name: cargo-deny (advisories, licences, bans, sources) + run: | + cd src-tauri + cargo deny check + + # Advisory for now, deliberately. The Rust graph was clean after one + # update pass, so gating it costs nothing; the JS graph has not been + # audited before and a first run that fails the build teaches everyone to + # ignore this job. Promote to a hard gate once the output is empty and + # stays empty — same approach that got clippy from advisory to -D warnings. + - name: bun audit (advisory) + run: | + bun install + bun audit || echo "::warning::bun audit reported findings — advisory for now, see CLAUDE.md" diff --git a/.gitea/workflows/build-release.yml b/.gitea/workflows/build-release.yml index f8b93828c..2ed80ae15 100644 --- a/.gitea/workflows/build-release.yml +++ b/.gitea/workflows/build-release.yml @@ -13,13 +13,15 @@ on: env: RUST_BACKTRACE: 1 CARGO_TERM_COLOR: always + # Incremental state is never reused between CI runs -- pure disk cost. + CARGO_INCREMENTAL: 0 jobs: test: name: Run Tests runs-on: linux/amd64 container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -27,13 +29,22 @@ jobs: - name: Cache Rust dependencies uses: actions/cache@v3 with: + # Registry only -- never src-tauri/target. That directory is ~16 GB and + # was cached under five separate keys, which filled the runner's 74 GB + # disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026). + # registry/src is omitted too: cargo re-extracts it for free from + # registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted). path: | - ~/.cargo/registry - ~/.cargo/git - src-tauri/target - key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }} + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + # One shared key across every job. The old per-job keys existed to stop + # debug/release target artifacts clobbering each other; with target no + # longer cached, registry contents are target-independent, so all jobs + # want the same crates. First job to finish saves; the rest restore. + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} restore-keys: | - ${{ runner.os }}-cargo-host- + ${{ runner.os }}-cargo-registry- - name: Cache Node dependencies uses: actions/cache@v3 @@ -54,6 +65,22 @@ jobs: bun run test --run continue-on-error: false + # Same gate as build-and-test.yml. A release must not ship from a tree + # that would fail the per-commit checks. rustfmt/clippy come from the + # builder image; nothing is installed here. + - name: Check Rust formatting + run: | + cd src-tauri + cargo fmt --all -- --check + continue-on-error: false + + # Advisory until the ~51 pre-existing warnings are cleared; see the longer + # note in build-and-test.yml. Tighten both to `-- -D warnings` together. + - name: Run clippy (advisory) + run: | + cd src-tauri + cargo clippy --all-targets + - name: Run Rust tests run: bun run test:rust continue-on-error: false @@ -67,7 +94,7 @@ jobs: runs-on: linux/amd64 needs: test container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -75,13 +102,22 @@ jobs: - name: Cache Rust dependencies uses: actions/cache@v3 with: + # Registry only -- never src-tauri/target. That directory is ~16 GB and + # was cached under five separate keys, which filled the runner's 74 GB + # disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026). + # registry/src is omitted too: cargo re-extracts it for free from + # registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted). path: | - ~/.cargo/registry - ~/.cargo/git - src-tauri/target - key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }} + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + # One shared key across every job. The old per-job keys existed to stop + # debug/release target artifacts clobbering each other; with target no + # longer cached, registry contents are target-independent, so all jobs + # want the same crates. First job to finish saves; the rest restore. + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} restore-keys: | - ${{ runner.os }}-cargo-host- + ${{ runner.os }}-cargo-registry- - name: Cache Node dependencies uses: actions/cache@v3 @@ -96,21 +132,68 @@ jobs: - name: Install dependencies run: bun install + # The Linux job previously had no version step at all, so a tagged release + # built Linux packages from whatever version happened to be committed. + - name: Set app version from tag + run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}" + if: startsWith(github.ref, 'refs/tags/v') + + # TAURI_SKIP_UPDATER is gone: it was suppressing the updater artifacts + # (.AppImage.tar.gz + .sig) that the update manifest points at, back when + # there was no updater to feed. With the signing key present, `tauri build` + # emits and signs them. + # + # If TAURI_SIGNING_PRIVATE_KEY is ever absent the build fails loudly rather + # than quietly shipping an unsigned release that no client will accept -- + # which is the behaviour we want. - name: Build for Linux run: bun run tauri build env: - TAURI_SKIP_UPDATER: true + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - name: Prepare Linux artifacts run: | mkdir -p dist/linux - # Copy AppImage - if [ -f "src-tauri/target/release/bundle/appimage/jellytau_"*.AppImage ]; then - cp src-tauri/target/release/bundle/appimage/jellytau_*.AppImage dist/linux/ + # Match by extension, not by product name. Bundle filenames follow + # `productName`, so renaming the app (jellytau -> JellyTau) made the + # old `jellytau_*.deb` glob match nothing — and because the copy was + # wrapped in `if [ -f ... ]`, the artifact simply vanished from the + # release with no error. Each bundle directory holds one file. + # + # `if [ -f "dir/"*.ext ]` was also wrong on its own terms: with more + # than one match `test` gets extra arguments and fails. + # + # No `shopt -s nullglob` here: the runner executes `run:` blocks with + # POSIX sh, where shopt does not exist -- it exited 127 and killed the + # step (which is why v0.9.0 and v0.9.1 built but never published). + # Without nullglob an unmatched pattern stays literal, so test each + # candidate instead. Same POSIX-only rule as traceability-check.yml. + # + # The .AppImage.tar.gz + .sig pair is what the updater downloads and + # verifies; the plain .AppImage is what a human downloads. Both ship. + for bundle in \ + src-tauri/target/release/bundle/appimage/*.AppImage \ + src-tauri/target/release/bundle/appimage/*.AppImage.tar.gz \ + src-tauri/target/release/bundle/appimage/*.AppImage.tar.gz.sig \ + src-tauri/target/release/bundle/deb/*.deb \ + src-tauri/target/release/bundle/rpm/*.rpm; do + [ -e "$bundle" ] || continue + cp -v "$bundle" dist/linux/ + done + + # An AppImage that did not build means no updater artifact either, and + # the release notes have advertised an AppImage for months. Fail rather + # than publish a release whose manifest points at nothing. + if ! ls dist/linux/*.AppImage >/dev/null 2>&1; then + echo "::error::No AppImage produced -- check bundle.targets in tauri.conf.json" + exit 1 fi - # Copy .deb if built - if [ -f "src-tauri/target/release/bundle/deb/jellytau_"*.deb ]; then - cp src-tauri/target/release/bundle/deb/jellytau_*.deb dist/linux/ + + # A release with no Linux package is a failure, not a quiet success. + if [ -z "$(ls -A dist/linux/)" ]; then + echo "::error::No Linux bundles found under src-tauri/target/release/bundle/" + exit 1 fi ls -lah dist/linux/ @@ -119,7 +202,7 @@ jobs: with: name: jellytau-linux path: dist/linux/ - retention-days: 30 + retention-days: 7 build-windows: name: Build Windows @@ -129,7 +212,7 @@ jobs: # baked into the builder image. No toolchain installs here — the image has # cargo-xwin, clang/clang-cl, lld, llvm, nsis and the msvc target. container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -137,14 +220,31 @@ jobs: - name: Cache Rust dependencies uses: actions/cache@v3 with: + # Registry only -- never src-tauri/target. That directory is ~16 GB and + # was cached under five separate keys, which filled the runner's 74 GB + # disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026). + # registry/src is omitted too: cargo re-extracts it for free from + # registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted). path: | - ~/.cargo/registry - ~/.cargo/git - ~/.cache/cargo-xwin - src-tauri/target - key: ${{ runner.os }}-cargo-windows-${{ hashFiles('**/Cargo.lock') }} + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + # One shared key across every job. The old per-job keys existed to stop + # debug/release target artifacts clobbering each other; with target no + # longer cached, registry contents are target-independent, so all jobs + # want the same crates. First job to finish saves; the rest restore. + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} restore-keys: | - ${{ runner.os }}-cargo-windows- + ${{ runner.os }}-cargo-registry- + + - name: Cache Windows CRT/SDK (cargo-xwin) + uses: actions/cache@v3 + with: + path: ~/.cache/cargo-xwin + # Contents track the xwin version baked into the builder image, not our + # lockfile -- keying this on Cargo.lock re-downloaded the whole SDK on + # every release bump. Bump the suffix by hand if the image's xwin moves. + key: ${{ runner.os }}-cargo-xwin-v1 - name: Cache Node dependencies uses: actions/cache@v3 @@ -156,18 +256,19 @@ jobs: restore-keys: | ${{ runner.os }}-bun- + # The tag is the single source of truth for a release version; the script + # stamps every file that carries it (package.json, tauri.conf.json, + # Cargo.toml, Cargo.lock). This step used to sed only tauri.conf.json, so + # the other three shipped whatever was committed. - name: Set app version from tag - run: | - # On a tag build the tag is the single source of truth for the version. - if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then - VERSION="${GITHUB_REF#refs/tags/v}" - echo "Setting version to $VERSION" - sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json - fi - grep '"version"' src-tauri/tauri.conf.json + run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}" + if: startsWith(github.ref, 'refs/tags/v') - name: Build Windows (NSIS installer + exe) run: OUTPUT_DIR="$PWD/dist/windows" WIN_BUNDLES=nsis ./scripts/build-windows-cross.sh + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - name: List Windows artifacts run: ls -lah dist/windows/ @@ -177,14 +278,14 @@ jobs: with: name: jellytau-windows path: dist/windows/ - retention-days: 30 + retention-days: 7 build-android: name: Build Android runs-on: linux/amd64 needs: test container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 env: ANDROID_HOME: /opt/android-sdk ANDROID_SDK_ROOT: /opt/android-sdk @@ -196,13 +297,22 @@ jobs: - name: Cache Rust dependencies uses: actions/cache@v3 with: + # Registry only -- never src-tauri/target. That directory is ~16 GB and + # was cached under five separate keys, which filled the runner's 74 GB + # disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026). + # registry/src is omitted too: cargo re-extracts it for free from + # registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted). path: | - ~/.cargo/registry - ~/.cargo/git - src-tauri/target - key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }} + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + # One shared key across every job. The old per-job keys existed to stop + # debug/release target artifacts clobbering each other; with target no + # longer cached, registry contents are target-independent, so all jobs + # want the same crates. First job to finish saves; the rest restore. + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} restore-keys: | - ${{ runner.os }}-cargo-android- + ${{ runner.os }}-cargo-registry- - name: Cache Node dependencies uses: actions/cache@v3 @@ -217,48 +327,22 @@ jobs: - name: Install dependencies run: bun install + # Stamp before `android init`: it derives its generated project (including + # the initial versionCode) from tauri.conf.json. - name: Set app version from tag - run: | - # On a tag build, the tag is the single source of truth for the - # version name. On non-tag runs keep whatever is in tauri.conf.json. - if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then - VERSION="${GITHUB_REF#refs/tags/v}" - echo "Setting version to $VERSION" - sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json - fi - grep '"version"' src-tauri/tauri.conf.json + run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}" + if: startsWith(github.ref, 'refs/tags/v') - name: Initialize Android project run: bun run tauri android init + # Re-run after init: tauri.properties only exists now, and its + # autogenerated versionCode (0.0.15 -> 15) is both tiny and NOT monotonic + # against the 1000 floor already shipped in the field. The script rewrites + # it as 1000 + major*10000 + minor*100 + patch. Runs unconditionally so + # untagged builds get a sane code too, derived from git describe. - name: Pin a monotonic Android versionCode - run: | - # `tauri android init` autogenerates src-tauri/gen/android/app/tauri.properties - # with a versionCode derived from the semver (e.g. 0.0.15 -> 15). That - # number is (a) tiny and (b) NOT monotonic across our history: earlier - # local/dev builds shipped versionCode 1000 (from a 0.1.0 config), so a - # plain 15 would be a *downgrade* and Android would refuse the update. - # - # Derive an explicit code that is both monotonic in semver order and - # always above the 1000 floor already in the field: - # code = 1000 + major*10000 + minor*100 + patch - # e.g. 0.0.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000. - # POSIX sh only (the runner uses dash): no here-strings, no \s in sed. - PROPS="src-tauri/gen/android/app/tauri.properties" - VERSION=$(grep '"version"' src-tauri/tauri.conf.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/') - MAJ=$(echo "$VERSION" | cut -d. -f1) - MIN=$(echo "$VERSION" | cut -d. -f2) - PAT=$(echo "$VERSION" | cut -d. -f3) - # Guard against a malformed/missing component so we never emit code 0. - : "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}" - CODE=$(( 1000 + MAJ*10000 + MIN*100 + PAT )) - echo "version=$VERSION -> versionCode=$CODE" - if grep -q '^tauri.android.versionCode=' "$PROPS"; then - sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS" - else - echo "tauri.android.versionCode=$CODE" >> "$PROPS" - fi - cat "$PROPS" + run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}" - name: Sync custom Android sources & gradle config run: ./scripts/sync-android-sources.sh @@ -292,7 +376,7 @@ jobs: with: name: jellytau-android path: dist/android/ - retention-days: 30 + retention-days: 7 create-release: name: Create Release @@ -300,7 +384,7 @@ jobs: needs: [build-linux, build-windows, build-android] if: startsWith(github.ref, 'refs/tags/v') container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -329,63 +413,194 @@ jobs: name: jellytau-android path: artifacts/android/ + # Software Bill of Materials, one per half of the app. Without it there is + # no answer to "does this release contain ?" other than + # rebuilding the tag and re-resolving it. cargo-cyclonedx is in the builder + # image; the JS side is read straight from the lockfile bun install used. + - name: Generate SBOM + run: | + set -e + mkdir -p artifacts/sbom + cd src-tauri + cargo cyclonedx --format json + find . -maxdepth 2 -name "*.cdx.json" -exec cp -v {} ../artifacts/sbom/ \; + cd .. + bun install --frozen-lockfile + bun pm ls --all > artifacts/sbom/frontend-dependencies.txt + ls -lah artifacts/sbom/ + + # Checksums over everything being published. A release of unsigned Linux + # and Windows binaries with no checksum gives a user no way at all to tell + # a corrupted or substituted download from a good one — and the AppImage + # and NSIS installer are both fetched over plain HTTP redirects. + # + # Written with paths relative to the asset directory so `sha256sum -c + # SHA256SUMS` works in the directory a user downloaded into. + # The update manifest. Built before the checksums so latest.json is not + # itself hashed into SHA256SUMS (it is metadata about the release, not a + # download), and after the artifacts exist so the signatures can be read. + # + # Why a dedicated `updater` branch and a raw-file URL: this Gitea serves + # /releases/download// but returns 404 for + # /releases/latest/download/, so there is no stable "latest release" + # URL to point a client at. The gitea-pages branch is force-pushed whole by + # publish-docs.yml, so hosting the manifest there would delete it on the + # next docs build. An orphan branch that only ever contains latest.json is + # the one location both stable and ours. + - name: Build update manifest (latest.json) + id: manifest + run: | + set -e + VERSION="${{ steps.tag_name.outputs.VERSION }}" + # The manifest carries the bare version; the tag carries the v prefix. + PLAIN="${VERSION#v}" + BASE="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${VERSION}" + + # Tauri matches on "-". We ship one desktop arch today. + APPIMAGE_SIG="" + NSIS_SIG="" + APPIMAGE_URL="" + NSIS_URL="" + + for f in artifacts/linux/*.AppImage.tar.gz; do + [ -e "$f" ] || continue + APPIMAGE_URL="${BASE}/$(basename "$f")" + [ -e "$f.sig" ] && APPIMAGE_SIG="$(cat "$f.sig")" + done + + for f in artifacts/windows/*-setup.exe; do + [ -e "$f" ] || continue + NSIS_URL="${BASE}/$(basename "$f")" + [ -e "$f.sig" ] && NSIS_SIG="$(cat "$f.sig")" + done + + # A manifest with an empty signature is worse than no manifest: the + # client rejects it after downloading the whole payload. + if [ -z "$APPIMAGE_SIG" ] || [ -z "$NSIS_SIG" ]; then + echo "::error::Missing updater signature (appimage='$APPIMAGE_SIG' nsis='$NSIS_SIG')." + echo "::error::Check that TAURI_SIGNING_PRIVATE_KEY reached both desktop build jobs." + exit 1 + fi + + # Release notes for the update prompt come from the traceability graph, + # same source as the release body. + NOTES="$(bun run release:notes 2>/dev/null | head -c 4000 || echo "See the release page for details.")" + + jq -n \ + --arg version "$PLAIN" \ + --arg notes "$NOTES" \ + --arg pub_date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg lin_sig "$APPIMAGE_SIG" --arg lin_url "$APPIMAGE_URL" \ + --arg win_sig "$NSIS_SIG" --arg win_url "$NSIS_URL" \ + '{ + version: $version, + notes: $notes, + pub_date: $pub_date, + platforms: { + "linux-x86_64": { signature: $lin_sig, url: $lin_url }, + "windows-x86_64": { signature: $win_sig, url: $win_url } + } + }' > latest.json + + echo "📄 latest.json:" + cat latest.json + + - name: Publish latest.json to the updater branch + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + TOKEN="${GITEA_TOKEN:-$AUTO_TOKEN}" + HOST="$(echo "$GITHUB_SERVER_URL" | sed -E 's#^https?://##')" + REMOTE="https://oauth2:${TOKEN}@${HOST}/${GITHUB_REPOSITORY}.git" + + # Built in a scratch repo, NOT by switching branches in the checkout. + # `git checkout --orphan` here would leave every later step standing on + # a one-commit branch -- and the next step but one runs + # `bun run release:notes`, which resolves a commit range against the + # real history and would silently produce nothing. + WORK="$RUNNER_TEMP/updater-branch" + rm -rf "$WORK" + mkdir -p "$WORK" + cp latest.json "$WORK/latest.json" + cd "$WORK" + git init -q + git config user.email "ci@jellytau" + git config user.name "JellyTau CI" + git add latest.json + git commit -qm "chore(updater): manifest for ${{ steps.tag_name.outputs.VERSION }}" + echo "🚀 Force-pushing update manifest to the updater branch" + # Force-push: the branch holds exactly one file and no history worth + # keeping, same shape as publish-docs.yml's gitea-pages. + git push -f "$REMOTE" HEAD:refs/heads/updater + echo "✅ Served at ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/raw/branch/updater/latest.json" + + - name: Generate SHA256SUMS + run: | + set -e + mkdir -p artifacts/release + find artifacts/linux artifacts/windows artifacts/android -type f -exec cp -v {} artifacts/release/ \; + cd artifacts/release + sha256sum * > SHA256SUMS + echo "🔐 Published checksums:" + cat SHA256SUMS + # Verify what we just wrote, so a broken checksum file fails the + # release rather than shipping and failing for users. + sha256sum -c SHA256SUMS + + # Release notes come from the traceability graph, not from a hardcoded + # heredoc. scripts/release-notes.ts resolves the commit range's changed + # files to their TRACES ids and then to requirement descriptions, grouping + # UR into Features and DR/IR into Improvements -- which is what CLAUDE.md + # has asked for all along, while this workflow pasted a fixed block of + # install instructions and a line saying "see CHANGELOG.md for detailed + # changes". It also linked "GitHub Issues" on a Gitea-hosted project. - name: Prepare release notes id: release_notes run: | + set -e VERSION="${{ steps.tag_name.outputs.VERSION }}" - echo "## JellyTau $VERSION Release" > release_notes.md - echo "" >> release_notes.md - echo "### Downloads" >> release_notes.md - echo "" >> release_notes.md - echo "#### Linux" >> release_notes.md - echo "- **AppImage** - Run directly on most Linux distributions" >> release_notes.md - echo "- **DEB** - Install via \`sudo dpkg -i jellytau_*.deb\` (Ubuntu/Debian)" >> release_notes.md - echo "" >> release_notes.md - echo "#### Windows" >> release_notes.md - echo "- **Installer (.exe)** - Run \`jellytau_*-setup.exe\` (NSIS). Unsigned — SmartScreen may warn on first run." >> release_notes.md - echo "" >> release_notes.md - echo "#### Android" >> release_notes.md - echo "- **APK** - Install via \`adb install jellytau-release.apk\` or sideload via file manager" >> release_notes.md - echo "- **AAB** - Upload to Google Play Console or testing platforms" >> release_notes.md - echo "" >> release_notes.md - echo "### What's New" >> release_notes.md - echo "" >> release_notes.md - echo "See [CHANGELOG.md](CHANGELOG.md) for detailed changes." >> release_notes.md - echo "" >> release_notes.md - echo "### Installation" >> release_notes.md - echo "" >> release_notes.md - echo "#### Linux (AppImage)" >> release_notes.md - echo "\`\`\`bash" >> release_notes.md - echo "chmod +x jellytau_*.AppImage" >> release_notes.md - echo "./jellytau_*.AppImage" >> release_notes.md - echo "\`\`\`" >> release_notes.md - echo "" >> release_notes.md - echo "#### Linux (DEB)" >> release_notes.md - echo "\`\`\`bash" >> release_notes.md - echo "sudo dpkg -i jellytau_*.deb" >> release_notes.md - echo "jellytau" >> release_notes.md - echo "\`\`\`" >> release_notes.md - echo "" >> release_notes.md - echo "#### Android" >> release_notes.md - echo "- Sideload: Download APK and install via file manager or ADB" >> release_notes.md - echo "- Play Store: Coming soon" >> release_notes.md - echo "" >> release_notes.md - echo "### Known Issues" >> release_notes.md - echo "" >> release_notes.md - echo "See [GitHub Issues](../../issues) for reported bugs." >> release_notes.md - echo "" >> release_notes.md - echo "### Requirements" >> release_notes.md - echo "" >> release_notes.md - echo "**Linux:**" >> release_notes.md - echo "- 64-bit Linux system" >> release_notes.md - echo "- GLIBC 2.29+" >> release_notes.md - echo "" >> release_notes.md - echo "**Android:**" >> release_notes.md - echo "- Android 8.0 or higher" >> release_notes.md - echo "- 50MB free storage" >> release_notes.md - echo "" >> release_notes.md - echo "---" >> release_notes.md - echo "Built with Tauri, SvelteKit, and Rust" >> release_notes.md + { + echo "## JellyTau $VERSION" + echo "" + # A generated summary of what actually changed; falls back to a + # pointer rather than failing the release if the range is odd. + bun run release:notes 2>/dev/null || echo "See the commit log for changes in this release." + echo "" + echo "### Downloads" + echo "" + echo "| Platform | File |" + echo "|---|---|" + echo "| Linux (portable) | \`*.AppImage\` — \`chmod +x\` and run |" + echo "| Linux (Debian/Ubuntu) | \`*.deb\` — \`sudo dpkg -i\` |" + echo "| Linux (Fedora/openSUSE) | \`*.rpm\` — \`sudo rpm -i\` |" + echo "| Windows | \`*-setup.exe\` (NSIS). Unsigned — SmartScreen may warn on first run. |" + echo "| Android | \`*.apk\` sideload, or \`*.aab\` for Play Console |" + echo "" + echo "Desktop builds update themselves from here on: JellyTau checks this" + echo "release feed and can install a new version in place." + echo "" + echo "### Verifying your download" + echo "" + echo "\`\`\`bash" + echo "sha256sum -c SHA256SUMS" + echo "\`\`\`" + echo "" + echo "\`SHA256SUMS\` covers every file in this release. An SBOM" + echo "(\`*.cdx.json\`, \`frontend-dependencies.txt\`) lists what went into it." + echo "" + echo "### Requirements" + echo "" + echo "- **Linux:** 64-bit, GLIBC 2.29+" + echo "- **Windows:** 64-bit Windows 10 or later" + echo "- **Android:** 8.0 or later, ~50 MB free" + echo "" + echo "---" + echo "Report a problem: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/issues" + } > release_notes.md + echo "📝 Release notes:" + cat release_notes.md - name: Publish Gitea release & upload assets env: @@ -425,7 +640,10 @@ jobs: fi echo "Release id=$RELEASE_ID" - for f in artifacts/android/* artifacts/linux/* artifacts/windows/*; do + # artifacts/release/ holds a copy of every platform artifact plus the + # SHA256SUMS generated over exactly that set, so the checksums describe + # precisely what is uploaded. artifacts/sbom/ rides along. + for f in artifacts/release/* artifacts/sbom/*; do [ -f "$f" ] || continue echo "⬆️ Uploading $(basename "$f")" curl -fsS -X POST \ diff --git a/.gitea/workflows/publish-docs.yml b/.gitea/workflows/publish-docs.yml index 6aa1b4000..a740666c2 100644 --- a/.gitea/workflows/publish-docs.yml +++ b/.gitea/workflows/publish-docs.yml @@ -21,7 +21,7 @@ jobs: name: Build & publish docs to gitea-pages runs-on: linux/amd64 container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 steps: - name: Checkout code @@ -34,14 +34,13 @@ jobs: - name: Install dependencies run: bun install - - name: Install mdBook - run: | - set -e - MDBOOK_VERSION=v0.4.40 - URL="https://github.com/rust-lang/mdBook/releases/download/${MDBOOK_VERSION}/mdbook-${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz" - echo "⬇️ Downloading mdBook ${MDBOOK_VERSION}" - curl -fsSL "$URL" | tar -xz -C /usr/local/bin - mdbook --version + # mdBook is baked into jellytau-builder (Dockerfile.builder, MDBOOK_VERSION). + # It used to be curl'd from GitHub releases straight into /usr/local/bin + # right here, which was a toolchain install at job time — the exact thing + # CLAUDE.md's 🔴 rule forbids — and made every docs publish depend on + # GitHub's CDN answering. To move the version, bump it in the image. + - name: Confirm mdBook is present + run: mdbook --version - name: Regenerate traceability matrix (keep published copy current) run: bun run traces:markdown diff --git a/.gitea/workflows/traceability-check.yml b/.gitea/workflows/traceability-check.yml index b42920cb6..58e9562da 100644 --- a/.gitea/workflows/traceability-check.yml +++ b/.gitea/workflows/traceability-check.yml @@ -17,7 +17,7 @@ jobs: runs-on: linux/amd64 name: Check Requirement Traces container: - image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08 steps: - name: Checkout repository @@ -46,7 +46,7 @@ jobs: # hardcode them here. This step previously divided by frozen literals # (UR/39, IR/24, DR/48, JA/3, total 114) while the file had grown to # 211 requirements, so it reported 158% coverage and the threshold - # below could never trip. See docs/specs/traceability-gate-repair.md. + # below could never trip. See docs/traceability-ci.md. TOTAL_TRACES=$(jq '.totalTraces' traces-report.json) COVERED=$(jq '.coverage.covered' traces-report.json) TOTAL_REQS=$(jq '.coverage.total' traces-report.json) @@ -81,8 +81,20 @@ jobs: exit 1 fi - # Check minimum threshold - MIN_THRESHOLD=50 + # Minimum coverage. RATCHET POLICY: this number only ever goes UP. + # + # It sits a few points under the coverage actually achieved, so a real + # regression trips it. It was 50 while true coverage was 86%, which + # meant nearly half the matrix could rot before CI said a word — a + # gate that cannot fail is not a gate. + # + # When coverage rises durably, raise this to just under the new figure + # (`bun run traces:coverage` prints it). Never lower it to make a red + # build pass — add the missing TRACES comments instead. + # + # Keep in sync with MIN_COVERAGE_PERCENT in scripts/extract-traces.ts; + # scripts/extract-traces.test.ts fails if the two drift apart. + MIN_THRESHOLD=89 if [ "$COVERAGE" -lt "$MIN_THRESHOLD" ]; then echo "❌ ERROR: Coverage ($COVERAGE%) is below minimum threshold ($MIN_THRESHOLD%)" exit 1 @@ -90,6 +102,15 @@ jobs: echo "✅ Coverage is acceptable ($COVERAGE% >= $MIN_THRESHOLD%)" + # Every ID named by a TRACES comment must be defined as a table row in + # docs/requirements.md. The extractor used to accept any well-formed ID + # silently, so a typo or a rename that missed a call site passed CI + # unnoticed (DR-189 and UT-188 lived in three source files, defined + # nowhere, for months). This covers UT/IT too, which the coverage + # orphan list above deliberately ignores. + - name: Validate requirement IDs + run: bun run traces:validate + - name: Check modified files if: github.event_name == 'pull_request' run: | diff --git a/.gitignore b/.gitignore index 9fddd7304..6d2418e78 100644 --- a/.gitignore +++ b/.gitignore @@ -30,11 +30,6 @@ coverage .nyc_output *.lcov -# WebdriverIO E2E tests -e2e/logs/ -e2e/screenshots/ -wdio-*.log - # Vitest .vitest diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..f6dbee729 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,35 @@ +# Dependencies & build output +node_modules/ +.svelte-kit/ + +# Scratch worktrees (git-ignored) — full checkouts of this repo +.claude/ +build/ +dist/ +coverage/ +/package/ + +# Rust backend (rustfmt owns this tree) +src-tauri/ + +# Generated by tauri-specta — regenerated on every Rust build, never hand-edited +src/lib/api/bindings.ts + +# Lockfiles and generated data +bun.lock +*.lcov + +# Generated docs (built by the publish-docs CI job) +docs/SUMMARY.md +docs/README.md +docs/api-redirect.md +docs-site/book/ + +# Hand-maintained Markdown (docs/, CHANGELOG.md, README.md, ...). Prettier +# reflows tables and wrapped prose, which would swamp real doc diffs and fight +# the hand-tuned layout of docs/requirements.md and docs/traceability.md +# (the latter is generated by scripts/extract-traces.ts). +**/*.md + +# CI workflow YAML — formatting churn here would obscure real pipeline diffs. +.gitea/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..cd2a15aae --- /dev/null +++ b/.prettierrc @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/prettierrc", + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "quoteProps": "as-needed", + "trailingComma": "all", + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf", + "plugins": ["prettier-plugin-svelte"], + "overrides": [ + { + "files": "*.svelte", + "options": { "parser": "svelte" } + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 73294c8ae..332f71d45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,670 @@ Entries are grouped by the capability they change, not by commit. Requirement IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the generated trace matrix lives in [docs/traceability.md](docs/traceability.md). +For how long each fixed defect had been shipping before it was found, see +[docs/defect-windows.md](docs/defect-windows.md). + +## v0.9.1 + +A one-line fix to the home screen, released on its own because it is the kind of +small wrongness you notice every time. + +### 🐛 Fixes + +- **Swiping the hero banner now buys you a full six seconds.** The rotation + timer was started once when the banner appeared and then left alone, so a + swipe, arrow or dot tap inherited whatever was left of the running countdown + — swipe five and a half seconds in and the banner moved on half a second + later, before you had read the title. Any manual change now restarts the + countdown from that moment. (UR-034 → DR-038) + +## v0.9.0 + +An audit release. One new setting you asked for, two naming bugs that only ever +showed in builds a developer never looks at, and a large amount of tidying that +should be invisible in use. + +Note for anyone upgrading a Linux package: the Debian/RPM package is now called +`jelly-tau` rather than `jellytau` (the packager derives it from the app name). +It declares the rename, so `apt`/`dnf` will replace the old package rather than +install a second copy. The command is still `jellytau`. + +### ✨ Changes + +- **You can now hide library folders from music browsing.** Pick the folders to + exclude in Settings; they disappear from albums, artists, genres, search and + the home rows alike. This replaces a filter that dropped anything *named* + "Podcasts" — one person's library layout compiled into the app, which meant an + album genuinely called "Podcasts" vanished while a podcast folder named + anything else stayed. Exclusion now matches on the folder itself, is decided + in one place rather than at the six screens someone remembered to filter, and + defaults to excluding nothing. (UR-076 → DR-209) + +- **The app is called JellyTau again.** The Android release build showed + `jellytau` under its icon, and the Linux and Windows packages carried the same + lowercase name. The debug build has always overridden the label to "JellyTau + Debug", so the install a developer looks at every day was the only correctly + cased one and nobody saw it. (DR-214) + +- **The RPM package is published.** It has been built by every release since + Linux packaging was added, and never copied out of the build — so it existed, + cost build time, and reached nobody. (DR-214) + +- **Linux and Windows packages carry their own metadata.** Publisher, copyright, + category, description and licence were all absent, so the packages installed + with no maintainer and no description. The hand-written Arch package had all + of it; only the generated packaging was missing it. (DR-214) + +### 🔒 Hardening + +None of these were reachable in normal use — the app refuses plain-`http` +servers, Android blocks cleartext, and the webview runs under a CSP that bars +inline script — so they are consistency fixes rather than incidents. Each one +had the correct pattern already in the same file, a few lines away. + +- **Thumbnail cache writes stay inside the cache directory.** The filename was + built from three values but only one was sanitised, and joining a path does not + fold `..` or keep the base when handed an absolute path. (DR-210) + +- **Download paths stay inside the download directory.** A correct sanitiser + already existed, but the command that queues a download accepted a raw path, + so the guard could be routed around rather than being absent. (DR-211) + +- **Query and URL values are bound and encoded, not pasted in.** The offline + item-type filter built SQL by string formatting while its sibling query used + placeholders, and browse URLs left values unencoded while the genre parameter + next to them was encoded properly. Volume is also range-checked at the command + boundary instead of relying on each player backend. (DR-212) + +### 🛠 Development + +Nothing here changes the app, but the previous release's audit found the tooling +claiming more than it delivered, and this is the repair. + +- **The frontend has a real logger.** 484 `console` calls shipped to users and + ran on every device; the Rust half has had levelled logging with a runtime + override since the beginning. There is now a matching facade — quiet in + release builds, verbose in debug ones, with warnings and errors never + suppressed and `localStorage` able to turn the volume up in a shipped build to + diagnose a problem. (DR-204) + +- **The traceability matrix is navigable.** Every one of its ~2,800 file links + was broken: the generator wrote repo-root paths into a file that lives in + `docs/`. The document the whole traceability system exists to produce could not + be clicked through, and had no test. Both are fixed, and a link checker now + fails the build on a dead documentation link. (DR-093, DR-208) + +- **The Rust toolchain is pinned.** Developer machines and CI were five releases + apart, which meant a clean `cargo clippy` locally proved nothing about CI — the + same tree measured zero warnings on one and three on the other. With both sides + on the same compiler, clippy is now a hard gate instead of advisory. (DR-206) + +- **The frontend has a linter and formatter**, its first — the Rust half has had + `cargo fmt --check` and clippy in CI for a while. A pre-commit hook runs the + fast checks, so the "before committing" list is enforced rather than + remembered. (DR-205, DR-207) + +- **Containerised builds no longer leave root-owned files** in the working tree, + which had accumulated to the point of breaking `cargo clean` and, eventually, + `cargo build` itself. (DR-213) + +- Removed: a webdriverio end-to-end suite that had not run in seven months and + was wired into nothing, and a frontend validation module whose six exported + functions had no caller outside their own tests — which made it read as + covered input validation while guarding nothing. + +## v0.8.2 + +A single fix, for Android background audio. + +### 🐛 Fixes + +- **Listening to a video in the background no longer jumps back to where you + started.** Handing a video off to background audio streams a live mp3 + transcode, which is chunked — no length, and no duration the player can read. + ExoPlayer resumes a failed load in place only when it knows one of those two + things; with neither it assumes the source is live and re-requests the URL from + the beginning. That URL starts at the moment you locked the screen, so a + network blip left a retry armed, and when the buffer eventually ran dry — + minutes later, with nothing in between — playback silently resumed from the + handoff point and carried on. No error was raised and nothing ended, so none of + the existing stream-recovery paths could see it; the only sign was a position + that went backwards, which is why it looked random. The player is now refused + its own retry for exactly that kind of stream, so the failure surfaces and the + backend re-opens the stream at the position playback actually reached, keeping + your selected audio track. Music and video are untouched: both declare their + timeline, and the player resumes them where the load stopped. + (UR-040, UR-004 → DR-203) + +## v0.8.1 + +A single fix, for Android. + +### 🐛 Fixes + +- **The screen no longer sleeps while you are watching something.** Android + counts its display timeout from the last time you touched the phone, and + watching a film is exactly when you do not — so the picture dimmed and the + screen went out mid-playback unless you kept tapping it. Nothing in the app + ever asked the display to stay on, and neither video renderer does so by + itself: ExoPlayer's wake mode keeps the CPU and wifi alive but says nothing + about the screen, and an embedded WebView does not take the display wake lock + that a browser takes for `