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.0is0.2.0whether it came from a tagged release orbun run tauri dev. - Three files carry the version.
package.json,src-tauri/Cargo.tomlandsrc-tauri/tauri.conf.jsonmust 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 buildsucceeds 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 devreportsDevelopment; a dirty tree reportsUntagged(release mode) with-dirtyin 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 stampedunknown. Currently onlypublish-docs.ymlsets it;build-release.ymlhas five checkouts andbuild-and-test.ymltwo, all of which would reportunknownas-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:boundarypass. -
cargo fmtclean,cargo clippyclean,bun run test:rustpasses. -
bindings.tsregenerated. -
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.rsprovenance emission →// TRACES: | DR-093BuildInfo/BuildKind/classify_build→// TRACES: | DR-093get_build_infocommand →// TRACES: | DR-093- Settings About block →
// TRACES: | DR-093 classify_buildtests →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: 0change 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 diffbefore "repairing" unexpected changes.