dwarves-gui/build.rs
//! Build script — convert the committed `assets/dwarves.webp` forge logo (two
//! dwarves at the rune-anvil forge, znippy-rabbit hammer) into a PNG in `OUT_DIR`,
//! so the About popup can embed it.
//!
//! Why convert: the shared `facett-about` widget decodes an [`Identity`]'s
//! `logo_png` with a **PNG-only** `image` build (its runtime decode carries no webp
//! feature). We keep the SOURCE asset as the artist's `.webp` (embed the webp), and
//! transcode it once at build time — pure Rust, zero shell (L9) — so the logo
//! actually renders in the card. The transcode is wasm-irrelevant (the About popup
//! is native-only), but a build script runs on the host toolchain regardless, so the
//! PNG is always available to `include_bytes!` from `OUT_DIR`.
use std::path::Path;
fn main() {
// The build stamp: `dwarves-ui --version` and the About box print
// `dwarves v<ver> (<git hash>)` so an operator can tell WHICH build is running.
emit_git_hash();
// The BUILD RECEIPT — the section `--attest` reads back out of the finished binary.
emit_build_receipt();
// ── THE LOGO LIVES INSIDE THIS PACKAGE, and it has to ────────────────────────
//
// Until 2026-08-24 this read `../assets/dwarves.webp` — the dwarves repo's shared
// `assets/` dir, one level up from this crate. Inside the checkout that is fine. In
// a PUBLISHED crate it does not exist: `cargo package` tarballs only files from
// under the package root, so `../assets/` resolves outside the extracted crate and
// the `panic!` below — correctly, loudly — made the published crate unbuildable for
// everyone who tried to install it. Same defect class as `dwarves-web-client`
// reading the sibling's proto, and the same fix: a package owns the bytes it needs
// at build time, because `../` is a path that only exists in a checkout.
//
// The repo-root `assets/dwarves.webp` stays where it is (`README.md` renders it), so
// this is a second copy of 90 KB kept honest by `tests/logo_asset_parity.rs`.
let webp = Path::new("assets/dwarves.webp");
println!("cargo:rerun-if-changed=assets/dwarves.webp");
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR set by cargo");
let dest = Path::new(&out_dir).join("dwarves_logo.png");
match image::open(webp) {
Ok(img) => {
img.save_with_format(&dest, image::ImageFormat::Png)
.expect("re-encode dwarves logo as PNG");
}
Err(e) => {
// Fail LOUD — the About logo is a shipped surface, not optional.
panic!(
"dwarves-gui build: could not decode {} as an image: {e}",
webp.display()
);
}
}
}
/// Emit `cargo:rustc-env=DWARVES_GIT_HASH=<short sha>` — the short git commit the binary
/// was built from — by reading the git metadata as **plain files only**: NO `git`, NO
/// `std::process::Command`, NO shell (the nordisk pure-Rust zero-shell law). Handles a
/// normal `.git` dir, a linked-worktree `.git` FILE (`gitdir:` → the worktree gitdir with
/// its own HEAD but a shared `commondir` for refs), loose refs, and `packed-refs`. Falls
/// back to `unknown` so `env!("DWARVES_GIT_HASH")` always resolves (e.g. a tarball build).
fn emit_git_hash() {
use std::path::{Path, PathBuf};
fn find_git_dir(start: &Path) -> Option<PathBuf> {
let mut cur = Some(start);
while let Some(d) = cur {
let g = d.join(".git");
if g.is_dir() {
return Some(g);
}
if g.is_file() {
// Linked worktree: `.git` is a file `gitdir: <path>`.
if let Ok(s) = std::fs::read_to_string(&g) {
if let Some(p) = s.lines().find_map(|l| l.strip_prefix("gitdir:")) {
return Some(PathBuf::from(p.trim()));
}
}
}
cur = d.parent();
}
None
}
fn resolve(git_dir: &Path) -> Option<String> {
let head_path = git_dir.join("HEAD");
let head = std::fs::read_to_string(&head_path).ok()?;
println!("cargo:rerun-if-changed={}", head_path.display());
let head = head.trim();
// Refs live in the COMMON git dir (for a worktree, `commondir` points there;
// otherwise it is the git dir itself).
let common = std::fs::read_to_string(git_dir.join("commondir"))
.ok()
.map(|s| git_dir.join(s.trim()))
.and_then(|p| std::fs::canonicalize(&p).ok())
.unwrap_or_else(|| git_dir.to_path_buf());
if let Some(reference) = head.strip_prefix("ref:") {
let reference = reference.trim();
let loose = common.join(reference);
if let Ok(s) = std::fs::read_to_string(&loose) {
println!("cargo:rerun-if-changed={}", loose.display());
return Some(s.trim().to_string());
}
// packed-refs fallback (the loose ref file may not exist).
let packed_path = common.join("packed-refs");
println!("cargo:rerun-if-changed={}", packed_path.display());
let packed = std::fs::read_to_string(&packed_path).ok()?;
return packed.lines().find_map(|l| {
let l = l.trim();
if l.starts_with('#') || l.starts_with('^') {
return None;
}
let (sha, name) = l.split_once(' ')?;
(name == reference).then(|| sha.to_string())
});
}
// Detached HEAD: the file already holds the sha.
Some(head.to_string())
}
let manifest = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR set by cargo");
let hash = find_git_dir(Path::new(&manifest))
.and_then(|g| resolve(&g))
.map(|sha| sha.chars().take(12).collect::<String>())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "unknown".to_string());
println!("cargo:rustc-env=DWARVES_GIT_HASH={hash}");
}
/// **Write this artifact's build receipt into `.nordisk.buildthing`.**
///
/// The UI half of the same fix `crates/dwarves/build.rs` carries: the finished binary
/// must CARRY its resolved feature set so `cargo xtask build --attest` can read it back
/// out with an object parser and audit every claim against the symbol table. Before
/// this, `dwarves-ui` built green with no section and `--attest` refused it.
///
/// The recipe hash is mandatory — `emit` refuses a receipt without one, because the
/// version says which recipe was INTENDED and only the hash says which one RAN. It is
/// the content hash of `.nornir/build-variants.toml`, the file that decides this atom's
/// package, bin, features and profile.
///
/// One `build.rs` serves both `dwarves-ui` and `dwarves-solo`, and cargo does not tell a
/// build script which bin it is for. They are separate cargo invocations with different
/// features, so the receipts genuinely differ; only the NAME is ambiguous, so
/// `NORDISK_BUILD_ATOM` is honoured when the builder exports it and the fallback names
/// the crate truthfully rather than guessing a bin.
fn emit_build_receipt() {
use nornir_build_thing::contract::Coordinate;
use nornir_build_thing::receipt::{emit, ReceiptBuilder};
let recipe_path =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.nornir/build-variants.toml");
// A PUBLISHED crate has no recipe — the SAME fix `crates/dwarves/build.rs` took in
// 6a29e0e + 6af3ec2, which was never propagated here. `.nornir/build-variants.toml`
// lives one level above this manifest in the nordisk checkout; from a registry
// tarball that path resolves outside the extracted crate and simply is not there.
// There is no atom, no build matrix and nothing to attest, so there is no receipt to
// emit either — and a build.rs that panics on its absence makes the published crate
// unbuildable for everyone. dwarves 0.1.5 failed `cargo publish --verify` on exactly
// this shape; dwarves-gui carried the identical defect while its `publish = true`
// flag was already on (4425148).
//
// ONLY `NotFound` is tolerated, and that distinction is the whole fix. A recipe that
// exists but cannot be read — bad permissions, a truncated file, an I/O error — still
// panics, because inside the workspace that is precisely the "receipt silently
// vanished" defect this mechanism was built to catch. Swallowing every error here
// would turn a loud, correct guard into one that can never report red.
let recipe = match std::fs::read(&recipe_path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
// `embed_build_receipt!()` is a bare `include!` (src/main.rs:34, shared by
// ALL THREE bins — dwarves-ui, dwarves-solo, dwarves-ui-gpu), so the file has
// to EXIST even when there is no receipt to put in it. Returning early
// without writing it is the second half of the same bug, and 6af3ec2 is the
// commit that had to come back and fix it: "the no-recipe branch must still
// WRITE the receipt file, not just skip". An empty file is the honest
// content — the receipt lives in a linker section, so no section means "this
// artifact carries no receipt", which is exactly true of a build with no
// recipe.
//
// The name comes from `receipt::GENERATED_FILE` rather than a string typed
// here, so this branch cannot drift from the writer it stands in for.
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is set in build.rs");
let path =
std::path::Path::new(&out_dir).join(nornir_build_thing::receipt::GENERATED_FILE);
std::fs::write(&path, "// no build recipe: no receipt for this artifact\n")
.unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
println!(
"cargo:warning=no build recipe at {} — building outside the nordisk \
workspace (published crate), so this artifact carries no build receipt",
recipe_path.display()
);
return;
}
Err(e) => panic!("read the recipe {}: {e}", recipe_path.display()),
};
println!("cargo:rerun-if-changed={}", recipe_path.display());
let build_thing = Coordinate {
krate: "dwarves-build-thing".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
hash: Some(nornir_hash::sha256_hex(&recipe)),
};
let atom = std::env::var("NORDISK_BUILD_ATOM")
.ok()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "dwarves-ui".to_string());
let target = std::env::var("TARGET").unwrap_or_default();
match ReceiptBuilder::from_cargo_env(build_thing, atom, target).and_then(|b| b.build()) {
Ok(receipt) => {
if let Err(e) = emit(&receipt) {
panic!("build receipt refused: {e:#}");
}
}
Err(e) => panic!("build receipt could not be assembled: {e:#}"),
}
}