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

nordisk/gunnar-ui

xtask/src/ci.rs

//! **gunnar-ui's CI plan** (`cargo xtask ci`, nornir-xtask-support's runner): its locks, its
//! tests, the wasm console checked for its target, and (full) its declared rows. A library
//! function, so `xtask/tests` can read the plan the runner runs.

use std::path::Path;

use nornir_xtask_support::ci::{Mode, Plan, Step};

/// **The quick set's environment** (lane CI-RED, 2026-10-31; Gunnar's 8c399ddf): no debug info,
/// no symbol table and no incremental cache. A failing test still prints its panic and
/// `file:line`; the full set keeps the default profile. Every cargo a verb starts inherits it.
pub const QUICK_ENV: &[(&str, &str)] = &[
    ("CARGO_INCREMENTAL", "0"),
    ("CARGO_PROFILE_DEV_DEBUG", "0"),
    ("CARGO_PROFILE_TEST_DEBUG", "0"),
    ("CARGO_PROFILE_DEV_STRIP", "symbols"),
    ("CARGO_PROFILE_TEST_STRIP", "symbols"),
];

/// The environment `mode` adds to every step: [`QUICK_ENV`] for the quick set, none else.
pub fn mode_env(mode: Mode) -> &'static [(&'static str, &'static str)] {
    match mode {
        Mode::Quick => QUICK_ENV,
        Mode::Full | Mode::Nightly => &[],
    }
}

/// `plan` for `mode`: every step with [`mode_env`] (`cargo xtask ci` applies it).
pub fn for_mode(mut plan: Plan, mode: Mode) -> Plan {
    let env = mode_env(mode);
    plan.steps = plan.steps.into_iter().map(|s| env.iter().fold(s, |s, (k, v)| s.env(k, v))).collect();
    plan
}

/// The off-by-default surfaces of gunnar-ui-server, compiled and tested together in the quick
/// set's ONE `features` step (each alone is a full-set step).
pub const FEATURE_UNION: &str = "metered,public-site,forges,mail,sso,mcp";

/// The plan `cargo xtask ci` runs.
pub fn plan(root: &Path) -> Plan {
    let root = root.to_path_buf();
    Plan {
        repo: "gunnar-ui".into(),
        root: root.clone(),
        steps: vec![
            nornir_xtask_support::lockcheck::step(&root),
            Step::cargo("test", &["test", "--workspace", "--locked"]),
            // CI-RED: the quick set's one feature resolution beyond the default: every
            // off-by-default surface at once; each alone below runs from the full set on.
            Step::cargo("features", &["test", "--locked", "-p", "gunnar-ui-server", "--features", FEATURE_UNION]),
            // gunnar.rs's public face is off in the default build, so `test` above
            // never compiles its mount; this step does (TASKS-12, `public-site`).
            Step::cargo("public-site", &["test", "--locked", "-p", "gunnar-ui-server", "--features", "public-site"]).from(Mode::Full),
            // gunnar.rs's own row is metered AND public-site: the home's offer
            // is compiled only there.
            Step::cargo("public-site-metered", &["test", "--locked", "-p", "gunnar-ui-server", "--features", "metered,public-site"]).from(Mode::Full),
            // Forge import is off in the default build (AIRGAP, owner 2026-10-25), so `test`
            // above compiles its stand-in only; this step compiles and drives the real one.
            Step::cargo("forges", &["test", "--locked", "-p", "gunnar-ui-server", "--features", "forges"]).from(Mode::Full),
            // Every front row ships metered (gunnar.rs) or open WITH forges, public-site beside.
            Step::cargo("forges-metered", &["test", "--locked", "-p", "gunnar-ui-server", "--features", "metered,public-site,forges,mail,sso"]).from(Mode::Full),
            // Company sign-in is off in the default build (A34-15), so `test` above compiles none
            // of it; this step drives its doors and the Identity provider page's API.
            Step::cargo("sso", &["test", "--locked", "-p", "gunnar-ui-server", "--features", "sso"]).from(Mode::Full),
            // The invite mail is off in the default build (AIRGAP); this step compiles the sender.
            Step::cargo("mail", &["test", "--locked", "-p", "gunnar-ui-server", "--features", "mail"]).from(Mode::Full),
            // The MCP connector is off in the default build, so `test` above never compiles it;
            // this step drives it and the administrator's switch in front of it (owner 2026-10-26).
            Step::cargo("mcp", &["test", "--locked", "-p", "gunnar-ui-server", "--features", "mcp"]).from(Mode::Full),
            Step::cargo("wasm", &["check", "--locked", "-p", "gunnar-ui-web", "--target", "wasm32-unknown-unknown"]),
            // The metered bundle (the paid pages). Both bundles carry the administrator's switches
            // since GUI-PUBSW, drawn where the console names them.
            Step::cargo("wasm-metered", &["check", "--locked", "-p", "gunnar-ui-web", "--target", "wasm32-unknown-unknown", "--features", "metered"]),
            // RA2-7: the A29-20 first touch in a real browser (`passkeys_at_gunnar::first_touch`),
            // in the browser image (`browser/Containerfile`: the toolchain image plus Chromium), with
            // the browser REQUIRED, so a missing Chromium is red here and never a green NOT RUN. A
            // host step: the verb enters its own image.
            Step::host("browser", "cargo", &["xtask", "browser"]).timeout(3600),
            Step::cargo("rows", &["xtask", "build"]).from(Mode::Full),
        ],
    }
}

