diff --git a/docs/specs/build-provenance.md b/docs/specs/build-provenance.md new file mode 100644 index 00000000..224f9f40 --- /dev/null +++ b/docs/specs/build-provenance.md @@ -0,0 +1,250 @@ +# Spec: Build provenance (git describe + build profile) + +**Status:** Proposed +**Requirements:** new DR-093 (build provenance surfaced in-app and in logs); no UR — this is a diagnostic capability, not a user feature +**UX spec:** n/a — adds an About block to Settings; no new flow +**Supersedes / revises:** — + +## Summary + +Make every build say exactly what it is. Today a running JellyTau reports no +version at all — not in the UI, not in the logs — and the only version string in +the tree is the hand-maintained `0.2.0` duplicated across three files. + +This adds a `build.rs`-generated provenance string (`git describe` + short SHA + +dirty flag + debug/release profile), exposes it over IPC, and renders it in a new +Settings › About block. It also removes one of the three hand-bumped version +files. + +## Motivation + +The concrete problem: when a user reports "the equalizer does nothing on my +device" — which is a live risk for v0.2.0, whose Android audio settings are not +yet device-verified — there is currently no way to tell which build they are +running. Tag? Master? A local debug build from three weeks ago? The bug report +cannot distinguish them. + +Two smaller irritations this also fixes: + +- **Debug builds masquerade as releases.** `0.2.0` is `0.2.0` whether it came + from a tagged release or `bun run tauri dev`. +- **Three files carry the version.** `package.json`, `src-tauri/Cargo.toml` and + `src-tauri/tauri.conf.json` must be bumped in lockstep; the release checklist + exists partly to stop them drifting. + +### What this deliberately does *not* do + +**The canonical version stays hand-bumped in `Cargo.toml`.** Cargo requires a +literal semver string at manifest-parse time and cannot derive it from git. The +same is true of `tauri.conf.json`. Attempting to source the *release* version +from a tag trades a scripted, reviewable bump for a fragile build-time +dependency that breaks in exactly the environment we care most about (CI, in +Docker, from a shallow clone). + +So: **the release version is authored; the build provenance is derived.** They +answer different questions — "what release is this?" versus "what commit is this +binary actually built from?" — and only the second benefits from git. + +## Layer assignment + +| Logic / responsibility | Layer | Why it belongs there | +|------------------------|-------|----------------------| +| Capturing git describe / SHA / dirty state at compile time | Rust (`build.rs`) | Only the Rust build has a compile step that can shell out to git and bake the result into the binary. A frontend equivalent would report the *dev server's* state, not the shipped binary's. | +| Degrading to a sentinel when git is unavailable | Rust (`build.rs`) | Build-environment concern. Must never fail the build — CI runs in Docker from a shallow clone. | +| Release version (`0.2.0`) | Rust (`Cargo.toml`, authored) | Domain fact about the product, not derivable from the environment. | +| Deciding *what a build is* (release / dev / dirty) | Rust | Domain classification. The frontend must not infer "this is a dev build" from a string shape — it renders what it is told. | +| Rendering the About block, copy-to-clipboard | Frontend | Pure presentation. | + +Borderline row: the release/dev/dirty classification could be done in the +frontend by pattern-matching the describe string. It goes to Rust because that is +a *rule about what constitutes a release build*, and it would have to change if +the tagging scheme changed — the litmus test in the template puts that in Rust. +Send a typed enum, not a string for the frontend to parse. + +## Design + +### `build.rs` + +```rust +fn main() { + emit_build_provenance(); + tauri_build::build() +} + +fn emit_build_provenance() { + let describe = std::process::Command::new("git") + .args(["describe", "--tags", "--always", "--dirty"]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + println!("cargo:rustc-env=JELLYTAU_GIT_DESCRIBE={describe}"); + + // Rebuild when HEAD moves or a ref is written, so the string does not go + // stale across commits. Guarded: these paths do not exist in a git-less + // source tarball, and emitting rerun-if-changed for a missing path would + // force a rebuild every time. + for p in [".git/HEAD", ".git/refs"] { + if std::path::Path::new("../").join(p).exists() { + println!("cargo:rerun-if-changed=../{p}"); + } + } +} +``` + +🔴 **`build.rs` must never fail the build.** Every git call is +`.ok()`-swallowed; a missing git binary, a shallow clone, or a source tarball all +yield `"unknown"`. A build that breaks because git is absent would be a worse bug +than the one this fixes. + +Note the `../` prefixes: `build.rs` runs with CWD at `src-tauri/`, so the repo's +`.git` is one level up. + +### The provenance type + +```rust +/// TRACES: DR-093 +#[derive(specta::Type, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildInfo { + /// Authored release version (Cargo.toml). + pub version: String, + /// `git describe --tags --always --dirty`, or "unknown". + pub git_describe: String, + /// What kind of build this is — classified in Rust, not inferred by the UI. + pub kind: BuildKind, +} + +/// TRACES: DR-093 +#[derive(specta::Type, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum BuildKind { + /// Built from a clean, exactly-tagged commit in release mode. + Release, + /// Release-mode build that is not on a clean tag (e.g. master, or dirty). + Untagged, + /// debug_assertions build. + Development, + /// Git state unavailable at build time. + Unknown, +} +``` + +Classification: + +```rust +let kind = if cfg!(debug_assertions) { + BuildKind::Development +} else if describe == "unknown" { + BuildKind::Unknown +} else if describe.contains('-') { // "v0.2.0-3-gcb79a37" or "...-dirty" + BuildKind::Untagged +} else { + BuildKind::Release +}; +``` + +### Command + +```rust +/// TRACES: DR-093 +#[tauri::command] +#[specta::specta] +pub fn get_build_info() -> BuildInfo { … } +``` + +No parameters, so the camelCase param rule does not apply; the struct fields do +need `#[serde(rename_all = "camelCase")]` (above). Regenerate `bindings.ts`. + +Also log the provenance once at startup, next to the existing init logging — +that is what makes a user-submitted log file self-identifying, which is most of +the value. + +### Settings › About + +A new block at the bottom of `src/routes/settings/+page.svelte`, rendering +version, describe string, and a badge for non-release builds. One +copy-to-clipboard button that yields a paste-ready block for bug reports: + +``` +JellyTau 0.2.0 (v0.2.0-3-gcb79a37-dirty, development) +linux x86_64 +``` + +Platform/arch come from the existing Tauri APIs; do not shell out. + +### Removing one version file + +`tauri.conf.json`'s `"version"` field can be omitted, in which case Tauri falls +back to the Cargo version. That takes the bump from three files to two. + +**Verify before adopting**: confirm the Android `versionName`/`versionCode` and +the NSIS installer version still resolve correctly with the field absent — +Android packaging in particular reads the Tauri config. If either regresses, +keep the field and drop this part; it is a convenience, not the point of the +spec. + +## Out of scope + +- Deriving the *release* version from git tags (see Motivation). +- A build-time timestamp. It defeats reproducible builds and adds little over + the commit SHA. +- CI provenance/attestation, SBOM, signing. +- Displaying the Jellyfin server version (separate concern, already available + from `/System/Info`). + +## Acceptance criteria + +- [ ] `cargo build` succeeds with git absent, from a shallow clone, and from a source tarball with no `.git` — yielding `"unknown"` in each case, never a build failure. +- [ ] A tagged clean release build reports `BuildKind::Release`; `bun run tauri dev` reports `Development`; a dirty tree reports `Untagged` (release mode) with `-dirty` in the describe string. +- [ ] The describe string changes after a new commit without a manual `cargo clean` (rerun-if-changed works). +- [ ] Provenance is logged once at startup. +- [ ] Settings › About renders version + describe + build-kind badge, with working copy-to-clipboard. +- [ ] 🔴 CI checkouts that build a shippable artifact set `fetch-depth: 0`, or their artifacts are knowingly stamped `unknown`. Currently only `publish-docs.yml` sets it; `build-release.yml` has five checkouts and `build-and-test.yml` two, all of which would report `unknown` as-is. +- [ ] **No toolchain installed in CI** — git is already present in the builder image; nothing new is added. +- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass. +- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes. +- [ ] `bindings.ts` regenerated. +- [ ] DR-093 allocated in `requirements.md`; new code carries `// TRACES:`. + +## Testing + +**Rust**: the classification is pure and must be extracted from the command as +`classify_build(describe: &str, debug: bool) -> BuildKind` so it can be tested +directly. Cover: `"v0.2.0"` → `Release`; `"v0.2.0-3-gcb79a37"` → `Untagged`; +`"v0.2.0-dirty"` → `Untagged`; `"unknown"` → `Unknown`; `debug = true` → always +`Development` regardless of describe. + +`build.rs` itself is not unit-testable. Verify its failure path manually by +building with `PATH` stripped of git, and from a `git archive` tarball — both +must succeed with `"unknown"`. + +**Frontend**: assert the About block renders each `BuildKind` correctly, and that +it renders the backend-supplied kind rather than re-deriving it from the string +(a test that passes a `Release` kind with a `-dirty` describe and asserts the +badge follows the *kind* would catch that regression). + +## TRACES + +- `build.rs` provenance emission → `// TRACES: | DR-093` +- `BuildInfo` / `BuildKind` / `classify_build` → `// TRACES: | DR-093` +- `get_build_info` command → `// TRACES: | DR-093` +- Settings About block → `// TRACES: | DR-093` +- `classify_build` tests → `UT-BUILD-1` +- Allocate **DR-093** in `requirements.md` ("Build provenance: git describe and + build profile surfaced in-app and in logs"). Next free DR at time of writing + is DR-093. + +## Notes for the implementer + +- Do the `build.rs` + command + logging first; the About UI is the smaller half + and the logging alone delivers most of the diagnostic value. +- The `fetch-depth: 0` change is the easiest part to forget and the one that + makes CI artifacts useless if missed — it is why that acceptance box is + flagged. Weigh it per workflow: test-only jobs do not need it. +- Do not add a build timestamp "while you are in there" — see Out of scope. +- A parallel Claude session may be active — `git diff` before "repairing" + unexpected changes.