diff --git a/.github/workflows/release-builds.yml b/.github/workflows/release-builds.yml index 560a1e1b..8ac38ed7 100644 --- a/.github/workflows/release-builds.yml +++ b/.github/workflows/release-builds.yml @@ -42,7 +42,14 @@ jobs: portable-path: src-tauri/target/release/gridfpv-desktop steps: + # fetch-tags is load-bearing for the build stamp (#513): a build names itself by its + # release version only when HEAD sits exactly on a clean v* tag, so the tags must be + # present in the checkout. Dispatch this workflow FROM the tagged commit to produce a + # release-named build; a dispatch from an untagged commit correctly produces the + # 0.4.0-dev- naming (the dev-* prerelease portables). - uses: actions/checkout@v7 + with: + fetch-tags: true # Linux-only: Tauri's webkit2gtk/GTK system deps (Windows uses the bundled # WebView2 runtime and needs none of these). Mirror src-tauri/README.md. diff --git a/crates/app/src/logging.rs b/crates/app/src/logging.rs index 51c4f118..28d4d0b2 100644 --- a/crates/app/src/logging.rs +++ b/crates/app/src/logging.rs @@ -267,7 +267,7 @@ fn open_sink() -> Option { .unwrap_or_else(|_| "".to_string()); sink.write_line(&format!( "==== GridFPV {} — session start (pid {}, {} {}, exe {exe}) ====", - env!("CARGO_PKG_VERSION"), + gridfpv_server::BUILD_VERSION, std::process::id(), std::env::consts::OS, std::env::consts::ARCH, diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index b6be6fc8..4254f805 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -111,7 +111,10 @@ fn print_startup( bound.to_string() }; - println!("GridFPV Director {} — serving", env!("CARGO_PKG_VERSION")); + println!( + "GridFPV Director {} — serving", + gridfpv_server::BUILD_VERSION + ); println!(" listening on : http://{bound}"); println!(" console URL : http://{url_host}/"); match rd_token { @@ -182,7 +185,7 @@ async fn shutdown_signal() { fn run_demo() -> Result<(), Box> { println!( "GridFPV {} — walking-skeleton demo\n", - env!("CARGO_PKG_VERSION") + gridfpv_server::BUILD_VERSION ); let events = synthetic_session( diff --git a/crates/server/build.rs b/crates/server/build.rs new file mode 100644 index 00000000..274a4397 --- /dev/null +++ b/crates/server/build.rs @@ -0,0 +1,85 @@ +//! Stamps `GRIDFPV_BUILD_VERSION` — the version the running build *reports* (#513), read back +//! as [`gridfpv_server::BUILD_VERSION`] and served on `/about` (the console's brand stamp, the +//! Director banner, the log session header). +//! +//! The scheme, per the field ruling (#513): **a release names itself, everything else names its +//! commit.** Before this every build — the `dev-*` prerelease portables included — reported the +//! static workspace version (`0.4.0-alpha.1`), so "which build is this?" was unanswerable on a +//! support call. +//! +//! Resolution order: +//! 1. `GRIDFPV_RELEASE_VERSION` env (leading `v` stripped) — the explicit override for a build +//! pipeline that knows exactly what it is shipping. +//! 2. HEAD exactly on a **clean** `v*` tag ⇒ the tag minus the `v` — the standard +//! alpha/beta/full naming (`0.4.0-alpha.1`, `0.4.0`). The `--match "v*"` filter is +//! load-bearing: the `dev-2026-*` prerelease tags are exactly the builds that must NOT name +//! themselves like releases. +//! 3. Otherwise ⇒ `-dev-`, `-dirty` appended when the tree has +//! uncommitted changes (a dirty build is not its hash). +//! 4. No usable git at all (a source tarball) ⇒ the workspace version verbatim. + +use std::process::Command; + +fn main() { + // Re-stamp when HEAD moves (commit / checkout / tag) or the index changes (staging is the + // cheapest observable signal that the dirty state may have flipped). Both relative to the + // workspace root's .git, two levels up from this crate. + println!("cargo:rerun-if-changed=../../.git/HEAD"); + println!("cargo:rerun-if-changed=../../.git/index"); + println!("cargo:rerun-if-env-changed=GRIDFPV_RELEASE_VERSION"); + + let pkg = std::env::var("CARGO_PKG_VERSION").expect("cargo sets CARGO_PKG_VERSION"); + println!( + "cargo:rustc-env=GRIDFPV_BUILD_VERSION={}", + build_version(&pkg) + ); +} + +fn build_version(pkg: &str) -> String { + if let Ok(v) = std::env::var("GRIDFPV_RELEASE_VERSION") { + let v = v.trim(); + if !v.is_empty() { + return v.trim_start_matches('v').to_string(); + } + } + // `git()` yields None for empty output, so Some(porcelain) == a dirty tree. + let dirty = git(&["status", "--porcelain"]).is_some(); + if !dirty { + if let Some(tag) = git(&[ + "describe", + "--exact-match", + "--tags", + "--match", + "v*", + "HEAD", + ]) { + return tag.trim_start_matches('v').to_string(); + } + } + let Some(hash) = git(&["rev-parse", "--short=7", "HEAD"]) else { + // No git (a source tarball / a stripped checkout): the workspace version verbatim is + // the most honest answer left. + return pkg.to_string(); + }; + // The dev base is the workspace version's MAJOR.MINOR.PATCH — the milestone being worked + // toward — with the `-alpha.N`-style tail dropped; the commit hash is the real identity. + let base = pkg.split('-').next().unwrap_or(pkg); + let dirty = if dirty { "-dirty" } else { "" }; + format!("{base}-dev-{hash}{dirty}") +} + +/// Run `git` in the crate directory (git walks up to the workspace root on its own); `None` on +/// a failed/missing git or empty output, so callers read `Some` as a real answer. +fn git(args: &[&str]) -> Option { + let out = Command::new("git").args(args).output().ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8(out.stdout).ok()?; + let s = s.trim(); + if s.is_empty() { + None + } else { + Some(s.to_string()) + } +} diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 0e8da6f5..dce008ad 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -383,14 +383,16 @@ pub fn router(registry: EventRegistry) -> Router { .route("/health", get(|| async { "ok" })) // The product identity (alpha field-support): WHICH build is this rig running? The // console footer reads it, and a bug report from the field should quote it. The - // version is the ONE workspace version (v0.4.0-alpha.1 scheme, `cargo xtask version`); - // the contract version is the independent wire-compat integer. + // version is the BUILD's version (#513): a release names itself (the v0.4.0-alpha.1 + // scheme, `cargo xtask version`), any other build names its commit + // (`0.4.0-dev-`); the contract version is the independent wire-compat + // integer. .route( "/about", get(|| async { Json(serde_json::json!({ "name": "GridFPV", - "version": env!("CARGO_PKG_VERSION"), + "version": crate::BUILD_VERSION, "contract_version": crate::CONTRACT_VERSION, })) }), diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 48dbaef3..1d25d360 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -105,6 +105,14 @@ impl ContractVersion { /// The contract version this server build speaks. The first wire contract is `1`. pub const CONTRACT_VERSION: ContractVersion = ContractVersion::new(1); +/// The **product version this build reports** (#513), stamped by `build.rs`: a release build +/// (HEAD on a clean `v*` tag, or an explicit `GRIDFPV_RELEASE_VERSION`) names itself by its +/// standard alpha/beta/full version, and every other build names its commit — +/// `0.4.0-dev-` (`-dirty` when the tree had uncommitted changes). This is what +/// `/about` serves and the console's brand stamp shows; use it in place of +/// `CARGO_PKG_VERSION` anywhere a human reads a version off a running build. +pub const BUILD_VERSION: &str = env!("GRIDFPV_BUILD_VERSION"); + /// The **oldest** contract version this server still serves (protocol.html §7, §9.7) — /// the bottom of the supported band `MIN_SUPPORTED_CONTRACT_VERSION..=CONTRACT_VERSION`. /// @@ -254,4 +262,20 @@ mod tests { let back: ServerHello = serde_json::from_str(&json).unwrap(); assert_eq!(reply, back); } + + #[test] + fn build_version_is_semver_shaped_whatever_named_this_build() { + // The stamp (#513) resolves differently per build (a release tag, a dev commit hash, a + // tarball fallback), but every spelling must satisfy the shape the /about contract test + // pins: x.y.z with an optional -prerelease tail. This is the compile-time proof the + // build.rs emitted SOMETHING sane for the build running these tests. + let v = crate::BUILD_VERSION; + let (core, _tail) = v.split_once('-').unwrap_or((v, "")); + assert_eq!(core.split('.').count(), 3, "MAJOR.MINOR.PATCH core: {v:?}"); + assert!( + core.split('.') + .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit())), + "numeric core: {v:?}" + ); + } } diff --git a/frontend/apps/rd-console/src/lib/buildVersion.svelte.ts b/frontend/apps/rd-console/src/lib/buildVersion.svelte.ts index 0a12d126..0475f510 100644 --- a/frontend/apps/rd-console/src/lib/buildVersion.svelte.ts +++ b/frontend/apps/rd-console/src/lib/buildVersion.svelte.ts @@ -26,8 +26,9 @@ let version = $state(undefined); let asked = false; /** - * The Director's version string (`"0.4.0-alpha.1"`), or `undefined` until the read lands — or for - * good, if it never does. Reading this the first time starts the fetch. + * The Director's version string — a release's standard name (`"0.4.0-alpha.1"`) or a + * non-release build's commit stamp (`"0.4.0-dev-"`, #513) — or `undefined` until + * the read lands, or for good, if it never does. Reading this the first time starts the fetch. */ export function directorVersion(): string | undefined { if (!asked) { diff --git a/frontend/contract/events.contract.ts b/frontend/contract/events.contract.ts index 45572a6b..d32e1ae5 100644 --- a/frontend/contract/events.contract.ts +++ b/frontend/contract/events.contract.ts @@ -1720,7 +1720,9 @@ describe('seam: /about (the build stamp)', () => { expect(res.ok).toBe(true); const about = await res.json(); expect(about.name).toBe('GridFPV'); - // x.y.z with an optional -prerelease tail (the v0.4.0-alpha.1 scheme). + // x.y.z with an optional -prerelease tail: a release's standard naming + // (0.4.0-alpha.1), or a non-release build's commit stamp (0.4.0-dev-, + // -dirty appended for an uncommitted tree) — #513. expect(about.version).toMatch(/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/); expect(typeof about.contract_version).toBe('number'); });