# Spec: Build provenance (git describe + build profile)

Status: Proposed — not started. `src-tauri/build.rs` still contains only `tauri_build::build()`, and nothing reports a version over IPC. Note that `scripts/set-version.sh` has since landed, which changes the "three hand-bumped files" premise below: versions are now stamped from one place. Requirements: ⚠️ the suggested id DR-093 has since been allocated to the traceability coverage gate — allocate a fresh id (DR-215 or later) on implementation. 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`

    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

    #![allow(unused)]
    fn main() {
    /// 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:

    #![allow(unused)]
    fn main() {
    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

    #![allow(unused)]
    fn main() {
    /// 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.