Files
jellytau/docs/specs/build-provenance.md
T
dtourolle 48f63dd763
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m15s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
docs(spec): build provenance — git describe + build profile
A running JellyTau currently reports no version anywhere: not in the UI, not in
the logs. When a user reports "the equalizer does nothing on my device" there is
no way to tell whether they are on the v0.2.0 tag, master, or a three-week-old
local debug build — a live gap given v0.2.0's Android audio settings are not yet
device-verified.

Specifies a build.rs-emitted `git describe --tags --always --dirty`, a typed
BuildKind (Release/Untagged/Development/Unknown) classified in Rust rather than
pattern-matched in the UI, a get_build_info command, startup logging, and a
Settings > About block with copy-to-clipboard for bug reports.

Explicitly does NOT derive the release version from git: Cargo needs a literal
semver at manifest-parse time, so sourcing it from a tag would trade a
reviewable bump for a build-time dependency that fails in CI's shallow Docker
clones. The release version stays authored; only the provenance is derived —
they answer different questions.

Two constraints found while writing this:
- Only publish-docs.yml sets fetch-depth: 0. build-release.yml has five
  checkouts and build-and-test.yml two, all of which would stamp "unknown"
  as-is. Flagged as an acceptance criterion.
- tauri.conf.json's version field can be dropped to fall back to Cargo (three
  hand-bumped files becomes two), but gen/android/app/build.gradle.kts reads
  versionName/versionCode from generated Tauri properties, so that must be
  verified before adopting rather than assumed.
2026-07-28 23:19:34 +02:00

251 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.