/// **The cargo runs `cargo xtask browser` makes** (RA2-7), each in the browser image with the
/// browser required (`GUNNAR_UI_NEEDS_BROWSER=1`, so a missing Chromium is red, never a NOT RUN).
/// A `filter` runs that one lib test filter only.
pub fn browser_runs(filter: Option<&str>) -> Vec<Vec<String>> {
    let run = |args: &[&str]| -> Vec<String> {
        let mut a: Vec<String> = ["test", "--locked", "-p", "gunnar-ui-server", "--features", "metered"].iter().map(|s| s.to_string()).collect();
        a.extend(args.iter().map(|s| s.to_string()));
        a.extend(["--", "--test-threads", "1"].iter().map(|s| s.to_string()));
        a
    };
    match filter {
        Some(f) => vec![run(&["--lib", f])],
        // The A29-20 first touch, and Gunnar's own sign-up page back from paying (Stripe ONCE
        // per buy, `tests/signup_paid_return_browser.rs`).
        // GUN-LANDING: gunnar.rs's front page (`public-site`): Sign up | Log in, and it settles signed in.
        None => vec![
            run(&["--lib", "passkeys_at_gunnar::first_touch"]),
            run(&["--test", "signup_paid_return_browser"]),
            {
                let mut a = run(&["--lib", "anonymous::public_site::served::in_the_browser"]);
                a[5] = "metered,public-site".to_owned();
                a
            },
            // GUN-GUEST: a deep link opened in the console by a guest and a signed-in person,
            // with the console's built bundle (`GUNNAR_UI_BUNDLE`; NOT RUN without one).
            {
                let mut a = run(&["--lib", "anonymous::console_address::prerendered::in_the_browser"]);
                a[5] = "metered,public-site".to_owned();
                a
            },
            // GUN-SEO-GUEST: the indexed www address, opened by a guest, is the typeset README.
            {
                let mut a = run(&["--lib", "anonymous::seo_guest::in_the_browser"]);
                a[5] = "metered,public-site".to_owned();
                a
            },
        ],
    }
}

/// The browser image's tag for a `Containerfile`'s bytes: 64-bit FNV-1a, as 16 hex digits.
/// Stable across Rust versions (std's hasher is not), and xtask takes no hash crate.
pub fn recipe_tag(recipe: &[u8]) -> String {
    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
    for b in recipe {
        h ^= u64::from(*b);
        h = h.wrapping_mul(0x0000_0100_0000_01b3);
    }
    format!("{h:016x}")
}

/// **The browser image's tag** (GHUNT-17): the recipe's bytes ON its base, `base` being the base
/// image's id (`podman image inspect --format {{.Id}}`, or its reference when the engine knows
/// none). Keyed by the recipe alone, an image built on ci3 stayed in use after the base moved
/// to ci4.
pub fn image_tag(recipe: &[u8], base: &str) -> String {
    let mut keyed = recipe.to_vec();
    keyed.extend_from_slice(b"\0BASE ");
    keyed.extend_from_slice(base.as_bytes());
    recipe_tag(&keyed)
}