Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger-ui

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"]
        );
    }
}