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 / responsibilityLayerWhy it belongs there
Capturing git describe / SHA / dirty state at compile timeRust (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 unavailableRust (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)RustDomain 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-clipboardFrontendPure 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.