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.
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user