xtask/src/build.rs
//! `cargo xtask build` — **build what the recipe declares, and nothing else.**
//!
//! # Why this exists
//!
//! Until 2026-09-17 this repository's build knowledge lived in prose: `trunk
//! build --release` in `README.md` §Release build, again in `publish`, again in
//! `deploy/holger-console.container`'s comment. None of it could be read by any
//! tool, and nothing held the spellings to each other.
//!
//! The declaration is [`holger_ui_recipe`], and this module OWNS NO BUILD
//! KNOWLEDGE. It carries no package name, no feature string, no triple and no
//! list of rows. Every one of those is a field of a `BuildVariant` the recipe
//! declares — and now, so is the ENVIRONMENT: the image, its digest, its rustc,
//! its target set and its bounds are `[build.toolchain]` in the recipe, not
//! constants here.
//!
//! # The two legs, and which row takes which
//!
//! **cargo rows go in the declared container — and that leg now exists.**
//! `mvp-ui` is `holger-ui-server`, a `x86_64-unknown-linux-musl` row, and it is
//! compiled by [`cargo_container::build_variant_in_container`] over a
//! [`DraupnirCargoRunner`], inside the digest-pinned image the recipe's
//! `[build.toolchain]` names. There is deliberately NO host fallback. The law
//! (`workspace_nordisk/nornir-workspace.toml`) is one sentence — *the host
//! contributes a container engine socket and a workdir, NOTHING else, because
//! then the BOX decides the verdict instead of the DECLARATION* — and this row
//! is the case in point: the SAME `cargo build … --target
//! x86_64-unknown-linux-musl` was measured RED on oden (2026-08-25, `E0463`),
//! RED on loki (2026-09-17, no `x86_64-linux-musl-gcc`) and GREEN on t14s
//! (2026-09-20, 2 m 21 s, valid static-pie ELF — `variants::MUSL_WALL`). Three
//! boxes, three verdicts, one row. A green on one box is as much a symptom of
//! the defect as a red on another, so "it works here" is not a reason to take
//! the host road and this door does not offer it.
//!
//! **This module used to REFUSE that row**, naming the function and the table
//! it lacked, because the alternative on offer was `Command::new("cargo")` on
//! whatever box typed the command. The refusal was right for as long as the leg
//! was unwritten; what closes it is writing the leg, never weakening it.
//!
//! **wasm rows go through trunk, on the host — the one NAMED EXCEPTION.** A
//! `kind = "wasm"` row is NOT just its cargo build: `trunk build --release` runs
//! that cargo build and then wasm-bindgen, `wasm-opt` and the `index.html` asset
//! pipeline, and it writes content-hashed names (`holger-ui-web-<hash>_bg.wasm`,
//! which `publish` globs for by that suffix and whose bootstrap it hashes into
//! the CSP). Handing a wasm row to a plain cargo loop — in a container or not —
//! would produce `holger_ui_web.wasm`, a file no browser loads and no door
//! ships, and it would report success.
//!
//! trunk is this repository's own named exception to "no shelling out", written
//! down in `main.rs`'s module doc when the bash deploy scripts became Rust:
//! *"trunk has no library API; it is the build tool this repo already requires."*
//! gunnar-ui drives its wasm rows the same way, for the same reason.
//!
//! ⚠ **AND IT IS AN EXCEPTION, NOT A SECOND ROAD.** The pinned toolchain image
//! carries `wasm32-unknown-unknown` std but NOT trunk, wasm-bindgen-cli or
//! `wasm-opt`, so `mvp-ui-web` cannot enter it today. The honest statement of
//! where that row stands is therefore two labelled halves, and they must not be
//! collapsed into one:
//!
//! * MEASURED, t14s 2026-09-20: the host has `trunk`, `wasm-bindgen` and the
//! `wasm32-unknown-unknown` target, and a host `trunk build --release` took
//! ~1 m 20 s and produced a 3 923 434-byte wasm. That is a measurement OF THE
//! BOX.
//! * OPEN, and owed by the shared vocabulary rather than by this repository: a
//! `TrunkStage` beside `nornir_build_thing`'s `BindgenStage`, and the three
//! tools in the image, so this row gets the same container the cargo row got.
//! Until then `mvp-ui-web` builds on the host and this door says so out loud
//! rather than letting a green imply a hermetic build.
//!
//! [`cargo_container::build_variant_in_container`]: holger_ui_recipe::nornir_build_thing::cargo_container::build_variant_in_container
//! [`DraupnirCargoRunner`]: holger_ui_recipe::nornir_build_thing::cargo_container::DraupnirCargoRunner
use std::path::{Path, PathBuf};
use std::process::Command;
use holger_ui_recipe::nornir_build_thing as nbt;
use nbt::cargo_container as cc;
use nbt::suite_cli;
use nbt::variant::{BuildVariant, BuildVariantSet};
/// `cargo xtask build [--variant <name>] [--suite <name>] [--list]`.
///
/// The flags are the constellation's shared ones: `suite_cli::parse` is the
/// same parser dwarves, nornir, holger, korp, gunnar-ui and facett's xtasks use,
/// so a person who has typed `cargo xtask build --variant …` in any of them has
/// typed it here.
pub fn run(root: &Path, argv: &[String]) -> Result<Vec<String>, String> {
// **The committed description must be the code's emission before anything
// builds.** Not because this door reads it — it does not, it asks the code
// — but because everything else that discovers this repository does read
// it, and a build that runs while the file next to it says something else
// is how the two drift apart in the first place.
holger_ui_recipe::require_committed_is_emitted(root).map_err(|e| format!("{e:#}"))?;
let args = suite_cli::parse(argv).map_err(|e| format!("{e:#}"))?;
let set = holger_ui_recipe::declared();
let (_suite, chosen) = suite_cli::resolve(&set, &args).map_err(|e| format!("{e:#}"))?;
if args.list {
for v in &chosen {
println!("{:<26} {}", v.name, describe(v));
}
return Ok(chosen.iter().map(|v| v.name.clone()).collect());
}
// The container road is entered ONCE per invocation and only when a cargo
// row was actually chosen: minting/answering the image costs seconds, and a
// `--variant mvp-ui-web` run has no business paying them.
let mut road: Option<CargoRoad> = None;
let mut built = Vec::new();
for v in &chosen {
eprintln!("\n=== {} ===\n {}", v.name, describe(v));
if is_browser_bundle(v) {
trunk(root, v)?;
} else {
let road = match road {
Some(ref r) => r,
None => road.insert(cargo_road(root, &set)?),
};
container(road, &set, v, root)?;
}
built.push(v.name.clone());
}
Ok(built)
}
/// A row whose target is wasm32 is built by trunk, not by cargo alone.
fn is_browser_bundle(v: &BuildVariant) -> bool {
v.target.as_deref().is_some_and(|t| t.starts_with("wasm32"))
}
/// What this row builds, in one line — the row's own answer, never a guess.
fn describe(v: &BuildVariant) -> String {
if is_browser_bundle(v) {
format!("trunk {} (host — the named exception)", trunk_args(v).join(" "))
} else {
format!("{} (in the declared container)", v.command_line())
}
}
// ── the cargo leg: the declared container, and no other road ─────────────────
/// **The build environment, resolved ONCE per invocation.** Every field is
/// either the recipe's (`tc`) or cargo's own ladder (`target_dir`, `registry`,
/// via `HostPaths::from_env`). This xtask contributes none of it.
struct CargoRoad {
tc: cc::CargoToolchain,
runner: cc::DraupnirCargoRunner,
target_dir: PathBuf,
registry: Option<PathBuf>,
}
/// **Enter the image the recipe declares — and refuse rather than fall back.**
///
/// Step 1 is the image, minted and never assumed. `image_sha256` is a manifest
/// digest and nothing pulls it: `nordisk-cargo-toolchain` has no registry behind
/// it, so every box mints it from the Containerfile embedded in
/// `nornir-build-thing` (the repo never supplies its own build environment),
/// reproducibly, so the digest a fresh box lands is the digest the declaration
/// pins and a re-mint is answered from podman's layer cache.
fn cargo_road(root: &Path, set: &BuildVariantSet) -> Result<CargoRoad, String> {
let Some(tc) = cc::toolchain_with_override(set.toolchain.clone())
.map_err(|e| format!("{e:#}"))?
else {
return Err(format!(
"holger-ui declares no `[build.toolchain]`, so there is no image to build in, and \
`cargo xtask build` does NOT fall back to host cargo: a build whose verdict \
depends on which box typed it is not a build. Declare the table in \
`crates/holger-ui-recipe/src/lib.rs` (`toolchain()`) and re-emit \
`.nornir/build-variants.toml`, or set {} to a digest-pinned reference for a \
one-off run.",
cc::TOOLCHAIN_IMAGE_OVERRIDE_ENV
));
};
tc.validate().map_err(|e| format!("[build.toolchain]: {e:#}"))?;
let paths = cc::HostPaths::from_env(root);
std::fs::create_dir_all(&paths.target_dir)
.map_err(|e| format!("create {}: {e}", paths.target_dir.display()))?;
let runner = cc::DraupnirCargoRunner::new();
let ctx = paths.target_dir.join("nordisk-cargo-toolchain-context");
let t0 = std::time::Instant::now();
// The pin, present or minted under the recipe's own tag; never `:live` (a `:live` re-mint
// un-names whatever image the tag held, and a pin on it then goes to a registry).
let built = cc::ensure_declared_toolchain(&runner, &ctx, &tc)
.map(|m| match m {
cc::ToolchainMint::AlreadyPresent => tc.image_ref(),
cc::ToolchainMint::Minted { tag } => tag,
})
.map_err(|e| {
format!(
"mint the declared toolchain image: {e:#}\n This is step 1 and it needs a \
container engine socket — the ONE thing the host is asked for. The \
declaration pins {}; nothing pulls it, every box mints it.",
tc.image_ref()
)
})?;
eprintln!(
"toolchain image {built} ready in {:.1} s → building in {} (rust {}, {} MiB, {} cpus, \
network {}, siblings: {})",
t0.elapsed().as_secs_f64(),
tc.image_ref(),
tc.rust_version,
tc.mem_limit_mb,
tc.cpus,
if tc.network { "on" } else { "none" },
if tc.siblings.is_empty() {
"none".to_string()
} else {
tc.siblings.join(", ")
}
);
Ok(CargoRoad {
tc,
runner,
target_dir: paths.target_dir.clone(),
registry: paths.registry_bind().map(Path::to_path_buf),
})
}
/// Build ONE cargo row in the declared container through the SAME loop the
/// dwarves forge, the kernel kind and korp-installer's compose already drive —
/// same `--message-format=json` parse, same content-hashing, and provenance
/// recording the CONTAINER's `rustc --version` rather than the host's.
fn container(
road: &CargoRoad,
set: &BuildVariantSet,
v: &BuildVariant,
root: &Path,
) -> Result<(), String> {
let t0 = std::time::Instant::now();
let out = cc::build_variant_in_container(
set,
&v.name,
root,
&road.target_dir,
road.registry.as_deref(),
&road.tc,
&road.runner,
)
.map_err(|e| format!("variant '{}': {e:#}", v.name))?;
eprintln!(
" {} in {:.1} s",
if out.success { "built" } else { "FAILED" },
t0.elapsed().as_secs_f64()
);
eprintln!(" digest : {}", out.attestation.digest_hex());
// The provenance record's OWN hashes — what is printed is what was
// attested, not a second hashing pass here.
for a in &out.attestation.record.artifacts {
eprintln!(" artifact : {} ({} bytes) sha256:{}", a.path, a.bytes, a.sha256);
}
if !out.success {
return Err(format!(
"variant '{}': the build in {} failed",
v.name,
road.tc.image_ref()
));
}
Ok(())
}
// ── the wasm leg: trunk, on the host, by named exception ─────────────────────
/// The trunk argv for a wasm row, materialized from the ROW and its web app.
/// `--release` because the row's profile says so; `--features` only when the
/// row decided one, and `--dist` and the page only when the row's
/// `[[build.web_app]]` names another than `Trunk.toml`'s (the metered bundle,
/// A28 M13), so the open bundle's command line stays the one `README.md`
/// documents.
fn trunk_args(v: &BuildVariant) -> Vec<String> {
let mut a = vec!["build".to_string()];
if v.profile == nbt::variant::Profile::Release {
a.push("--release".to_string());
}
if !v.features.is_empty() {
a.push("--features".to_string());
a.push(v.features.join(","));
}
let set = holger_ui_recipe::declared();
if let Some(app) = set.web_apps.iter().find(|w| w.name == v.name) {
if app.out_dir != holger_ui_recipe::variants::DIST {
a.push("--dist".to_string());
a.push(app.out_dir.clone());
}
if app.index != holger_ui_recipe::variants::INDEX {
a.push(app.index.clone());
}
}
a
}
/// The directory holding the row's `Trunk.toml` — where trunk must run, because
/// that file declares the dist directory and the proxies and trunk reads it
/// relative to the working directory.
///
/// holger-ui's console package IS the workspace root package (`Trunk.toml`,
/// `index.html` and `src/` all sit at the top), unlike gunnar-ui where it is
/// `crates/gunnar-ui-web`. Both spellings are checked rather than assumed, so
/// this function keeps working on the day the console moves under `crates/` —
/// which is what the web/server/twin split would do.
fn bundle_dir(root: &Path, package: &str) -> Option<PathBuf> {
[root.to_path_buf(), root.join("crates").join(package)]
.into_iter()
.find(|d| d.join("Trunk.toml").is_file())
}
fn trunk(root: &Path, v: &BuildVariant) -> Result<(), String> {
let package = v
.package
.as_deref()
.ok_or_else(|| format!("variant '{}': a wasm row with no package", v.name))?;
let dir = bundle_dir(root, package).ok_or_else(|| {
format!(
"variant '{}': no Trunk.toml at {} or {}/crates/{package} — the bundle's dist \
directory and its proxies live there, and running trunk without them builds \
something no door in this repository serves",
v.name,
root.display(),
root.display()
)
})?;
// Said out loud on every run: this row is NOT hermetic, and a green here is
// a statement about this box. See the module doc's two labelled halves.
eprintln!(
" ⚠ host build (named exception): the pinned image carries wasm32 std but not trunk, \
wasm-bindgen-cli or wasm-opt, so this row's verdict is this box's until a TrunkStage \
and those three tools exist."
);
let status = Command::new("trunk")
.current_dir(&dir)
.args(trunk_args(v))
.status()
.map_err(|e| {
format!(
"variant '{}': running trunk in {}: {e}. The console is a WebAssembly \
bundle and trunk is how it is built (`cargo install trunk --locked \
--version 0.21.14`).",
v.name,
dir.display()
)
})?;
if !status.success() {
return Err(format!("variant '{}': trunk exited {status}", v.name));
}
Ok(())
}
// ── the CI gate of the rows: host-only, and said so ──────────────────────────
/// The image reference the cargo rows build in: the recipe's `[build.toolchain]`
/// pin (`<image>@sha256:<digest>`), with [`cc::TOOLCHAIN_IMAGE_OVERRIDE_ENV`]
/// applied when it is set.
pub fn pinned_image_ref() -> Result<String, String> {
let set = holger_ui_recipe::declared();
match cc::toolchain_with_override(set.toolchain.clone()).map_err(|e| format!("{e:#}"))? {
Some(tc) => Ok(tc.image_ref()),
None => Err("holger-ui declares no `[build.toolchain]`".into()),
}
}
/// Why `cargo xtask ci`'s rows step cannot run on this box, or `None` when it can.
/// `present` is the engine's answer for `image_ref`: `Ok(true)` it holds the image,
/// `Ok(false)` it does not, `Err` the engine could not be asked.
///
/// The pinned image is published in no registry: `nordisk-cargo-toolchain` is minted
/// per box from the Containerfile in `nornir-build-thing`, and builder-64's mint lands
/// another digest than the pin. So a box that lacks it cannot pull or load it by digest,
/// and the step is host-only: it SKIPS, naming the digest and the reason, and never
/// passes in silence.
pub fn rows_skip_reason(image_ref: &str, present: Result<bool, String>) -> Option<String> {
let why = match present {
Ok(true) => return None,
Ok(false) => "this box does not hold that image".to_string(),
Err(e) => format!("this box could not ask a container engine for it ({e})"),
};
Some(format!(
"SKIPPED (host-only): the MVP rows build in the pinned toolchain image {image_ref}, and {why}. \
It is published in no registry (nordisk-cargo-toolchain is minted per box, and a mint \
on another box lands another digest), so it cannot be pulled or loaded by digest. Run \
`cargo xtask ci` on a host that holds it, or set {}=<image>@sha256:<digest> to an image \
this box holds.",
cc::TOOLCHAIN_IMAGE_OVERRIDE_ENV
))
}
#[cfg(test)]
mod rows_gate_tests {
use super::*;
const PIN: &str = "localhost/nordisk-cargo-toolchain@sha256:668468e775ad80f32d700cbc9709d41cac726f40d00649461a0932f30d1b2067";
#[test]
fn the_rows_build_in_an_image_pinned_by_digest() {
if std::env::var(cc::TOOLCHAIN_IMAGE_OVERRIDE_ENV).is_ok() {
return;
}
let r = pinned_image_ref().expect("the recipe declares a toolchain");
let (_, digest) = r.split_once("@sha256:").expect("a digest, not a tag");
assert_eq!(digest.len(), 64, "{r}");
assert!(digest.bytes().all(|b| b.is_ascii_hexdigit()), "{r}");
}
#[test]
fn a_box_without_the_pinned_image_skips_out_loud_naming_the_digest() {
let why = rows_skip_reason(PIN, Ok(false)).expect("absent image must skip, never pass");
assert!(why.starts_with("SKIPPED"), "{why}");
assert!(why.contains("host-only"), "{why}");
assert!(why.contains(PIN), "the reason names the digest: {why}");
assert!(why.contains("no registry"), "the reason says why it cannot be pulled: {why}");
assert!(why.contains(cc::TOOLCHAIN_IMAGE_OVERRIDE_ENV), "the reason names the override: {why}");
}
#[test]
fn a_box_with_no_engine_skips_out_loud_too() {
let why = rows_skip_reason(PIN, Err("could not start podman: No such file or directory".into()))
.expect("no engine must skip, never pass");
assert!(why.starts_with("SKIPPED"), "{why}");
assert!(why.contains(PIN), "{why}");
assert!(why.contains("could not start podman"), "{why}");
}
#[test]
fn a_box_that_holds_the_pinned_image_runs_the_rows() {
assert_eq!(rows_skip_reason(PIN, Ok(true)), None);
}
}
#[cfg(test)]
mod trunk_args_tests {
use super::*;
/// A28 M13: the open bundle is the README's `trunk build --release`; the metered one is
/// built with its feature, into its own dist, from its own page.
#[test]
fn each_bundle_row_is_its_own_trunk_build() {
let set = holger_ui_recipe::declared();
assert_eq!(trunk_args(set.get("mvp-ui-web").unwrap()), ["build", "--release"]);
assert_eq!(
trunk_args(set.get("mvp-ui-web-metered").unwrap()),
["build", "--release", "--features", "metered", "--dist", "dist-metered", "index-metered.html"]
);
}
}