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)
}