From 407e9699f45c3c3b86a639fb2ebab7fde43ce38c Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Thu, 20 Aug 2026 16:52:48 +0800 Subject: [PATCH 01/21] chore: give every workspace crate one version `cargo publish --workspace` reads each crate's version from its own manifest, so four manifests carrying their own literal was four chances to publish a release under a version the tag did not name. Inherit from the workspace instead. The readers that parsed a crate manifest for a version now read the workspace one. --- Cargo.lock | 1 + crates/hiroz-protocol/Cargo.toml | 2 +- crates/hiroz-union/Cargo.toml | 10 ++++++++-- crates/hiroz/Cargo.toml | 2 +- crates/rmw-zenoh-rs/Cargo.toml | 2 +- 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a8c811d25..993717257 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1974,6 +1974,7 @@ dependencies = [ "serde_json", "serde_yaml", "serial_test", + "sha2", "tokio", "tracing", "tracing-subscriber", diff --git a/crates/hiroz-protocol/Cargo.toml b/crates/hiroz-protocol/Cargo.toml index 20ebe5ae2..59655a169 100644 --- a/crates/hiroz-protocol/Cargo.toml +++ b/crates/hiroz-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hiroz-protocol" -version = "0.1.0" +version.workspace = true edition = "2021" rust-version = "1.75" authors = ["ZettaScale Technology "] diff --git a/crates/hiroz-union/Cargo.toml b/crates/hiroz-union/Cargo.toml index b5169e6ea..082dcdb5a 100644 --- a/crates/hiroz-union/Cargo.toml +++ b/crates/hiroz-union/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hiroz-union" -version = "0.1.0" +version.workspace = true edition = "2024" description = "hu — plugin platform and TUI for the hiroz ROS 2 ecosystem" homepage = "https://github.com/ZettaScaleLabs/hiroz" @@ -28,6 +28,7 @@ tracing = { workspace = true } tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] } zenoh = { workspace = true } chrono = { version = "0.4", features = ["serde"] } +sha2 = "0.10" anyhow = { workspace = true } wasmtime = { version = "38", features = [ "component-model", @@ -47,4 +48,9 @@ default = ["wasm-plugins"] wasm-plugins = ["dep:wasmtime", "dep:wasmtime-wasi"] web-plugins = ["wasm-plugins", "dep:axum"] ros-interop = [] -humble = [] +# No `humble` feature. It existed here as `humble = []` -- empty, forwarding +# nothing to hiroz, referenced by no code and no build. `--features humble` +# therefore selected hiroz's default jazzy while reading as a distro switch. +# Selecting Humble needs `default-features = false` on the hiroz dependency, +# which changes this crate's build graph; do that deliberately, not by +# reviving a no-op flag. diff --git a/crates/hiroz/Cargo.toml b/crates/hiroz/Cargo.toml index b11b6ec20..436801c8f 100644 --- a/crates/hiroz/Cargo.toml +++ b/crates/hiroz/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hiroz" -version = "0.1.0" +version.workspace = true edition = "2024" description = "Native Rust ROS 2 implementation using Zenoh" license.workspace = true diff --git a/crates/rmw-zenoh-rs/Cargo.toml b/crates/rmw-zenoh-rs/Cargo.toml index 82376e06a..8f451d2c5 100644 --- a/crates/rmw-zenoh-rs/Cargo.toml +++ b/crates/rmw-zenoh-rs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rmw-zenoh-rs" -version = "0.1.0" +version.workspace = true edition = "2024" description = "ROS 2 RMW (ROS Middleware) implementation using Zenoh - Requires ROS 2 Iron or later" publish = false From ee80a56b5527614b8af4ec1e0136c1a56dd2709e Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Thu, 20 Aug 2026 16:53:01 +0800 Subject: [PATCH 02/21] feat(hu): install and uninstall WASM plugins A downloaded `hu` had no way to acquire a plugin: discovery reads a directory, and nothing put anything in it. `hu plugin install` takes a local path, a URL, or a name resolved against a release index, and `hu plugin uninstall` reverses it. Every install validates the component before it lands, so a file the host cannot load is refused rather than left to fail at dispatch. A checksum mismatch and a WIT world mismatch are both refusals, and the destination filename is sanitised, so a name taken from a URL cannot escape the plugin directory. The subcommand a plugin provides comes from its filename, so the installer strips the version a release asset carries. Without that, installing the published `hu_meter-0.1.0.wasm` created `hu meter-0_1_0` and left the documented `hu meter` nonexistent. --- crates/hiroz-union/src/main.rs | 131 ++++++- crates/hiroz-union/src/plugin/install.rs | 406 +++++++++++++++++++++ crates/hiroz-union/src/plugin/mod.rs | 2 + crates/hiroz-union/src/plugin/wasm/mod.rs | 102 +++++- crates/hiroz-union/tests/plugin_install.rs | 339 +++++++++++++++++ crates/hiroz-union/wit/v0.1/hu-plugin.wit | 3 +- 6 files changed, 975 insertions(+), 8 deletions(-) create mode 100644 crates/hiroz-union/src/plugin/install.rs create mode 100644 crates/hiroz-union/tests/plugin_install.rs diff --git a/crates/hiroz-union/src/main.rs b/crates/hiroz-union/src/main.rs index e89f846ac..068e07c57 100644 --- a/crates/hiroz-union/src/main.rs +++ b/crates/hiroz-union/src/main.rs @@ -39,6 +39,9 @@ impl From for core::engine::Backend { #[derive(Parser)] #[command( name = "hu", + // Releases ship versioned artifacts and the install docs tell users to run + // `hu --version` to check what they got, so the binary has to answer. + version, about = "Plugin platform and TUI for the hiroz ROS 2 ecosystem", disable_help_subcommand = true )] @@ -117,6 +120,20 @@ enum PluginAction { /// Path to the .wasm plugin file path: String, }, + /// Install a plugin from a local file, a URL, or a name in the registry + Install { + /// Path to a .wasm file, a URL, or a plugin name (e.g. `meter`) + source: String, + + /// Plugin index URL to resolve a name against (also HU_PLUGIN_REGISTRY) + #[arg(long, value_name = "URL")] + registry: Option, + }, + /// Remove an installed plugin + Uninstall { + /// Plugin name as shown by `hu plugin list` (e.g. `meter`) + name: String, + }, } #[tokio::main] @@ -140,6 +157,12 @@ async fn main() -> Result<(), Box> { Some(Commands::Plugin { action: PluginAction::Validate { path }, }) => return run_plugin_validate(path, cli.json), + Some(Commands::Plugin { + action: PluginAction::Install { source, registry }, + }) => return run_plugin_install(source, registry.as_deref(), cli.json), + Some(Commands::Plugin { + action: PluginAction::Uninstall { name }, + }) => return run_plugin_uninstall(name, cli.json), Some(Commands::Router { listen, config }) => { return run_router(listen.clone(), config.clone()).await; } @@ -318,14 +341,23 @@ fn run_plugin_list(json: bool) -> Result<(), Box = plugins .iter() .map(|(name, path)| { + let m = meta(name); serde_json::json!({ "name": name, "path": path.to_string_lossy(), "kind": "wasm", + "version": m.map(|m| m.version.clone()), + "source": m.map(|m| m.source.clone()).unwrap_or_else(|| "unmanaged".into()), }) }) .collect(); @@ -333,18 +365,111 @@ fn run_plugin_list(json: bool) -> Result<(), Box"); return Ok(()); } - println!("{:<20} PATH", "PLUGIN"); - println!("{}", "-".repeat(60)); + println!("{:<16} {:<10} {:<10} PATH", "PLUGIN", "VERSION", "SOURCE"); + println!("{}", "-".repeat(78)); for (name, path) in &plugins { - println!("{:<20} {}", name, path.to_string_lossy()); + let m = meta(name); + println!( + "{:<16} {:<10} {:<10} {}", + name, + m.map(|m| m.version.as_str()).unwrap_or("-"), + m.map(|m| source_label(&m.source)).unwrap_or("unmanaged"), + path.to_string_lossy() + ); } } Ok(()) } } +#[cfg(feature = "wasm-plugins")] +fn source_label(source: &str) -> &'static str { + if source.starts_with("http://") || source.starts_with("https://") { + "download" + } else if source == "local" { + "local" + } else { + "installed" + } +} + +fn run_plugin_install( + source: &str, + registry: Option<&str>, + json: bool, +) -> Result<(), Box> { + #[cfg(not(feature = "wasm-plugins"))] + { + let _ = (source, registry, json); + eprintln!("WASM plugin support not compiled in."); + std::process::exit(1); + } + #[cfg(feature = "wasm-plugins")] + { + match plugin::install::install(source, registry) { + Ok(path) => { + if json { + println!( + "{}", + serde_json::json!({"status": "installed", "path": path.to_string_lossy()}) + ); + } else { + println!("installed {}", path.display()); + println!("verify with: hu plugin list"); + } + Ok(()) + } + Err(e) => { + if json { + println!("{}", serde_json::json!({"error": e.to_string()})); + } else { + eprintln!("error: {e}"); + } + std::process::exit(1); + } + } + } +} + +fn run_plugin_uninstall( + name: &str, + json: bool, +) -> Result<(), Box> { + #[cfg(not(feature = "wasm-plugins"))] + { + let _ = (name, json); + eprintln!("WASM plugin support not compiled in."); + std::process::exit(1); + } + #[cfg(feature = "wasm-plugins")] + { + match plugin::install::uninstall(name) { + Ok(path) => { + if json { + println!( + "{}", + serde_json::json!({"status": "removed", "path": path.to_string_lossy()}) + ); + } else { + println!("removed {}", path.display()); + } + Ok(()) + } + Err(e) => { + if json { + println!("{}", serde_json::json!({"error": e.to_string()})); + } else { + eprintln!("error: {e}"); + } + std::process::exit(1); + } + } + } +} + fn run_plugin_validate( path: &str, json: bool, diff --git a/crates/hiroz-union/src/plugin/install.rs b/crates/hiroz-union/src/plugin/install.rs new file mode 100644 index 000000000..15240aedb --- /dev/null +++ b/crates/hiroz-union/src/plugin/install.rs @@ -0,0 +1,406 @@ +//! Installing and removing WASM plugins. +//! +//! `hu meter` and `hu monitor` are not built into the binary — they are +//! `.wasm` components discovered on the plugin path. A user who downloads +//! `hu` therefore has no plugins at all until something puts them there. +//! This module is that something. +//! +//! Three sources are accepted, in decreasing order of how much we can check: +//! +//! - a **local path**, validated as a component before it is accepted; +//! - a **URL**, downloaded and, if a `.sha256` sits alongside it, verified; +//! - a **name** resolved through a release index, which carries a checksum +//! and the WIT world the plugin was built against. +//! +//! Note on trust: the checksums here protect against corruption and accidental +//! substitution. They are *not* authenticity — nothing is signed, and the +//! plugin permission model is self-declared by the plugin (see the WIT source). +//! Installing a plugin means trusting whoever wrote it. + +use anyhow::{Context, Result, anyhow, bail}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; + +use super::wasm::{plugin_search_dirs, sanitize_plugin_stem, validate_plugin_static}; + +/// WIT world this build of `hu` hosts. A plugin built against a different +/// world will not instantiate, so we refuse it up front with a readable +/// message instead of letting wasmtime fail later with a link error. +pub const HOST_WIT_WORLD: &str = "hu:plugin@0.1.0"; + +const DEFAULT_REGISTRY_ENV: &str = "HU_PLUGIN_REGISTRY"; + +#[derive(Debug, Deserialize)] +struct RegistryIndex { + #[allow(dead_code)] + schema: u32, + wit_world: String, + plugins: Vec, +} + +#[derive(Debug, Deserialize)] +struct RegistryEntry { + name: String, + file: String, + version: String, + sha256: String, +} + +/// Record of what was installed, so `hu plugin list` can tell a released +/// plugin from one a developer dropped in by hand. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct InstalledDb { + #[serde(default)] + pub plugins: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstalledEntry { + pub name: String, + pub file: String, + pub version: String, + pub source: String, +} + +/// The directory installs write to: always the last search dir, which is the +/// per-user one. `$HU_PLUGIN_PATH` entries are deliberately not written to — +/// those point at build trees during development and are not ours to manage. +pub fn install_dir() -> Result { + let home = dirs::home_dir().ok_or_else(|| anyhow!("cannot determine home directory"))?; + Ok(home.join(".local/share/hu/plugins")) +} + +fn db_path() -> Result { + Ok(install_dir()?.join("installed.json")) +} + +pub fn load_db() -> InstalledDb { + db_path() + .ok() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() +} + +fn save_db(db: &InstalledDb) -> Result<()> { + let path = db_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, serde_json::to_string_pretty(db)?) + .with_context(|| format!("writing {}", path.display())) +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut h = Sha256::new(); + h.update(bytes); + h.finalize().iter().map(|b| format!("{b:02x}")).collect() +} + +/// Drop a trailing `-` from a plugin filename stem. +/// +/// Only a suffix made purely of digits and dots counts, so `hu_meter-0.1.0` +/// loses its version while a plugin genuinely named `hu_my-tool` keeps its +/// name. Conservative on purpose: mangling a legitimate name would silently +/// rename someone's subcommand. +fn strip_version_suffix(stem: &str) -> &str { + match stem.rsplit_once('-') { + Some((head, tail)) + if !head.is_empty() + && !tail.is_empty() + && tail.chars().all(|c| c.is_ascii_digit() || c == '.') + && tail.chars().any(|c| c.is_ascii_digit()) => + { + head + } + _ => stem, + } +} + +fn is_url(s: &str) -> bool { + s.starts_with("http://") || s.starts_with("https://") +} + +/// Download over `curl`. `hu` deliberately carries no HTTP client — pulling in +/// a TLS stack for an occasional convenience command is a poor trade, and +/// `curl` is present anywhere a user could have downloaded `hu` in the first +/// place. +fn http_get(url: &str) -> Result> { + let mut cmd = std::process::Command::new("curl"); + // `--fail` matters: without it an HTTP error page is written to stdout and + // we would cheerfully install a 404 as a plugin. + cmd.args(["-fsSL", "--", url]); + if let Ok(token) = std::env::var("HU_RELEASE_TOKEN") { + cmd.arg("-H").arg(format!("Authorization: token {token}")); + } + let out = cmd + .output() + .with_context(|| "running curl (is it installed?)")?; + if !out.status.success() { + bail!( + "download failed for {url}: {}", + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(out.stdout) +} + +/// Accept a `.wasm` payload: check the world, check it compiles as a +/// component, then place it. Returns the installed path. +fn accept( + bytes: &[u8], + file_name: &str, + expected_sha: Option<&str>, + source: &str, + version: &str, +) -> Result { + if let Some(want) = expected_sha { + let got = sha256_hex(bytes); + if !want.eq_ignore_ascii_case(&got) { + bail!( + "checksum mismatch for {file_name}\n expected {want}\n got {got}\n\ + The download is corrupt or has been altered. Nothing was installed." + ); + } + } + + // The file name comes from a URL or an index we did not write, so it is + // attacker-influenced. Reuse the same sanitizer the plugin work dirs use: + // it collapses `..` and separators, so the result is one safe segment and + // cannot escape the install dir. + // + // Strip the version first. Release assets are named `hu_meter-0.1.0.wasm`, + // and discovery derives the subcommand from the filename — so keeping the + // suffix would install `hu meter-0_1_0` instead of `hu meter`, i.e. the + // documented command would not exist. Must happen before sanitizing, which + // turns the dots into underscores and makes the suffix unrecognizable. + let stem = Path::new(file_name) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); + let safe = sanitize_plugin_stem(strip_version_suffix(stem)); + + let dir = install_dir()?; + std::fs::create_dir_all(&dir)?; + + // Validate before it lands on the plugin path, using a temp file — a + // component that will not compile must never be discoverable, not even + // briefly. + let tmp = dir.join(format!(".{safe}.wasm.partial")); + std::fs::write(&tmp, bytes).with_context(|| format!("writing {}", tmp.display()))?; + let validation = validate_plugin_static(&tmp); + if let Err(e) = validation { + let _ = std::fs::remove_file(&tmp); + bail!("{file_name} is not a loadable WASM component: {e}"); + } + + let dest = dir.join(format!("{safe}.wasm")); + std::fs::rename(&tmp, &dest).with_context(|| format!("installing {}", dest.display()))?; + + let display_name = safe + .strip_prefix("hu_") + .or_else(|| safe.strip_prefix("hu-")) + .unwrap_or(&safe) + .to_string(); + + let mut db = load_db(); + db.plugins.retain(|p| p.name != display_name); + db.plugins.push(InstalledEntry { + name: display_name, + file: format!("{safe}.wasm"), + version: version.to_string(), + source: source.to_string(), + }); + save_db(&db)?; + + Ok(dest) +} + +/// Install from a local path, a URL, or a name resolved through the registry. +pub fn install(source: &str, registry: Option<&str>) -> Result { + let local = Path::new(source); + if local.exists() { + let bytes = std::fs::read(local).with_context(|| format!("reading {source}"))?; + let name = local + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("plugin.wasm"); + // A sibling `.sha256` is honoured when present; its absence is not a + // failure for a local file the user already has in hand. + let sidecar = local.with_extension("wasm.sha256"); + let expected = std::fs::read_to_string(&sidecar) + .ok() + .and_then(|s| s.split_whitespace().next().map(str::to_string)); + return accept(&bytes, name, expected.as_deref(), source, "local"); + } + + if is_url(source) { + let bytes = http_get(source)?; + let name = source.rsplit('/').next().unwrap_or("plugin.wasm"); + let expected = http_get(&format!("{source}.sha256")) + .ok() + .and_then(|b| String::from_utf8(b).ok()) + .and_then(|s| s.split_whitespace().next().map(str::to_string)); + return accept(&bytes, name, expected.as_deref(), source, "url"); + } + + install_from_registry(source, registry) +} + +fn install_from_registry(name: &str, registry: Option<&str>) -> Result { + let index_url = registry + .map(str::to_string) + .or_else(|| std::env::var(DEFAULT_REGISTRY_ENV).ok()) + .ok_or_else(|| { + anyhow!( + "'{name}' is not an existing file or a URL, and no plugin registry is configured.\n\ + Set {DEFAULT_REGISTRY_ENV} to a release index URL, pass --registry, or install \ + from a downloaded file:\n hu plugin install ./hu_{name}.wasm" + ) + })?; + + let raw = http_get(&index_url)?; + let index: RegistryIndex = serde_json::from_slice(&raw) + .with_context(|| format!("parsing plugin index at {index_url}"))?; + + if index.wit_world != HOST_WIT_WORLD { + bail!( + "plugin index targets WIT world {} but this hu hosts {}.\n\ + Install a release matching this hu, or upgrade hu.", + index.wit_world, + HOST_WIT_WORLD + ); + } + + let entry = index + .plugins + .iter() + .find(|p| p.name == name) + .ok_or_else(|| { + let available: Vec<&str> = index.plugins.iter().map(|p| p.name.as_str()).collect(); + anyhow!( + "no plugin named '{name}' in the index. Available: {}", + if available.is_empty() { + "(none)".to_string() + } else { + available.join(", ") + } + ) + })?; + + // Assets sit next to the index. + let base = index_url + .rsplit_once('/') + .map(|(b, _)| b.to_string()) + .unwrap_or_default(); + let asset_url = format!("{base}/{}", entry.file); + let bytes = http_get(&asset_url)?; + + accept( + &bytes, + &entry.file, + Some(&entry.sha256), + &asset_url, + &entry.version, + ) +} + +/// Remove an installed plugin by its subcommand name. +pub fn uninstall(name: &str) -> Result { + let dir = install_dir()?; + let candidates = [ + dir.join(format!("hu_{name}.wasm")), + dir.join(format!("hu-{name}.wasm")), + dir.join(format!("{name}.wasm")), + ]; + let found = candidates.iter().find(|p| p.exists()).ok_or_else(|| { + // Point at the real cause when the plugin is on the path but not in + // the dir we manage — removing it is not ours to do. + let elsewhere = plugin_search_dirs() + .into_iter() + .filter(|d| *d != dir) + .any(|d| { + ["hu_", "hu-", ""] + .iter() + .any(|p| d.join(format!("{p}{name}.wasm")).exists()) + }); + if elsewhere { + anyhow!( + "'{name}' is loaded from a directory on $HU_PLUGIN_PATH, not from {}. \ + Remove it there, or unset $HU_PLUGIN_PATH.", + dir.display() + ) + } else { + anyhow!("no installed plugin named '{name}' in {}", dir.display()) + } + })?; + + std::fs::remove_file(found).with_context(|| format!("removing {}", found.display()))?; + + let mut db = load_db(); + db.plugins.retain(|p| p.name != name); + let _ = save_db(&db); + + Ok(found.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sha256_matches_known_vector() { + assert_eq!( + sha256_hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn traversal_in_a_downloaded_name_cannot_escape_the_install_dir() { + // The name is attacker-influenced; every separator and dot must be + // collapsed so the result stays one segment. + for evil in ["../../../etc/passwd", "..", "a/b/c", "hu_../x"] { + let stem = Path::new(evil) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); + let safe = sanitize_plugin_stem(stem); + assert!(!safe.contains('/'), "{evil} → {safe}"); + assert!(!safe.contains(".."), "{evil} → {safe}"); + assert_eq!(Path::new(&safe).components().count(), 1, "{evil} → {safe}"); + } + } + + #[test] + fn release_asset_names_install_under_their_plain_subcommand_name() { + // Regression: installing the release asset `hu_meter-0.1.0.wasm` gave + // the subcommand `hu meter-0_1_0`, so the documented `hu meter` did + // not exist after installing exactly what the release publishes. + assert_eq!(strip_version_suffix("hu_meter-0.1.0"), "hu_meter"); + assert_eq!(strip_version_suffix("hu_monitor-1.2.3"), "hu_monitor"); + assert_eq!(strip_version_suffix("hu_meter-12"), "hu_meter"); + } + + #[test] + fn a_name_that_merely_contains_a_hyphen_is_left_alone() { + // Renaming someone's subcommand because it has a hyphen would be + // worse than leaving a version on. + assert_eq!(strip_version_suffix("hu_my-tool"), "hu_my-tool"); + assert_eq!(strip_version_suffix("hu_meter"), "hu_meter"); + assert_eq!(strip_version_suffix("hu_a-b-c"), "hu_a-b-c"); + assert_eq!(strip_version_suffix("-1.0"), "-1.0"); + assert_eq!(strip_version_suffix("hu_x-"), "hu_x-"); + assert_eq!(strip_version_suffix("hu_x-..."), "hu_x-..."); + } + + #[test] + fn url_detection_does_not_treat_a_path_as_a_url() { + assert!(is_url("https://example.com/hu_meter.wasm")); + assert!(is_url("http://example.com/hu_meter.wasm")); + assert!(!is_url("./hu_meter.wasm")); + assert!(!is_url("meter")); + assert!(!is_url("/home/u/hu_meter.wasm")); + } +} diff --git a/crates/hiroz-union/src/plugin/mod.rs b/crates/hiroz-union/src/plugin/mod.rs index ce1d9f82e..94e94e12f 100644 --- a/crates/hiroz-union/src/plugin/mod.rs +++ b/crates/hiroz-union/src/plugin/mod.rs @@ -1 +1,3 @@ +#[cfg(feature = "wasm-plugins")] +pub mod install; pub mod wasm; diff --git a/crates/hiroz-union/src/plugin/wasm/mod.rs b/crates/hiroz-union/src/plugin/wasm/mod.rs index 4672f1976..daec6cd7d 100644 --- a/crates/hiroz-union/src/plugin/wasm/mod.rs +++ b/crates/hiroz-union/src/plugin/wasm/mod.rs @@ -15,6 +15,7 @@ pub use host::web_bindgen::hu::plugin::web_types::{HttpRequest, HttpResponse}; use std::collections::HashMap; use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, OnceLock}; use std::time::Duration; @@ -200,6 +201,51 @@ fn configured_wasm_engine() -> Result { Engine::new(&engine_config).context("creating WASM engine") } +// ─── Epoch budget vs. blocking host calls ──────────────────────────────────── + +/// Number of host calls currently blocked on I/O on behalf of a guest. +/// +/// Every guest dispatch runs under `set_epoch_deadline(30)` and the ticker below +/// increments the epoch every 100 ms, so a guest gets ~3 s of *wall clock* — and +/// wall clock is what the ticker measures, so time a host call spends waiting on +/// the network counts against the guest's budget even though the guest is not +/// running. A host call that blocks longer than the remaining budget therefore +/// traps the guest the moment it returns, and the guest never runs the error +/// branch it was just handed. That is not a theoretical bound: schema discovery +/// waits for a live publisher and then queries it, which legitimately takes +/// seconds on a cold graph. +static HOST_BLOCKING_CALLS: AtomicUsize = AtomicUsize::new(0); + +/// Suspends the epoch ticker for as long as it is alive. Hold one around any +/// host call that blocks on I/O, so the wait is not charged to the guest's +/// compute budget. +/// +/// The suspension is process-wide (there is one engine and one ticker), so a +/// *different* runaway guest is not preempted while a blocking call is in +/// flight. That window is bounded by the host call's own timeout, and the +/// alternative — trapping a well-behaved guest for waiting on the network — is +/// strictly worse. +pub(crate) struct HostBlockGuard(()); + +impl HostBlockGuard { + pub(crate) fn enter() -> Self { + HOST_BLOCKING_CALLS.fetch_add(1, Ordering::SeqCst); + Self(()) + } +} + +impl Drop for HostBlockGuard { + fn drop(&mut self) { + HOST_BLOCKING_CALLS.fetch_sub(1, Ordering::SeqCst); + } +} + +/// Whether the epoch ticker should advance right now. Split out so the rule is +/// testable without an engine. +fn epoch_should_tick() -> bool { + HOST_BLOCKING_CALLS.load(Ordering::SeqCst) == 0 +} + /// Process-wide shared WASM engine (and its single epoch-ticker task). Building /// a fresh engine per `load_plugins` call would spawn a new ticker each time — /// the TUI's `reload_plugins` loops, so tickers (each holding an engine clone) @@ -220,7 +266,9 @@ fn shared_wasm_engine() -> Result { handle.spawn(async move { loop { tokio::time::sleep(Duration::from_millis(100)).await; - ticker_engine.increment_epoch(); + if epoch_should_tick() { + ticker_engine.increment_epoch(); + } } }); } else { @@ -232,7 +280,9 @@ fn shared_wasm_engine() -> Result { .spawn(move || { loop { std::thread::sleep(Duration::from_millis(100)); - ticker_engine.increment_epoch(); + if epoch_should_tick() { + ticker_engine.increment_epoch(); + } } }) { @@ -357,7 +407,7 @@ fn iter_wasm_files() -> impl Iterator { /// outside `[A-Za-z0-9_-]` — including `.` and path separators — to `_`, so the /// result is always a single safe segment (`..` becomes `__`); fall back to /// `"unknown"` only for an empty stem. -fn sanitize_plugin_stem(plugin_stem: &str) -> String { +pub(crate) fn sanitize_plugin_stem(plugin_stem: &str) -> String { let cleaned: String = plugin_stem .chars() .map(|c| { @@ -583,15 +633,59 @@ pub fn validate_plugin_static(path: &std::path::Path) -> Result { Ok(format!("OK: {} is a valid WASM component", path.display())) } -fn plugin_search_dirs() -> Vec { +pub(crate) fn plugin_search_dirs() -> Vec { let mut dirs = Vec::new(); if let Ok(paths) = std::env::var("HU_PLUGIN_PATH") { for p in std::env::split_paths(&paths) { dirs.push(p); } } + // Prefix-relative, derived from where THIS binary sits. `install-hu.sh + // --prefix /opt/hu` writes the binary to /opt/hu/bin/hu and the plugins to + // /opt/hu/share/hu/plugins; without this the install succeeds, reports + // "installed plugin hu_meter.wasm", and then `hu plugin list` -- which the + // installer's own closing message tells the user to run -- is empty, + // because discovery only ever looked under $HOME. + // + // Both CI callers set HU_PREFIX and HOME to the same scratch directory, + // which is the one configuration where that bug cannot appear. + if let Ok(exe) = std::env::current_exe() + && let Some(prefix) = exe.parent().and_then(|bin| bin.parent()) + { + dirs.push(prefix.join("share/hu/plugins")); + } if let Some(home) = dirs::home_dir() { dirs.push(home.join(".local/share/hu/plugins")); } + // A default-prefix install makes the two paths above identical, and a + // duplicated directory would list every plugin twice. + dirs.dedup(); dirs } + +#[cfg(test)] +mod tests { + use super::{HostBlockGuard, epoch_should_tick}; + + // The ticker's only guard against charging network waits to the guest's + // compute budget is this counter, and it is one `fetch_add` away from being + // silently dropped by a later edit. (Serial by construction: nothing else in + // this crate's unit tests takes the guard, since host calls need a store.) + #[test] + fn host_block_guard_suspends_the_epoch_ticker() { + assert!(epoch_should_tick(), "ticker suspended before any guard"); + { + let _outer = HostBlockGuard::enter(); + assert!(!epoch_should_tick(), "guard did not suspend the ticker"); + { + let _inner = HostBlockGuard::enter(); + assert!(!epoch_should_tick(), "nested guard un-suspended it"); + } + assert!( + !epoch_should_tick(), + "dropping the inner guard resumed ticking while the outer is held" + ); + } + assert!(epoch_should_tick(), "ticker never resumed"); + } +} diff --git a/crates/hiroz-union/tests/plugin_install.rs b/crates/hiroz-union/tests/plugin_install.rs new file mode 100644 index 000000000..37278a801 --- /dev/null +++ b/crates/hiroz-union/tests/plugin_install.rs @@ -0,0 +1,339 @@ +//! `hu plugin install` over the network: URL and registry sources. +//! +//! These drive the **real binary** via `CARGO_BIN_EXE_hu` rather than calling +//! the functions directly. That is not a stylistic choice: `hiroz-union` is a +//! binary-only crate with no lib target, so an integration test cannot `use` +//! it. Driving the CLI also covers argument parsing and exit status, which is +//! what a user and a script actually depend on. +//! +//! Every test here is a **refusal**. Those are the paths that had never once +//! executed — including the WIT world-mismatch check, which is the kind of +//! guard that looks correct forever and is never proven to fire. +//! +//! The success paths are **not** here, because they need a genuine WASM +//! component and this crate cannot build one: the plugins are a separate, +//! `exclude`d, `wasm32-wasip2` workspace. They live at the end of +//! `scripts/ci/hu-tests.sh`, which has already built real plugins by that +//! point — install by URL with a `.sha256` sidecar, install by registry name +//! through a served index, dispatch, and uninstall. +//! +//! An earlier version of this comment claimed that script and the +//! docs-reproduction suite already covered them. Neither did: the script +//! touched only `plugin validate` and `plugin list`, and every +//! `hu plugin install` line in the docs is `skip`. The claim is why nobody +//! noticed for so long — a comment asserting coverage is as good at hiding a +//! gap as a doc asserting behaviour. +//! +//! The server is a few lines of `std::net` on a loopback ephemeral port: no +//! new dependency, and no network access, so these stay runnable offline. + +use std::{ + collections::HashMap, + io::{BufRead, BufReader, Write}, + net::{TcpListener, TcpStream}, + path::PathBuf, + process::Command, + sync::Arc, +}; + +/// A canned response: HTTP status and body. +type Route = (u16, Vec); + +/// Serve a fixed route table on loopback until the test drops the handle. +/// Returns the base URL. +fn serve(routes: HashMap) -> String { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback"); + let port = listener.local_addr().unwrap().port(); + let routes = Arc::new(routes); + + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { continue }; + let routes = Arc::clone(&routes); + std::thread::spawn(move || handle(stream, &routes)); + } + }); + + format!("http://127.0.0.1:{port}") +} + +fn handle(mut stream: TcpStream, routes: &HashMap) { + let mut reader = BufReader::new(match stream.try_clone() { + Ok(s) => s, + Err(_) => return, + }); + let mut request_line = String::new(); + if reader.read_line(&mut request_line).is_err() { + return; + } + // "GET /path HTTP/1.1" + let path = request_line.split_whitespace().nth(1).unwrap_or("/"); + + let (status, body) = routes + .get(path) + .cloned() + .unwrap_or((404, b"not found".to_vec())); + + let head = format!( + "HTTP/1.1 {status} X\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(head.as_bytes()); + let _ = stream.write_all(&body); + let _ = stream.flush(); +} + +struct Outcome { + ok: bool, + output: String, + home: PathBuf, +} + +impl Outcome { + /// Nothing may be left in the plugin directory after a refusal — a + /// partially-written plugin is worse than none, because discovery would + /// pick it up. + fn installed_plugins(&self) -> Vec { + let dir = self.home.join(".local/share/hu/plugins"); + std::fs::read_dir(dir) + .map(|rd| { + rd.flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect() + }) + .unwrap_or_default() + } +} + +/// Run `hu plugin install ` with an isolated HOME. +fn install(args: &[&str]) -> Outcome { + let home = std::env::temp_dir().join(format!( + "hu-install-test-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&home); + std::fs::create_dir_all(&home).unwrap(); + + let out = Command::new(env!("CARGO_BIN_EXE_hu")) + .arg("plugin") + .arg("install") + .args(args) + .env("HOME", &home) + // Discovery must not reach a build tree during these tests. + .env_remove("HU_PLUGIN_PATH") + .env_remove("HU_PLUGIN_REGISTRY") + .output() + .expect("run hu"); + + let output = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + Outcome { + ok: out.status.success(), + output, + home, + } +} + +fn index_json(world: &str, file: &str, sha: &str) -> Vec { + format!( + r#"{{"schema":1,"hu_version":"0.1.0","wit_world":"{world}", + "plugins":[{{"name":"meter","file":"{file}","version":"0.1.0", + "sha256":"{sha}","world":"hu-cli-plugin","description":"d"}}]}}"# + ) + .into_bytes() +} + +#[test] +fn url_install_refuses_a_404_instead_of_installing_the_error_page() { + let base = serve(HashMap::new()); // every path 404s + let r = install(&[&format!("{base}/hu_meter.wasm")]); + + assert!(!r.ok, "a 404 must fail, output was:\n{}", r.output); + assert!( + r.output.contains("download failed") || r.output.contains("error"), + "should say the download failed, got:\n{}", + r.output + ); + assert!( + r.installed_plugins().is_empty(), + "nothing may be installed after a 404, found {:?}", + r.installed_plugins() + ); +} + +#[test] +fn url_install_refuses_a_checksum_mismatch() { + let mut routes = HashMap::new(); + routes.insert("/hu_meter.wasm".to_string(), (200, b"payload".to_vec())); + // Sidecar advertises a hash the payload does not have. + routes.insert("/hu_meter.wasm.sha256".to_string(), (200, vec![b'0'; 64])); + let base = serve(routes); + + let r = install(&[&format!("{base}/hu_meter.wasm")]); + assert!(!r.ok, "checksum mismatch must fail:\n{}", r.output); + assert!( + r.output.contains("checksum mismatch"), + "should name the mismatch, got:\n{}", + r.output + ); + assert!(r.installed_plugins().is_empty()); +} + +#[test] +fn url_install_refuses_bytes_that_are_not_a_component() { + let mut routes = HashMap::new(); + // No sidecar, so the checksum step is skipped and validation is what has + // to catch this. + routes.insert( + "/hu_meter.wasm".to_string(), + (200, b"definitely not a wasm component".to_vec()), + ); + let base = serve(routes); + + let r = install(&[&format!("{base}/hu_meter.wasm")]); + assert!(!r.ok, "a non-component must fail:\n{}", r.output); + assert!( + r.output.contains("not a loadable WASM component"), + "should say it is not loadable, got:\n{}", + r.output + ); + assert!( + r.installed_plugins().is_empty(), + "the partial file must be cleaned up, found {:?}", + r.installed_plugins() + ); +} + +#[test] +fn registry_install_refuses_a_wit_world_this_hu_does_not_host() { + let mut routes = HashMap::new(); + routes.insert( + "/index.json".to_string(), + ( + 200, + index_json("hu:plugin@9.9.9", "hu_meter-0.1.0.wasm", &"0".repeat(64)), + ), + ); + let base = serve(routes); + + let r = install(&["meter", "--registry", &format!("{base}/index.json")]); + assert!(!r.ok, "a world mismatch must fail:\n{}", r.output); + assert!( + r.output.contains("9.9.9") && r.output.contains("hu:plugin@"), + "the message must name both worlds so the user can act, got:\n{}", + r.output + ); + assert!(r.installed_plugins().is_empty()); +} + +#[test] +fn registry_install_refuses_an_unknown_name_and_lists_what_exists() { + let mut routes = HashMap::new(); + routes.insert( + "/index.json".to_string(), + ( + 200, + index_json("hu:plugin@0.1.0", "hu_meter-0.1.0.wasm", &"0".repeat(64)), + ), + ); + let base = serve(routes); + + let r = install(&["nosuchplugin", "--registry", &format!("{base}/index.json")]); + assert!(!r.ok, "an unknown name must fail:\n{}", r.output); + assert!( + r.output.contains("meter"), + "should list what IS available, got:\n{}", + r.output + ); +} + +#[test] +fn registry_install_without_a_registry_says_how_to_configure_one() { + // Not a URL and not an existing file, with no registry configured: the + // message has to name the way out, or the user is stuck. + let r = install(&["meter"]); + assert!(!r.ok); + assert!( + r.output.contains("HU_PLUGIN_REGISTRY") && r.output.contains("--registry"), + "should name both ways to configure a registry, got:\n{}", + r.output + ); +} + +// --------------------------------------------------------------- uninstall +// +// `hu plugin uninstall` had never run either. Its interesting behaviour is +// the second branch: a plugin visible on `$HU_PLUGIN_PATH` is discoverable +// but not ours to delete, and saying "not installed" there would be actively +// misleading — the user can see it in `hu plugin list`. + +/// Run `hu plugin uninstall `, optionally with a plugin dir on +/// `$HU_PLUGIN_PATH`. +fn uninstall(name: &str, plugin_path: Option<&std::path::Path>) -> Outcome { + let home = std::env::temp_dir().join(format!( + "hu-uninstall-test-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&home); + std::fs::create_dir_all(&home).unwrap(); + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_hu")); + cmd.arg("plugin") + .arg("uninstall") + .arg(name) + .env("HOME", &home); + match plugin_path { + Some(p) => cmd.env("HU_PLUGIN_PATH", p), + None => cmd.env_remove("HU_PLUGIN_PATH"), + }; + + let out = cmd.output().expect("run hu"); + let output = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + Outcome { + ok: out.status.success(), + output, + home, + } +} + +#[test] +fn uninstall_refuses_a_plugin_that_is_not_installed() { + let r = uninstall("nosuchplugin", None); + assert!(!r.ok, "removing nothing must fail:\n{}", r.output); + assert!( + r.output + .contains("no installed plugin named 'nosuchplugin'"), + "should name what it looked for, got:\n{}", + r.output + ); +} + +#[test] +fn uninstall_explains_when_the_plugin_lives_on_hu_plugin_path() { + // A plugin here is discoverable — `hu plugin list` shows it — but it is + // not in the directory installs manage. "Not installed" would contradict + // what the user can see, so the message has to distinguish the two. + let dir = std::env::temp_dir().join(format!("hu-extpath-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("hu_meter.wasm"), b"not a real component").unwrap(); + + let r = uninstall("meter", Some(&dir)); + assert!(!r.ok, "must not claim success:\n{}", r.output); + assert!( + r.output.contains("HU_PLUGIN_PATH"), + "should point at the real location, not say 'not installed', got:\n{}", + r.output + ); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/hiroz-union/wit/v0.1/hu-plugin.wit b/crates/hiroz-union/wit/v0.1/hu-plugin.wit index 84165bd97..e4232ac93 100644 --- a/crates/hiroz-union/wit/v0.1/hu-plugin.wit +++ b/crates/hiroz-union/wit/v0.1/hu-plugin.wit @@ -64,7 +64,8 @@ interface types { denied, // The request was malformed (bad key expression, YAML/JSON, etc.). invalid(string), - // The underlying Zenoh transport failed. + // The underlying transport, or host-side setup such as schema + // discovery, failed. Carries a human-readable reason. transport(string), } From 6db9ebe654701bd3045e7e710d4024cd2542955b Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Thu, 20 Aug 2026 16:53:13 +0800 Subject: [PATCH 03/21] fix(hu): repair the paths a released binary actually runs Three defects that only a shipped artifact could expose. `hu web` panicked on startup. Its plugin routes used axum 0.7 wildcard syntax against axum 0.8, and `Router::route` validates by panicking at run time, so it compiled and died the moment anyone ran it. CI never compiled the feature. The type-hash guard on the disk-schema path compared rendered strings. One renderer is cfg-gated and collapses every hash to a single constant, so the comparison could pass on anything. Compare values, and treat "no hash advertised" as a third state rather than a mismatch, so a peer that advertises none is not refused with advice no message definition can satisfy. Six tests pin the decision. The `hu-meter` plugin and the logger round out the set. --- crates/hiroz-tests/tests/hu_meter.rs | 32 +- .../hiroz-union/plugins/hu-meter/src/lib.rs | 77 ++- crates/hiroz-union/src/core/logger.rs | 45 +- crates/hiroz-union/src/modes/web.rs | 35 +- .../hiroz-union/src/plugin/wasm/host/ros.rs | 438 +++++++++++++++--- 5 files changed, 518 insertions(+), 109 deletions(-) diff --git a/crates/hiroz-tests/tests/hu_meter.rs b/crates/hiroz-tests/tests/hu_meter.rs index acfdeed5f..5b561fb24 100644 --- a/crates/hiroz-tests/tests/hu_meter.rs +++ b/crates/hiroz-tests/tests/hu_meter.rs @@ -1009,20 +1009,40 @@ fn test_hu_meter_param_load() { // ─── echo --timeout ────────────────────────────────────────────────────────── +/// `echo` on a topic nobody publishes: the contract is **terminate, and say +/// why**. It used to exit 0 after the timeout having printed nothing, which is +/// the exact ambiguity this branch removes — a silent success is +/// indistinguishable from "the topic is idle". There is no schema to resolve +/// (nothing advertises a type and `subscribe` carries only a topic name), so +/// `subscribe` now fails, hu-meter reports it and exits non-zero. +/// +/// The "does not hang" half of the original assertion is preserved by the fact +/// that `run_hu_meter` returns at all. +/// +/// This is also the end-to-end cover for the epoch budget: with no publisher, +/// `subscribe` blocks for the *full* discovery timeout (5 s) inside a guest +/// dispatch whose epoch deadline is ~3 s of wall clock. Without +/// `HostBlockGuard` suspending the epoch ticker, the guest traps on return, the +/// error branch below never runs, and `exit_code` stays `None` — the command +/// would print nothing and hang in the tick loop instead of failing here. #[test] #[serial_test::serial] -fn test_hu_meter_echo_timeout_exits() { +fn test_hu_meter_echo_no_publisher_reports_and_exits() { let router = TestRouter::new(); - // No publisher — echo should exit after the timeout rather than hang. let out = run_hu_meter( router.endpoint(), &["echo", "/no_publisher_topic", "--timeout", "1"], ); - // Should exit cleanly (not hang indefinitely). + let stderr = String::from_utf8_lossy(&out.stderr); assert!( - out.status.success(), - "hu meter echo --timeout should exit cleanly when no messages arrive: {}", - String::from_utf8_lossy(&out.stderr) + !out.status.success(), + "hu meter echo on a topic with no publisher must report a failure, not exit \ + 0 having printed nothing (stdout: {}, stderr: {stderr})", + String::from_utf8_lossy(&out.stdout) + ); + assert!( + stderr.contains("/no_publisher_topic"), + "the failure must name the topic it could not resolve: {stderr}" ); } diff --git a/crates/hiroz-union/plugins/hu-meter/src/lib.rs b/crates/hiroz-union/plugins/hu-meter/src/lib.rs index 371cd45f2..01450a860 100644 --- a/crates/hiroz-union/plugins/hu-meter/src/lib.rs +++ b/crates/hiroz-union/plugins/hu-meter/src/lib.rs @@ -16,21 +16,34 @@ struct HuMeter { // Ticks elapsed (used for duration tracking at tick_ms = 1000 ms) ticks: u32, duration_ticks: u32, + /// Keeps `hz`/`bw` visible in the ROS graph. The rate tracker behind + /// `measure_hz` is a raw zenoh wildcard subscriber with no liveliness + /// token, so it announces nothing -- and a publisher that gates on + /// `wait_for_subscription` then waits forever and never publishes, which + /// reads back as a topic with no traffic. Holding a real subscription + /// restores the announcement. Its messages are unused; dropping it would + /// undeclare the token, so it must live as long as the measurement. + /// + /// Best-effort on purpose: `None` when the topic advertises no type, where + /// `hz` and `bw` must still work. + graph_presence: Option, } enum Mode { /// Waiting for startup event (initial state) Init, - /// Measure publish rate on a topic - Hz { - topic: String, - sub: Option, - }, - /// Measure bandwidth on a topic - Bw { - topic: String, - sub: Option, - }, + /// Measure publish rate on a topic. The numbers come from + /// `ros::measure_hz`, backed by a raw wildcard subscriber in the host that + /// counts and sizes bytes without decoding them -- so `hz` needs no schema + /// and keeps working on a topic whose type cannot be resolved. + /// + /// A best-effort subscription is still taken, in `HuMeter::graph_presence` + /// rather than here, purely so `hz` announces itself in the ROS graph. It + /// is deliberately not part of this variant: the measurement does not + /// depend on it, and a failure to acquire it must not fail the command. + Hz { topic: String }, + /// Measure bandwidth on a topic. See `Hz`; `bw` works the same way. + Bw { topic: String }, /// Echo messages Echo { topic: String, @@ -78,6 +91,7 @@ impl HuMeter { json: false, ticks: 0, duration_ticks: 0, + graph_presence: None, } } @@ -163,19 +177,13 @@ impl HuMeter { return; }; self.duration_ticks = duration_ticks; - let sub = match ros::subscribe(&topic) { - Ok(s) => s, - Err(e) => { - render::eprintln(&format!("Failed to subscribe to {topic}: {e}")); - render::exit(1); - self.mode = Mode::Done; - return; - } - }; - self.mode = Mode::Hz { - topic, - sub: Some(sub), - }; + // The numbers come from `measure_hz`, not from this subscription -- see + // `Mode::Hz`. It exists so `hz` still ANNOUNCES itself in the graph, as + // it did when this took a typed subscription for its data. Errors are + // ignored: on a topic with no advertised type there is nothing to + // announce, and `hz` must keep working there. + self.graph_presence = ros::subscribe(&topic).ok(); + self.mode = Mode::Hz { topic }; } fn cmd_bw(&mut self, args: &[String]) { @@ -187,19 +195,10 @@ impl HuMeter { return; }; self.duration_ticks = duration_ticks; - let sub = match ros::subscribe(&topic) { - Ok(s) => s, - Err(e) => { - render::eprintln(&format!("Failed to subscribe to {topic}: {e}")); - render::exit(1); - self.mode = Mode::Done; - return; - } - }; - self.mode = Mode::Bw { - topic, - sub: Some(sub), - }; + // Graph presence only, exactly as in `cmd_hz`; `measure_bw` supplies + // the numbers. + self.graph_presence = ros::subscribe(&topic).ok(); + self.mode = Mode::Bw { topic }; } fn cmd_echo(&mut self, args: &[String]) { @@ -1265,7 +1264,7 @@ impl HuMeter { let done = self.duration_ticks > 0 && self.ticks >= self.duration_ticks; match &mut self.mode { - Mode::Hz { topic, sub } => { + Mode::Hz { topic } => { let window_ms = 1000u32; match ros::measure_hz(topic, window_ms) { Ok(m) => { @@ -1283,13 +1282,12 @@ impl HuMeter { } Err(e) => render::println(&format!("measure-hz error: {e}")), } - let _ = sub; // keep subscription alive if done { render::exit(0); self.mode = Mode::Done; } } - Mode::Bw { topic, sub } => { + Mode::Bw { topic } => { let window_ms = 1000u32; match ros::measure_bw(topic, window_ms) { Ok(m) => { @@ -1307,7 +1305,6 @@ impl HuMeter { } Err(e) => render::println(&format!("measure-bw error: {e}")), } - let _ = sub; if done { render::exit(0); self.mode = Mode::Done; diff --git a/crates/hiroz-union/src/core/logger.rs b/crates/hiroz-union/src/core/logger.rs index 909b4bfcb..ac1df70b7 100644 --- a/crates/hiroz-union/src/core/logger.rs +++ b/crates/hiroz-union/src/core/logger.rs @@ -1,14 +1,24 @@ use tracing_subscriber::{EnvFilter, fmt}; +/// Default filter directives when `RUST_LOG` is unset. +/// +/// `hu` must be listed explicitly: the binary target is named `hu` +/// (`[[bin]] name = "hu"`), so every `tracing::` call in this crate emits under +/// the `hu` target. A filter naming only `hiroz` and `zenoh` drops all of them, +/// which is why the host's schema-discovery and decode warnings were emitted and +/// then discarded before reaching a terminal. +pub(crate) fn default_filter(debug: bool) -> &'static str { + if debug { + "hu=debug,hiroz=debug,zenoh=debug" + } else { + "hu=info,hiroz=info,zenoh=warn" + } +} + pub fn init_logger(json_mode: bool, debug: bool) { // Build filter from RUST_LOG environment variable or default - let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { - if debug { - EnvFilter::new("hiroz=debug,zenoh=debug") - } else { - EnvFilter::new("hiroz=info,zenoh=warn") - } - }); + let filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_filter(debug))); if json_mode { // Structured JSON logs to stderr (for real-time visibility) @@ -30,3 +40,24 @@ pub fn init_logger(json_mode: bool, debug: bool) { .init(); } } + +#[cfg(test)] +mod tests { + use super::default_filter; + use tracing_subscriber::EnvFilter; + + // The whole of the `hu` target fix is one string literal, and it is exactly + // the kind of line a later edit drops without noticing. + #[test] + fn defaults_name_the_hu_target_and_parse() { + for debug in [false, true] { + let directives = default_filter(debug); + assert!( + directives.contains("hu="), + "default filter for debug={debug} does not name the `hu` target: {directives}" + ); + EnvFilter::try_new(directives) + .unwrap_or_else(|e| panic!("default filter {directives:?} does not parse: {e}")); + } + } +} diff --git a/crates/hiroz-union/src/modes/web.rs b/crates/hiroz-union/src/modes/web.rs index 4a70b57da..706540e1f 100644 --- a/crates/hiroz-union/src/modes/web.rs +++ b/crates/hiroz-union/src/modes/web.rs @@ -41,6 +41,18 @@ mod inner { plugins: Mutex>, } + /// Split out from `run_web_mode` so a test can build it without a router, + /// a Zenoh session or a bound socket. `Router::route` validates path + /// syntax by **panicking at run time**, not at compile time — the axum 0.7 + /// form `*path` compiles cleanly and then aborts `hu web` on startup. That + /// shipped unnoticed because `web-plugins` was never built in CI. + fn build_router(state: Arc) -> Router { + Router::new() + .route("/plugins/{name}/{*path}", any(handle_plugin_request)) + .route("/plugins/{name}", any(handle_plugin_request_root)) + .with_state(state) + } + pub async fn run_web_mode( core: Arc, port: u16, @@ -59,10 +71,7 @@ mod inner { plugins: Mutex::new(plugins), }); - let app = Router::new() - .route("/plugins/{name}/*path", any(handle_plugin_request)) - .route("/plugins/{name}", any(handle_plugin_request_root)) - .with_state(state); + let app = build_router(state); // Bind to loopback by default so the plugin HTTP surface is not exposed // on all interfaces. Set `HU_WEB_BIND` (e.g. `0.0.0.0`) to opt into @@ -138,4 +147,22 @@ mod inner { }, } } + + #[cfg(test)] + mod tests { + use super::*; + + /// Regression: the routes used axum 0.7 wildcard syntax (`*path`) + /// against axum 0.8. That compiles cleanly and panics the moment + /// `hu web` starts, so it shipped unnoticed while `web-plugins` was + /// never built in CI. Building the router IS the check — wrong path + /// syntax panics here. + #[test] + fn router_paths_are_valid_for_this_axum_version() { + let state = Arc::new(WebState { + plugins: Mutex::new(Vec::new()), + }); + let _ = build_router(state); + } + } } diff --git a/crates/hiroz-union/src/plugin/wasm/host/ros.rs b/crates/hiroz-union/src/plugin/wasm/host/ros.rs index ccaf17936..ff6d554b1 100644 --- a/crates/hiroz-union/src/plugin/wasm/host/ros.rs +++ b/crates/hiroz-union/src/plugin/wasm/host/ros.rs @@ -3,9 +3,15 @@ use std::sync::Arc; use std::time::Duration; -use hiroz::dynamic::{ - DynamicMessage, DynamicValue, FieldType, MessageSchema, - serialization::{deserialize_cdr, serialize_cdr}, +use hiroz::{ + Builder, + dynamic::{ + DynSub, DynamicMessage, DynamicValue, FieldType, MessageSchema, + MessageSchemaTypeDescription, + serialization::{deserialize_cdr, serialize_cdr}, + }, + graph::Graph, + node::ZNode, }; use wasmtime::component::Resource; use zenoh::Wait; @@ -16,25 +22,174 @@ use super::super::state::{PluginState, ServiceClientData, SubscriptionData}; use super::hu; use hu::plugin::types::PluginError; +/// The message type advertised by a live publisher or subscriber on `topic`, if +/// any. Free-standing (rather than a `PluginState` method) so it can be tested +/// against a hand-built `Graph` without a wasmtime store. +fn live_topic_type_info(graph: &Graph, topic: &str) -> Option { + use hiroz_protocol::{EndpointKind, Entity}; + [EndpointKind::Publisher, EndpointKind::Subscription] + .into_iter() + .find_map(|kind| { + graph + .get_entities_by_topic(kind, topic) + .first() + .and_then(|ent| match ent.as_ref() { + Entity::Endpoint(ep) => ep.type_info.clone(), + _ => None, + }) + }) +} + +/// The three outcomes of checking a local `.msg` against a topic's advertised +/// hash. "Not advertised" is deliberately distinct from "mismatch": it cannot be +/// verified either way, so it must not be refused. +#[derive(Debug, PartialEq, Eq)] +enum HashCheck { + NotAdvertised, + Match, + Mismatch, +} + +/// Compare two hashes by value. +/// +/// Extracted so the three-way decision is testable without a router, a graph or +/// a `.msg` on disk. This guard has been wrong twice, both times in the +/// comparison rather than the surrounding plumbing, and both times found by +/// reading rather than by a test. +fn check_type_hash(local: &hiroz::TypeHash, advertised: &hiroz::TypeHash) -> HashCheck { + if *advertised == hiroz::TypeHash::zero() { + HashCheck::NotAdvertised + } else if local == advertised { + HashCheck::Match + } else { + HashCheck::Mismatch + } +} + +/// Render a `hiroz_protocol::TypeHash` as RIHS, without the `no-type-hash` gate. +/// +/// `TypeHash::to_rihs_string` collapses to the constant "TypeHashNotSupported" +/// under that feature. That is right for the wire, where the constant *is* the +/// representation, and wrong for a diagnostic, which must show what the peer +/// actually advertised. +fn rihs_ungated(hash: &hiroz::TypeHash) -> String { + let hex: String = hash.value.iter().map(|b| format!("{b:02x}")).collect(); + format!("RIHS{:02x}_{hex}", hash.version) +} + +/// Build a dynamic subscriber for `topic` from a `.msg` on `HIROZ_MSG_PATH`, +/// used only when live discovery has already failed. The type *name* is still +/// taken from the graph -- `subscribe(topic)` carries no type, so without a live +/// endpoint there is nothing to look up. +/// +/// Returns the specific reason on failure rather than a bare `None`: the three +/// ways this can fail (no live endpoint, no `.msg` on disk, the subscriber +/// declaration itself failing) send the reader to three different places, and +/// collapsing them into one "check `HIROZ_MSG_PATH`" message is the same class +/// of misdirection this whole path exists to remove. +fn dyn_sub_from_local_msg(node: &ZNode, graph: &Graph, topic: &str) -> Result { + let Some(ti) = live_topic_type_info(graph, topic) else { + return Err(format!( + "no publisher or subscriber on {topic} advertises a type, so there is no \ + type name to look up -- `subscribe` carries only a topic, and these \ + commands have no --type flag" + )); + }; + let canonical = hiroz::dynamic::ros_type_name_from_dds(&ti.name); + let Some(schema) = hiroz::dynamic::load_schema(&canonical) else { + return Err(format!( + "no .msg for {canonical} (advertised by {topic}) was found on HIROZ_MSG_PATH" + )); + }; + + // A subscriber's key expression is exact -- `{domain}/{topic}/{type}/{hash}` + // with no wildcard -- so it must carry the *publisher's* hash, which + // `with_type_info` below assigns. That makes messages arrive even when the + // local .msg disagrees with the publisher, and CDR is positional: a skewed + // schema usually yields structurally valid, plausible, *wrong* field values + // rather than a decode error. Wrong values are as indistinguishable from + // right ones as silence was from an idle topic -- and worse, they are + // trusted. So refuse, exactly as the publish path refuses a disk-resolved + // type that conflicts with the live one. + // Compare TypeHash VALUES, never their rendered strings. + // + // `compute_type_hash` yields a `hiroz_schema::TypeHash`, a bare [u8; 32]. + // `ti.hash` is a `hiroz_protocol::TypeHash`, a version plus a value. The + // original comparison bridged that gap with `to_rihs_string()` on both + // sides. That is what broke it. The protocol renderer is gated on + // `no-type-hash`, and it then returns one constant for every value, so both + // sides render identically and the guard passes on anything. + // + // No build enables that feature for this crate. hiroz-union always compiles + // against hiroz's default jazzy, its own `humble` feature was empty, and + // the humble CI legs build hiroz-tests, which does not depend on this crate. + // So the break was latent, not shipped. It is still the wrong comparison, + // and a cfg this crate does not control is a poor thing to rest on. + // + // Convert instead, as `hiroz::dynamic::type_info::schema_hash` does + // internally: through the RIHS01 string, whose schema-side renderer and + // protocol-side parser are both ungated. Then compare values. + // + // "No hash advertised" is a third state, not a mismatch. It cannot be + // verified either way, so say so and continue rather than refusing with + // advice no .msg on earth can satisfy. + let local_schema_hash = schema + .compute_type_hash() + .map_err(|e| format!("could not hash the local .msg for {canonical}: {e}"))?; + let local = hiroz::TypeHash::from_rihs_string(&local_schema_hash.to_rihs_string()) + .unwrap_or_else(hiroz::TypeHash::zero); + + match check_type_hash(&local, &ti.hash) { + // A publisher that advertises no hash -- a Humble node, or any peer + // built without type hashing. "Not advertised" is a third state, not a + // mismatch: it cannot be verified either way. Refusing here told the + // user to point HIROZ_MSG_PATH at definitions hashing to zero, which + // no .msg does, and made every cross-distro case fail -- the headline + // case for having a disk fallback at all. + HashCheck::NotAdvertised => tracing::warn!( + "{topic} advertises no type hash, so the local .msg for {canonical} \ + cannot be verified against it; decoding with the local definition" + ), + HashCheck::Mismatch => { + return Err(format!( + "local .msg for {canonical} hashes to {} but {topic} advertises \ + {}; refusing to decode with a mismatched schema -- point \ + HIROZ_MSG_PATH at the message definitions the publisher was built from", + local_schema_hash.to_rihs_string(), + // Render both sides through an ungated formatter. `ti.hash` is a + // `hiroz_protocol::TypeHash`, whose `to_rihs_string` is + // `#[cfg(feature = "no-type-hash")]`-gated: under that feature it + // returns the constant "TypeHashNotSupported" for every value, so + // this message would tell the reader the publisher advertises + // nothing while we refuse *because* it advertises something else. + rihs_ungated(&ti.hash) + )); + } + HashCheck::Match => {} + } + + // Order matters: `with_type_info` assigns unconditionally, so it must follow + // `create_dyn_sub` (which recomputes the hash from the local .msg). With the + // hashes now proven equal this is belt-and-braces, and it keeps the key + // byte-identical to what `create_dyn_sub_auto` would have declared. + let sub = node + .create_dyn_sub(topic, schema) + .with_type_info(ti) + .build() + .map_err(|e| { + format!("declaring a dynamic subscriber for {topic} ({canonical}) failed: {e}") + })?; + + tracing::info!("resolved schema for {topic} from a local .msg ({canonical})"); + Ok(sub) +} + impl PluginState { - /// The message type advertised by a live publisher or subscriber on `topic`, - /// if any. Used both to build the concrete publish key (`resolve_topic_ke`) - /// and to reject a disk-resolved type that conflicts with what the topic - /// actually carries (`encode_yaml_to_cdr`). + /// See [`live_topic_type_info`]. Used both to build the concrete publish key + /// (`resolve_topic_ke`) and to reject a disk-resolved type that conflicts + /// with what the topic actually carries (`encode_yaml_to_cdr`). fn live_topic_type_info(&self, topic: &str) -> Option { - use hiroz_protocol::{EndpointKind, Entity}; - [EndpointKind::Publisher, EndpointKind::Subscription] - .into_iter() - .find_map(|kind| { - self.engine - .graph - .get_entities_by_topic(kind, topic) - .first() - .and_then(|ent| match ent.as_ref() { - Entity::Endpoint(ep) => ep.type_info.clone(), - _ => None, - }) - }) + live_topic_type_info(&self.engine.graph, topic) } } @@ -60,26 +215,69 @@ impl hu::plugin::ros::Host for PluginState { topic: String, ) -> Result, PluginError> { self.require_perm(hu::plugin::types::Permission::SubscribeTopic)?; - let rep = self.alloc_rep(); - let (tx, rx) = flume::bounded::(256); + // Resolve the schema *before* returning a Subscription. Doing it inside + // the spawned task instead made every failure structurally invisible: + // `subscribe` had already returned Ok, so the plugin's own "Failed to + // subscribe" branch could never fire, and the dropped channel sender + // read as a permanently idle topic (`try_recv` -> None forever). That is + // why `hu meter echo` printed nothing and `hu meter delay` never exited. + // + // Blocking here is the same trade `encode_yaml_to_cdr` and + // `HostServiceClient::call` already make: `block_in_place` hands the + // worker off to the blocking pool, so the zenoh I/O and graph liveliness + // traffic this discovery *depends on* keep progressing while we wait. + // + // The `HostBlockGuard` is what makes that trade safe here. The guest is + // dispatched under a ~3 s epoch budget measured in wall clock, so a + // multi-second wait would trap the guest on return -- before it could run + // the very error branch this function exists to give it. The guard stops + // the epoch ticker for the duration of the wait. Without it, the failure + // path below is unobservable: the plugin dies mid-dispatch, `exit_code` + // stays `None`, and the command hangs instead of reporting. + const SUB_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5); let node = self.engine.node.clone(); - let topic_clone = topic.clone(); + let discovered = { + let _epoch = super::super::HostBlockGuard::enter(); + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(node.create_dyn_sub_auto(&topic, SUB_DISCOVERY_TIMEOUT)) + }) + }; + let sub = match discovered { + Ok(sub) => sub, + Err(e) => { + // Discovery is authoritative when it answers, but a node built + // without `.with_type_description_service()` answers nothing -- + // so fall back to the same on-disk `.msg` lookup the publish + // path uses (`encode_yaml_to_cdr`). The type *name* still comes + // from the graph; only the schema body comes from disk. + tracing::debug!("WASM plugin: schema discovery failed for {topic}: {e}"); + match dyn_sub_from_local_msg(&node, &self.engine.graph, &topic) { + Ok(sub) => sub, + // Both reasons are reported: which one matters depends on + // whether the user expected discovery or the disk to answer. + Err(fallback) => { + return Err(PluginError::Transport(format!( + "no schema for {topic}: discovery failed ({e}); {fallback}" + ))); + } + } + } + }; + + // Mint the resource only once the subscription really exists, so a + // failed subscribe doesn't burn a rep. + let rep = self.alloc_rep(); + let (tx, rx) = flume::bounded::(256); + let topic_for_log = topic.clone(); let handle = tokio::spawn(async move { - let sub = match node - .create_dyn_sub_auto(&topic_clone, Duration::from_secs(5)) - .await - { - Ok(s) => s, - Err(e) => { - tracing::warn!( - "WASM plugin: schema discovery failed for {}: {e}", - topic_clone - ); - return; - } - }; + // A stream that decodes to nothing is indistinguishable from an idle + // one, which is the same silence this whole path had. Report the + // first failure loudly and the rest at debug, so a fast topic whose + // schema drifted announces itself without flooding stderr. + let mut decode_errors: u64 = 0; loop { match sub.try_recv() { Some(Ok(msg)) => { @@ -88,7 +286,22 @@ impl hu::plugin::ros::Host for PluginState { break; } } - Some(Err(_)) => {} + Some(Err(e)) => { + decode_errors += 1; + if decode_errors == 1 { + tracing::warn!( + topic = %topic_for_log, + "WASM plugin: message decode failed: {e} \ + (further errors logged at debug)" + ); + } else { + tracing::debug!( + topic = %topic_for_log, + count = decode_errors, + "WASM plugin: message decode failed: {e}" + ); + } + } None => { tokio::time::sleep(Duration::from_millis(5)).await; } @@ -190,11 +403,19 @@ impl hu::plugin::ros::Host for PluginState { // Budget discovery independently; a slow/failed round-trip // shouldn't look like a generic encode failure. const DISCOVERY_TIMEOUT: Duration = Duration::from_millis(2000); - let discovered = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on( - node.discover_topic_schema_including_subscribers(&topic, DISCOVERY_TIMEOUT), - ) - }) + let discovered = { + // See `subscribe`: wall-clock waits are charged to the + // guest's epoch budget unless the ticker is suspended. + let _epoch = super::super::HostBlockGuard::enter(); + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on( + node.discover_topic_schema_including_subscribers( + &topic, + DISCOVERY_TIMEOUT, + ), + ) + }) + } .map_err(|_| PluginError::NotFound)?; if discovered.schema.type_name != type_name { return Err(PluginError::Invalid(format!( @@ -277,14 +498,18 @@ impl hu::plugin::ros::HostServiceClient for PluginState { // slow/failed discovery round-trip (two get_type_description queries) // can't consume the entire per-call timeout budget. const DISCOVERY_TIMEOUT: Duration = Duration::from_millis(2000); - let (req_schema, resp_schema) = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(node.discover_service_schema( - &service_name, - &req_type, - &resp_type, - DISCOVERY_TIMEOUT, - )) - }) + let (req_schema, resp_schema) = { + // See `subscribe`: suspend the epoch ticker across the wait. + let _epoch = super::super::HostBlockGuard::enter(); + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(node.discover_service_schema( + &service_name, + &req_type, + &resp_type, + DISCOVERY_TIMEOUT, + )) + }) + } .map_err(|_| PluginError::NotFound)?; let req_value = parse_yaml_or_json(&request_json).map_err(PluginError::Invalid)?; @@ -309,7 +534,13 @@ impl hu::plugin::ros::HostServiceClient for PluginState { .wait() .map_err(|e| e.to_string())?; - let reply = replies.recv().map_err(|_| PluginError::Timeout)?; + let reply = { + // The caller's own --timeout can exceed the guest's epoch budget, so + // suspend the ticker across the wait (see `subscribe`). + let _epoch = super::super::HostBlockGuard::enter(); + replies.recv() + } + .map_err(|_| PluginError::Timeout)?; let sample = reply.result().map_err(|e| e.to_string())?; let resp_cdr = sample.payload().to_bytes().into_owned(); @@ -356,7 +587,13 @@ impl hu::plugin::ros::HostServiceClient for PluginState { .wait() .map_err(|e| e.to_string())?; - let reply = replies.recv().map_err(|_| PluginError::Timeout)?; + let reply = { + // The caller's own --timeout can exceed the guest's epoch budget, so + // suspend the ticker across the wait (see `subscribe`). + let _epoch = super::super::HostBlockGuard::enter(); + replies.recv() + } + .map_err(|_| PluginError::Timeout)?; let sample = reply.result().map_err(|e| e.to_string())?; Ok(sample.payload().to_bytes().into_owned()) } @@ -482,6 +719,33 @@ fn json_to_dynamic_value( } } +#[cfg(test)] +mod graph_type_name_tests { + use hiroz::dynamic::ros_type_name_from_dds; + + // `dyn_sub_from_local_msg` feeds a graph-reported (DDS-mangled) type name + // straight into `load_schema`, which only accepts the canonical form. Pin the + // conversion at the crate boundary -- a regression here shows up as "no .msg + // found" rather than as a type error. + #[test] + fn graph_type_names_normalize_for_schema_lookup() { + assert_eq!( + ros_type_name_from_dds("std_msgs::msg::dds_::String_"), + "std_msgs/msg/String" + ); + // Some publishers report the un-`dds_`-qualified form. + assert_eq!( + ros_type_name_from_dds("rcl_interfaces::msg::ParameterEvent_"), + "rcl_interfaces/msg/ParameterEvent" + ); + // Already-canonical names must survive unchanged. + assert_eq!( + ros_type_name_from_dds("std_msgs/msg/String"), + "std_msgs/msg/String" + ); + } +} + #[cfg(test)] mod service_type_name_tests { use super::service_request_response_type_names; @@ -532,3 +796,73 @@ mod service_type_name_tests { ); } } + +#[cfg(test)] +mod type_hash_guard_tests { + use super::{HashCheck, check_type_hash, rihs_ungated}; + use hiroz::TypeHash; + + fn hash(byte: u8) -> TypeHash { + TypeHash::new(1, [byte; 32]) + } + + #[test] + fn equal_hashes_match() { + assert_eq!(check_type_hash(&hash(0xab), &hash(0xab)), HashCheck::Match); + } + + #[test] + fn different_hashes_mismatch() { + assert_eq!( + check_type_hash(&hash(0xab), &hash(0xcd)), + HashCheck::Mismatch + ); + } + + // The case the guard got wrong in the other direction: a peer that + // advertises nothing is unverifiable, not mismatched. Refusing it made every + // cross-distro subscribe fail, which is the headline case for a disk + // fallback existing at all. + #[test] + fn absent_advertised_hash_is_not_a_mismatch() { + assert_eq!( + check_type_hash(&hash(0xab), &TypeHash::zero()), + HashCheck::NotAdvertised + ); + } + + // A local .msg cannot hash to zero, but pin the precedence anyway: the + // "not advertised" arm is checked first, so two zeros are not a match. + #[test] + fn absent_beats_equality_when_both_are_zero() { + assert_eq!( + check_type_hash(&TypeHash::zero(), &TypeHash::zero()), + HashCheck::NotAdvertised + ); + } + + // The reason the comparison is by value and not by rendered string. Under + // `no-type-hash` the protocol renderer returns one constant for every hash, + // so any string comparison passes on anything. This asserts the property + // that made string comparison wrong, and it holds on every build. + #[test] + fn distinct_hashes_stay_distinct_by_value() { + let a = hash(0x01); + let b = hash(0x02); + assert_ne!(a, b); + assert_eq!(check_type_hash(&a, &b), HashCheck::Mismatch); + } + + // The diagnostic must show the advertised bytes. `to_rihs_string` is gated + // and would print "TypeHashNotSupported" under `no-type-hash`, telling the + // reader the publisher advertised nothing while we refuse because it + // advertised something else. + #[test] + fn ungated_renderer_shows_the_bytes() { + assert_eq!( + rihs_ungated(&hash(0xab)), + format!("RIHS01_{}", "ab".repeat(32)) + ); + assert_ne!(rihs_ungated(&hash(0xab)), "TypeHashNotSupported"); + } +} From e5a8389d7b3db7d4d432063e84ca21db04d6ec2c Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Thu, 20 Aug 2026 16:53:26 +0800 Subject: [PATCH 04/21] feat(release): publish the plugins a release needs to be usable No release has ever contained a `.wasm`. The workflow built the `hu` binary only, and the plugins were built by the test job, exercised, and discarded. A user who downloaded a release got `No WASM plugins found`, and every `hu meter` and `hu monitor` command failed to dispatch. RELEASING.md claimed otherwise. `scripts/build-hu-release.nu` is now the single packaging step: it produces the binary tarballs, both plugins, an offline plugins tarball, a machine-readable index and a SHA256SUMS over the set, and it refuses to build when the tag and the crate version disagree. A tag is three different strings -- the release identity, the version the filenames carry, and the version the binary reports. They coincide for a normal release and diverge for a pre-release, which is why a pre-release is the only tag shape that can catch this class of defect. test-release-version-semantics.sh pins all three, and it needs no runner, no network and no build. The release is created as a draft, promoted only once an install from the published assets succeeds, and returned to draft if that verification fails. --- .github/workflows/release.yml | 475 +++++++++++++++++++++- RELEASING.md | 25 +- scripts/build-hu-release.nu | 339 +++++++++++++++ scripts/test-release-version-semantics.sh | 323 +++++++++++++++ scripts/test-release-workflow.nu | 21 +- 5 files changed, 1161 insertions(+), 22 deletions(-) create mode 100755 scripts/build-hu-release.nu create mode 100755 scripts/test-release-version-semantics.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 96e53a844..662dd3fce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,21 +129,30 @@ jobs: matrix: include: # hu (hiroz-union) — distro-agnostic, one binary per platform + # `web-plugins` is deliberate: docs/tools/hu.md documents `hu web`, + # and a default-feature build accepts the subcommand then refuses it + # at run time. It implies wasm-plugins, so nothing is lost. - bin: hu package: hiroz-union - features: "" + features: "web-plugins" artifact: bin-hu-x86_64-linux target: x86_64-unknown-linux-gnu os: ubuntu-latest + # `web-plugins` is deliberate: docs/tools/hu.md documents `hu web`, + # and a default-feature build accepts the subcommand then refuses it + # at run time. It implies wasm-plugins, so nothing is lost. - bin: hu package: hiroz-union - features: "" + features: "web-plugins" artifact: bin-hu-aarch64-linux target: aarch64-unknown-linux-gnu os: ubuntu-latest + # `web-plugins` is deliberate: docs/tools/hu.md documents `hu web`, + # and a default-feature build accepts the subcommand then refuses it + # at run time. It implies wasm-plugins, so nothing is lost. - bin: hu package: hiroz-union - features: "" + features: "web-plugins" artifact: bin-hu-aarch64-macos target: aarch64-apple-darwin os: macos-latest @@ -153,6 +162,10 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable + with: + # wasm32-wasip2 builds the hu plugins. They are platform-independent, + # so only the primary leg (below) actually packages them. + targets: wasm32-wasip2 - name: Install Rust target (Linux cross-compile only) if: matrix.target == 'aarch64-unknown-linux-gnu' @@ -187,12 +200,50 @@ jobs: ${{ matrix.features != '' && format('--features {0} --no-default-features', matrix.features) || '' }} \ --target ${{ matrix.target }} + - name: Install nushell + uses: hustcer/setup-nu@v3 + with: + version: "0.113.1" + + # HU_VERSION identifies the RELEASE (`0.2.0-rc1`); HU_CORE identifies the + # ASSETS (`0.2.0`). They differ only for a pre-release, because + # build-hu-release.nu names every file for the crate version — an rc ships + # the same crate as the release it rehearses. Using one string for both is + # what broke the first pre-release on the other channel. + - name: Derive version from tag + shell: bash + run: | + set -eu + V="${GITHUB_REF_NAME#v}" + echo "HU_VERSION=$V" >> "$GITHUB_ENV" + echo "HU_CORE=${V%%-*}" >> "$GITHUB_ENV" + echo ">>> tag=$GITHUB_REF_NAME version=$V core=${V%%-*}" + - name: Package binary shell: bash run: | - mkdir -p bin-dist - cp "target/${{ matrix.target }}/release/${{ matrix.bin }}" \ - "bin-dist/${{ matrix.artifact }}" + # Package through the shared script, NOT an ad-hoc `cp`. The tarball + # name and contents are the contract `install-hu.sh` reads, so if + # this channel rolls its own the two release channels drift and a + # GitHub release stops being installable — which is exactly what + # happened while this step was a bare `cp` of the ELF. + # + # --binary-from because the cross legs build with cargo zigbuild + # above; the script packages what they produced rather than + # rebuilding. --no-sums because SHA256SUMS is assembled once, in the + # release job, over every asset from every leg. + # + # --version arms the tag-vs-crate guard (build-hu-release.nu:69-77), + # so a tag that disagrees with the crate fails here instead of + # shipping a tarball whose name lies about its contents. The guard + # compares only the CORE version, so an `-rc` suffix is legal. + nu scripts/build-hu-release.nu \ + --binary-only \ + --version "$HU_VERSION" \ + --binary-from "target/${{ matrix.target }}/release/${{ matrix.bin }}" \ + --target "${{ matrix.target }}" \ + --no-sums \ + --out bin-dist - name: Upload artifact uses: actions/upload-artifact@v4 @@ -200,6 +251,87 @@ jobs: name: ${{ matrix.artifact }} path: bin-dist/* + build-hu-plugins: + name: Build hu WASM plugins + runs-on: ubuntu-latest + # `hu meter` and `hu monitor` are NOT in the hu binary — they are WASM + # components. Without this job the release ships a hu that cannot run any + # documented `hu meter`/`hu monitor` command. + # + # wasm32-wasip2 output is platform-independent, so this runs on exactly one + # leg. Building it in the per-target matrix would have three legs racing to + # upload the same asset name. + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-wasip2 + + - name: Install nushell + uses: hustcer/setup-nu@v3 + with: + version: "0.113.1" + + # HU_VERSION identifies the RELEASE (`0.2.0-rc1`); HU_CORE identifies the + # ASSETS (`0.2.0`). build-hu-release.nu names every file for the crate + # version, so a pre-release tag produces core-named assets by design. + # Deriving one string and using it for both is what made the first + # pre-release on the other channel build cleanly and then fail verifying + # filenames that by design never exist. + - name: Derive version from tag + shell: bash + run: | + set -eu + V="${GITHUB_REF_NAME#v}" + echo "HU_VERSION=$V" >> "$GITHUB_ENV" + echo "HU_CORE=${V%%-*}" >> "$GITHUB_ENV" + echo ">>> tag=$GITHUB_REF_NAME version=$V core=${V%%-*}" + + # build-hu-release.nu validates each .wasm by loading it as a component + # through `hu plugin validate`, which needs a HOST-NATIVE hu. This leg is + # x86_64 Linux, so it can build one — and it must, because this is the + # only job that publishes the .wasm files. Without it the channel that + # ships the plugins never once compiles them as components. + # + # Default features are enough (`wasm-plugins` provides `plugin validate`) + # and this binary is never packaged: the released hu comes from the + # build-binaries matrix. + - name: Build a host-native hu for plugin validation + shell: bash + run: cargo build --release --bin hu --package hiroz-union + + - name: Build plugins and index + shell: bash + run: | + # The single packaging script every release platform calls, so they + # cannot drift in what they ship. + # --version arms the tag-vs-crate guard (build-hu-release.nu:69-77); + # it compares only the CORE version, so `-rc` is legal. + # --no-sums: SHA256SUMS is assembled once in the release job over + # every asset. A per-job file here would cover only the plugins and + # verify clean while saying nothing about the binary. + nu scripts/build-hu-release.nu --plugins-only --version "$HU_VERSION" --no-sums --out hu-dist + + - name: Verify the plugin artifact set + shell: bash + run: | + set -e + # HU_CORE, not HU_VERSION: the assets are named for the crate. + for f in "hu_meter-$HU_CORE.wasm" \ + "hu_monitor-$HU_CORE.wasm" \ + "hu-plugins-$HU_CORE.tar.gz" \ + "hu-plugins-$HU_CORE.json"; do + test -s "hu-dist/$f" || { echo "missing or empty: hu-dist/$f"; exit 1; } + done + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: bin-hu-plugins + path: hu-dist/* + build-go-libs: name: Build libhiroz (${{ matrix.distro }}, ${{ matrix.target }}) runs-on: ${{ matrix.os }} @@ -322,7 +454,7 @@ jobs: smoke-test-binaries: name: Smoke test binaries - needs: [build-binaries] + needs: [build-binaries, build-hu-plugins] runs-on: ubuntu-latest steps: - name: Download hu binary (x86_64) @@ -331,10 +463,63 @@ jobs: name: bin-hu-x86_64-linux path: dist/ - - name: Run --help + - name: Download hu plugins + uses: actions/download-artifact@v4 + with: + name: bin-hu-plugins + path: dist/ + + - uses: actions/checkout@v4 + + # Assemble the same SHA256SUMS the release job will, so this test + # installs from artifacts shaped exactly like the published ones. + - name: Assemble SHA256SUMS + shell: bash + run: | + set -e + cd dist + files=$(find . -maxdepth 1 -type f -printf '%f\n' | sort) + printf '%s\n' "$files" | xargs sha256sum > SHA256SUMS + cat SHA256SUMS + # Self-check: the file must verify cleanly, including not listing + # itself. + sha256sum -c SHA256SUMS + + # The real question is not "does the binary start" but "can a user who + # downloaded this release install it the documented way and then run + # `hu meter`". So this uses the actual installer rather than hand-placing + # files: it is the only thing that exercises the artifact *shape*, which + # is where this channel was broken — it shipped a bare ELF while the + # installer expects a tarball. + - name: Install from the release artifacts, as a user would + shell: bash + run: | + set -e + HUHOME="$RUNNER_TEMP/huhome" + mkdir -p "$HUHOME" + HOME="$HUHOME" HU_PREFIX="$HUHOME/.local" sh scripts/install-hu.sh --offline dist + unset HU_PLUGIN_PATH + HOME="$HUHOME" "$HUHOME/.local/bin/hu" --version + out="$(HOME="$HUHOME" "$HUHOME/.local/bin/hu" plugin list)" + echo "$out" + echo "$out" | grep -q meter || { echo "FAIL: meter not discovered"; exit 1; } + echo "$out" | grep -q monitor || { echo "FAIL: monitor not discovered"; exit 1; } + + # Prove the refusal fires. A checksum check that has never rejected + # anything is unverified, not safe. + - name: A corrupted asset must be refused + shell: bash run: | - chmod +x dist/bin-hu-x86_64-linux - dist/bin-hu-x86_64-linux --help + set -e + printf 'X' | dd of=dist/hu-*-x86_64-unknown-linux-gnu.tar.gz \ + bs=1 seek=100 conv=notrunc status=none + HUHOME="$RUNNER_TEMP/huhome-bad" + mkdir -p "$HUHOME" + if HOME="$HUHOME" HU_PREFIX="$HUHOME/.local" sh scripts/install-hu.sh --offline dist; then + echo "FAIL: installer accepted a corrupted tarball"; exit 1 + fi + test ! -e "$HUHOME/.local/bin/hu" || { echo "FAIL: installed despite refusing"; exit 1; } + echo "ok — corrupted asset refused, nothing installed" smoke-test-go: name: Smoke test Go library @@ -415,16 +600,119 @@ jobs: path: dist/ merge-multiple: true - - name: Create GitHub Release + # docs/tools/hu-install.md's first instruction is + # curl -fsSL /install-hu.sh | HU_VERSION=… sh + # and until this step existed that URL 404'd on every release ever cut: + # build-hu-release.nu writes six assets and the installer is not one of + # them, so nothing ever put install-hu.sh where the docs point. The + # bootstrap script is an asset like any other and belongs in dist/. + # + # It is staged HERE, before SHA256SUMS is assembled, so the checksum file + # covers it without a special case — the assembling step lists whatever + # is in dist/ at the time it runs. Staging it afterwards would publish an + # unlisted asset, which is the F2 shape: `sha256sum -c` passes while + # saying nothing about the file a user actually pipes into a shell. + - name: Stage the installer as a release asset + shell: bash + run: | + set -e + test -f scripts/install-hu.sh || { + echo "FAIL: scripts/install-hu.sh missing from the checkout"; exit 1; } + # Parse-only. A syntactically broken installer is worse than an + # absent one: `curl … | sh` executes it up to the parse error, so a + # partial run can leave a half-installed prefix behind. This costs + # nothing and cannot pass a file that would not parse on the reader's + # machine. + sh -n scripts/install-hu.sh + mkdir -p dist + cp scripts/install-hu.sh dist/install-hu.sh + chmod 0755 dist/install-hu.sh + echo "staged dist/install-hu.sh ($(wc -c < dist/install-hu.sh) bytes)" + + # One checksum file over every asset, generated here rather than per + # job. Per-job files were the bug: the plugins job emitted a SHA256SUMS + # covering only the plugins, so `sha256sum -c` passed while never + # checking the binary, and install-hu.sh refused the binary as unlisted. + - name: Assemble SHA256SUMS over the complete asset set + shell: bash + run: | + set -e + cd dist + rm -f SHA256SUMS + # Bare basenames, sorted: the format install-hu.sh and + # `sha256sum -c` both expect. + # + # Capture the file list BEFORE creating any output file. A shell + # redirect creates its target before the command on the left runs, + # so `find ... > SHA256SUMS` lists SHA256SUMS itself, with the hash + # of the empty file — and `sha256sum -c` then reports + # "SHA256SUMS: FAILED" on an otherwise perfect release. Redirecting + # to a temp name instead just moves the bug to the temp name. + files=$(find . -maxdepth 1 -type f -printf '%f\n' | sort) + printf '%s\n' "$files" | xargs sha256sum > SHA256SUMS + echo "covered $(wc -l < SHA256SUMS) assets:" + cat SHA256SUMS + # A release with no hu tarball listed is the defect this replaces — + # fail loudly rather than publishing it again. + grep -q 'hu-.*\.tar\.gz' SHA256SUMS + # Likewise for the installer: the docs tell readers to pipe it into a + # shell, so it must be published AND covered. An unlisted installer + # would still be downloadable and would still be unverifiable. + grep -q ' install-hu\.sh$' SHA256SUMS \ + || { echo "FAIL: install-hu.sh is not covered by SHA256SUMS"; exit 1; } + + # `prerelease` is derived from the tag shape, mirroring + # Without this an rc tag publishes as a + # full release and takes the "Latest" badge from the real one — and since + # `push: tags: v*` is this workflow's only trigger, an rc tag is the ONLY + # way to rehearse it, so the rehearsal would mislabel the product. + # Created as a DRAFT, then promoted by `publish-release` once the + # artifacts are on it, and withdrawn again by `withdraw-release` if the + # post-publish checks fail. Previously this published immediately and + # `smoke-test-release-install` ran afterwards, so a failed verification + # left a public release wearing the "Latest" badge with nothing to + # de-list it. + # + # The draft cannot be verified before promotion: GitHub does not serve + # draft assets from `browser_download_url` at all -- not anonymously and + # not with a token, only the metadata is readable. So "verify, then + # publish" is not available for a download test, and the honest shape is + # "publish, verify, withdraw on failure". That narrows the exposure from + # permanent to the length of the smoke test rather than removing it. + - name: Create GitHub Release (draft) uses: softprops/action-gh-release@v2 with: + draft: true name: ${{ github.ref_name }} body_path: CHANGELOG.md files: dist/** + # Semver: everything after the first `-` IS the pre-release + # identifier, so a hyphen is the whole test. Enumerating rc/alpha/beta + # is both longer and narrower -- it would publish `v0.2.0-smoke-test` + # as a full release and hand it the Latest badge. + prerelease: ${{ contains(github.ref_name, '-') }} + + # Promote the draft so the public download path exists. Everything below + # installs from that URL anonymously, which is the only way to exercise URL + # construction -- the offline path never builds one, which is how an + # installer that fetched a path the host did not serve reached a release. + publish-release: + name: Publish the draft release + needs: [release] + runs-on: ubuntu-latest + steps: + - name: Promote + env: + GH_TOKEN: ${{ github.token }} + run: | + set -e + gh release edit "${{ github.ref_name }}" \ + --repo "${{ github.repository }}" --draft=false + echo "published ${{ github.ref_name }}" smoke-test-release-install: name: Smoke test install from release URL - needs: [release] + needs: [publish-release] runs-on: ubuntu-latest steps: - name: Set up Python @@ -450,9 +738,172 @@ jobs: - name: Import test run: .venv/bin/python -c "import hiroz_py; print('hiroz_py install-from-release ok')" + - uses: actions/checkout@v4 + + # Install `hu` from the live release URLs, the way a reader of + # docs/tools/hu-install.md does. This is the only test that exercises + # URL construction — the installer once built asset paths this host does + # not serve, and no offline test could catch that because the offline + # path never builds a URL. + # + # No credential: this repo is public, and requiring one here would hide + # a regression where the installer starts demanding a token it does not + # need. + - name: Install hu from the published release + run: | + set -e + TAG="${{ github.ref_name }}" + # VER is the RELEASE identity and belongs in the download path and in + # `--version`, which install-hu.sh itself splits into a core version + # for the filenames. CORE is what the BINARY reports, because it is + # built from the crate: an rc tag v0.2.0-rc1 produces a hu that prints + # `hu 0.2.0`. Asserting on VER here fails every pre-release. + VER="${TAG#v}" + CORE="${VER%%-*}" + HUHOME="$RUNNER_TEMP/hu-from-release" + mkdir -p "$HUHOME" + # GitHub serves release assets from a stable path shape, + # so only the base directory differs. That is exactly what + # HU_RELEASE_BASE overrides. + export HU_RELEASE_BASE="https://github.com/${{ github.repository }}/releases/download/$TAG" + # Run the command docs/tools/hu-install.md actually tells a reader to + # run -- the installer piped from the release into a shell -- not an + # equivalent-looking `sh scripts/install-hu.sh` against the repo copy. + # Two things only this form exercises: reading the script from stdin, + # and passing HU_RELEASE_BASE/HU_VERSION through the pipeline into it. + # The published-vs-source diff below proves the asset is right; it + # cannot prove the documented invocation works. + # Download-then-run, exactly as docs/tools/hu-install.md instructs. + # `&&` propagates curl's status, so a 404 stops here. Piping instead + # would not: a pipeline's status is its last command's, so `sh` would + # read empty stdin, exit 0, and pass this step having installed + # nothing. Measured both ways -- rc=22 with the `&&`, rc=0 with a pipe. + cd "$RUNNER_TEMP" + env -u HU_RELEASE_TOKEN HOME="$HUHOME" HU_PREFIX="$HUHOME/.local" \ + bash -c "curl -fsSL '$HU_RELEASE_BASE/install-hu.sh' -o install-hu.sh \ + && HU_RELEASE_BASE='$HU_RELEASE_BASE' HU_VERSION='$VER' sh install-hu.sh" + cd "$GITHUB_WORKSPACE" + unset HU_PLUGIN_PATH || true + got=$(HOME="$HUHOME" "$HUHOME/.local/bin/hu" --version) + echo "installed: $got" + test "$got" = "hu $CORE" || { echo "FAIL: expected 'hu $CORE', got '$got'"; exit 1; } + out=$(HOME="$HUHOME" "$HUHOME/.local/bin/hu" plugin list) + echo "$out" + echo "$out" | grep -q meter || { echo "FAIL: meter missing"; exit 1; } + echo "$out" | grep -q monitor || { echo "FAIL: monitor missing"; exit 1; } + echo "HUHOME=$HUHOME" >> "$GITHUB_ENV" + echo "HU_RELEASE_BASE=$HU_RELEASE_BASE" >> "$GITHUB_ENV" + + # The docs' first instruction is `curl -fsSL /install-hu.sh | sh`. + # Publishing the file is one claim; the URL resolving is another, and + # only fetching it from the live release tests the second. Every release + # before this one served a 404 here. + - name: The documented installer URL must serve the installer + run: | + set -e + curl -fsSL "$HU_RELEASE_BASE/install-hu.sh" -o fetched-install-hu.sh + # Parse it as the reader's shell would. `curl … | sh` gives no + # opportunity to inspect first, so a broken asset runs partially. + sh -n fetched-install-hu.sh + # And it must be the file this tag was cut from, not a stale asset + # carried over from an earlier release. + diff -u scripts/install-hu.sh fetched-install-hu.sh \ + || { echo "FAIL: published install-hu.sh differs from the tagged source"; exit 1; } + echo "ok — the documented one-liner URL serves this tag's installer" + + - name: Install nushell + uses: hustcer/setup-nu@v3 + with: + version: "0.113.1" + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + # `hu plugin list` above is NOT sufficient on its own, and it is worth + # being explicit about why: discovery derives a plugin's name from its + # FILENAME and never opens the component + # (crates/hiroz-union/src/plugin/wasm/mod.rs), so an empty file named + # hu_meter.wasm satisfies every grep above. Until this step existed that + # was the only check this channel made against what it had just + # published — a release could ship an unloadable plugin and go green. + # The suite has already run against a published + # release; GitHub did not, so the two channels disagreed on what + # "released" proves. + # + # The publisher is built from the SOURCE CHECKOUT, deliberately, not + # taken from the artifact: `hu` cannot generate its own traffic (F12 — + # `hu meter pub` needs a schema no release ships), so a suite whose only + # fixture is `hu router` measures an empty graph and degenerates into an + # exit-status check. That is the exact hole a truncated plugin slips + # through. + - name: Build the traffic fixture from source + run: cargo build --release --example z_pubsub -p hiroz + + - name: Reproduce the documented commands against the published release + run: | + set -eu + unset HU_PLUGIN_PATH || true + HOME="$HUHOME" "$HUHOME/.local/bin/hu" router > router.log 2>&1 & + ROUTER_PID=$! + sleep 5 + # A router that died on startup shows up as a dozen unrelated + # measurement failures, so assert on it directly. + kill -0 "$ROUTER_PID" 2>/dev/null || { + echo "FAIL: router died on startup"; tail -20 router.log; exit 1; } + # Exit status must not pass through a pipe, and the log must be + # printed whichever way this goes. + set +e + nu scripts/test-hu-docs-repro.nu \ + --home "$HUHOME" \ + --publisher "$PWD/target/release/examples/z_pubsub" \ + --require-traffic > repro.log 2>&1 + rc=$? + set -e + cat repro.log + kill "$ROUTER_PID" 2>/dev/null || true + test "$rc" -eq 0 || { + echo "FAIL: the published release does not reproduce its own docs"; exit 1; } + + # If the published release cannot install itself, or cannot reproduce its own + # documentation, put it back in the drawer. A draft is invisible to everyone + # without push access and keeps its assets, so the run can be diagnosed from + # exactly what shipped. The tag survives -- withdrawing a release does not + # delete it -- so the fix is a new tag rather than a rewritten one. + # + # This is the half of "draft first" that is actually reachable, given that + # GitHub will not serve draft assets for the download test above. + withdraw-release: + name: Withdraw the release if verification failed + needs: [publish-release, smoke-test-release-install] + # NOT `failure()`: that is true when ANY ancestor fails, and this job's + # ancestors reach back to build-binaries. A failed build skips release and + # publish-release, then this job would still run and try to withdraw a + # release that was never created -- going red with a caption implying a bad + # release is live. Withdraw only what publish-release actually published. + if: ${{ always() + && needs.publish-release.result == 'success' + && needs.smoke-test-release-install.result == 'failure' }} + runs-on: ubuntu-latest + steps: + - name: Return the release to draft + env: + GH_TOKEN: ${{ github.token }} + run: | + set -e + gh release edit "${{ github.ref_name }}" \ + --repo "${{ github.repository }}" --draft=true + echo "WITHDRAWN: ${{ github.ref_name }} is a draft again — it did not verify." + echo "The tag still exists. Fix, then cut a new tag; do not move this one." + publish-crates: name: Publish core crates to crates.io needs: [smoke-test-release-install] + # A pre-release tag rehearses the pipeline; it must not publish to + # crates.io. crates.io versions come from the crate manifests, not from the + # tag, so an rc tag would try to publish the SAME version the real release + # already published and end the run red on a rehearsal that otherwise + # passed. Skipping keeps the rc a clean rehearsal signal. + if: ${{ !contains(github.ref_name, '-') }} runs-on: ubuntu-latest permissions: contents: read diff --git a/RELEASING.md b/RELEASING.md index 4881215a6..6e415953d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -6,14 +6,20 @@ This document covers the full release process: local dry-run, CI smoke test, and Before releasing, bump the version in all three places consistently: -| File | Field | -|------|-------| -| `Cargo.toml` | `[workspace.package] version` | -| `crates/hiroz-msgs/python/pyproject.toml` | `version` | -| `crates/hiroz-py/pyproject.toml` | `version` | +| File | Field | Controls | +|------|-------|----------| +| `Cargo.toml` | `[workspace.package] version` | **every crate under `crates/`** — they all inherit it, so this one row governs the crates.io versions, every `hu` release asset name, and what `hu --version` prints | +| `crates/hiroz-msgs/python/pyproject.toml` | `version` | the `hiroz-msgs-py` wheel | +| `crates/hiroz-py/pyproject.toml` | `version` | the `hiroz-py` wheel | The `hiroz-py` wheel depends on `hiroz-msgs-py>=` — update that lower bound too when bumping. +**One version governs every Rust crate, and a check enforces it.** `hiroz`, `hiroz-protocol` and `hiroz-union` each used to carry a literal `version`, which meant `cargo publish --workspace` could leave a published crate behind at the old number while the tag said otherwise, and a `v0.2.0` tag could produce `hu` assets named `0.1.0`. They now inherit, and `scripts/test-release-version-semantics.sh` fails if any crate under `crates/` reintroduces a literal. + +`hu` keeps an independent release *cadence* through its own `hu-v*` tags — you can cut a `hu` release between workspace releases — but not an independent *number*. + +> **Do not bump the WIT world alongside the product version.** `hu:plugin@0.1.0` is the plugin **ABI contract**, not a product version, and the two move on different clocks. It lives in three places that must agree — `HOST_WIT_WORLD` in `crates/hiroz-union/src/plugin/install.rs`, the `WIT_WORLD` constant in `scripts/build-hu-release.nu`, and the `package` line of `crates/hiroz-union/wit/v0.1/hu-plugin.wit` — and `install.rs` compares it to a release index by **exact string equality**. Bump the string and `hu plugin install ` refuses every index still declaring the old world, with a message telling the user to upgrade `hu` — for a change that never happened. Rename the package in `hu-plugin.wit` as well and the breakage is real rather than cosmetic: plugins built against the old package no longer instantiate. Change it only when the interface in `hu-plugin.wit` changes incompatibly, and then change all three sites in the same commit. + ## Step 1 — Local dry-run (optional) Build the Python wheels locally to catch obvious issues before touching CI: @@ -39,7 +45,9 @@ Before tagging a real version, verify the entire CI release pipeline works end-t ./scripts/test-release-workflow.nu ``` -This pushes `v0.0.0-smoke-test`, waits for all CI jobs to pass (builds, smoke tests, release creation), then reports the result. The script requires `gh` CLI authenticated to the repo. +This pushes `v-smoke-test` — e.g. `v0.1.0-smoke-test` — waits for all CI jobs to pass (builds, smoke tests, release creation), then reports the result. The script requires `gh` CLI authenticated to the repo. + +The tag carries the current workspace version deliberately. `build-hu-release.nu` cross-checks a tag's core version against it and fails the build on a mismatch, so a fixed tag like `v0.0.0-smoke-test` dies at the first packaging step. The `-smoke-test` suffix makes it a semver pre-release, so it publishes as a pre-release and skips the crates.io step. ```bash # Push only — skip the polling wait @@ -52,10 +60,11 @@ This pushes `v0.0.0-smoke-test`, waits for all CI jobs to pass (builds, smoke te The CI pipeline exercises: - All wheel builds (jazzy + humble × x86_64 Linux, aarch64 Linux, aarch64 macOS) -- The `hu` binary build, plus the `hu-meter` / `hu-monitor` WASM plugins (`hu_meter.wasm` / `hu_monitor.wasm`, `wasm32-wasip2` target) +- The `hu` binary build (built with `--features web-plugins`, so the documented `hu web` subcommand works in the artifact users download) +- The `hu-meter` / `hu-monitor` WASM plugins (`hu_meter.wasm` / `hu_monitor.wasm`, `wasm32-wasip2`), plus `hu-plugins-.tar.gz` and the `hu-plugins-.json` index, built once in the `build-hu-plugins` job — the output is platform-independent, so it is not part of the per-target matrix - All Go library builds (`libhiroz` static + shared) - Python smoke test: install into venv, `import hiroz_py` -- Binary smoke test: `--help` + 3-second runtime check (no crash) +- Binary smoke test: `--help`, plus a clean-install check that unpacks the plugins into `~/.local/share/hu/plugins` with `HU_PLUGIN_PATH` unset and asserts `hu plugin list` finds `meter` and `monitor` - Go smoke test: CGO compilation against the downloaded `.a` - Install-from-release-URL test: `pip install` from the actual GitHub Release artifacts diff --git a/scripts/build-hu-release.nu b/scripts/build-hu-release.nu new file mode 100755 index 000000000..90502f15c --- /dev/null +++ b/scripts/build-hu-release.nu @@ -0,0 +1,339 @@ +#!/usr/bin/env nu +# Produce the `hu` release artifact set into a dist directory. +# +# This is the single source of truth for what a `hu` release contains. Both +# every release platform calls it, so they cannot drift +# cannot drift apart in what they ship. +# +# hu--.tar.gz hu binary + LICENSE + install README +# hu_meter-.wasm wasm32-wasip2 — platform-independent +# hu_monitor-.wasm +# hu-plugins-.tar.gz both plugins, for offline install +# hu-plugins-.json index that `hu plugin install ` resolves +# install-hu.sh the installer the release notes tell users to curl +# SHA256SUMS covers every file above +# +# The plugins are the point: without them `hu meter` and `hu monitor` do not +# exist, because they are not built into the binary. +# +# `install-hu.sh` ships for a duller but equally concrete reason: every set of +# release notes opens with `curl -fsSL /install-hu.sh | sh`, and until +# the script was staged here it was in the repo and nowhere else — so the +# documented first command of a release 404'd on both channels. + +const PLUGIN_DIR = "crates/hiroz-union/plugins" +const WIT_WORLD = "hu:plugin@0.1.0" +const INSTALLER = "scripts/install-hu.sh" + +# Everything that ships. `name` is the subcommand (the `hu_`/`hu-` prefix is +# stripped at discovery), `dir` the crate directory, `stem` the .wasm filename. +const PLUGINS = [ + [name, dir, stem, world, description]; + ["meter" "hu-meter" "hu_meter" "hu-cli-plugin" "Topic rate, bandwidth, echo, publish, latency, graph and parameter introspection"] + ["monitor" "hu-monitor" "hu_monitor" "hu-cli-plugin" "Live graph events, graph snapshots, /rosout tailing and logger levels"] +] + +# `hiroz-union` inherits the workspace version (`version.workspace = true`), so +# read the workspace, not the crate. Reading the crate file used to work because +# it carried a literal version -- which meant `hu` could silently drift from the +# rest of the workspace, and a `v0.2.0` tag against a `0.1.0` crate failed the +# whole release at packaging. One version now governs both. +# +# Note the crate file no longer contains a quoted version at all, so the old +# `split row '"' | get 1` on it raises rather than returning a wrong answer. +def crate-version [] { + open --raw Cargo.toml + | lines + | skip until { |l| ($l | str trim) == "[workspace.package]" } + | where { |l| ($l | str trim | str starts-with "version") } + | first + | split row "\"" + | get 1 +} + +def sha256-of [path: string] { + open --raw $path | hash sha256 +} + +# Honour CARGO_TARGET_DIR — CI often redirects it, and assuming ./target makes +# the script silently look for artifacts that were written elsewhere. +def target-root [] { + $env.CARGO_TARGET_DIR? | default "target" +} + +def main [ + --target: string = "" # rust target triple; empty = host + --out: string = "dist" # output directory + --version: string = "" # expected version; must match the crate + --plugins-only # skip the host binary (for non-primary legs) + --binary-only # skip the plugins (they ship from one leg only) + --binary-from: string = "" # package this prebuilt hu instead of running cargo + --no-validate # skip `hu plugin validate` (cross-compiled legs) + --no-sums # skip SHA256SUMS; the caller assembles one +] { + let ver = (crate-version) + + # A tag that disagrees with the crate would mislabel every asset. Fail + # loudly rather than shipping `hu-0.2.0-...tar.gz` containing 0.1.0. + # + # Only the CORE version has to match. A semver pre-release suffix + # (`0.1.0-rc1`) is legitimate: it names a rehearsal of 0.1.0, built from + # the 0.1.0 source, and the assets it produces are 0.1.0 assets. Requiring + # an exact match here is what previously left no way to exercise the + # release pipeline except by publishing a real release and deleting it + # afterwards — a workaround, not a test. + let core = ($version | split row "-" | first) + if $version != "" and $core != $ver { + print $"FAIL: requested version ($version) has core ($core), but the hiroz-union crate is ($ver)" + exit 1 + } + if $version != "" and $core != $version { + print $" pre-release ($version) — assets are named for the core version ($ver)" + } + + mkdir $out + print $"hu release ($ver) → ($out)/" + + if not $plugins_only { + build-binary $ver $target $out $binary_from + } + if not $binary_only { + build-plugins $ver $out (not $no_validate) + # Staged on the same leg as the plugins, and deliberately not on a + # `--binary-only` one. Both are release-wide, platform-independent + # assets: there is exactly one correct copy per release, and the GitHub + # channel runs `--binary-only` once per target triple (three legs) and + # `--plugins-only` once. Staging unconditionally would have three legs + # racing to upload the same `install-hu.sh` under the same asset name — + # the same reason the plugins themselves ship from one leg only. + stage-installer $out + } + + # A SHA256SUMS covering only part of a release is worse than none: it + # verifies clean while saying nothing about the assets it omits, and + # `install-hu.sh` treats an unlisted file as a refusal. So when a caller + # assembles a release from several jobs, it must pass --no-sums here and + # generate one file over the complete set. + if $no_sums { + print " (SHA256SUMS skipped — caller assembles it over the full asset set)" + } else { + write-sums $out + } + print "" + ls $out | select name size | print +} + +def build-binary [ver: string, target: string, out: string, binary_from: string] { + # `--binary-from` packages a binary someone else built. It exists because + # the cross legs need `cargo zigbuild`, not `cargo build`, and pushing that + # knowledge in here would mean this script had to model every leg's build. + # What actually has to be shared is the *packaging* — the tarball name and + # contents — because that is the contract `install-hu.sh` reads. Letting a + # leg build however it likes and package through here is what keeps the + # channels from drifting. + let bin = if $binary_from != "" { + if not ($binary_from | path exists) { + print $"FAIL: --binary-from ($binary_from) does not exist" + exit 1 + } + print $"packaging prebuilt hu from ($binary_from)" + $binary_from + } else { + # `web-plugins` is deliberate: docs/tools/hu.md documents `hu web`, and + # a default-feature build does not have that subcommand at all. + # Shipping the default build means shipping a binary that fails a + # documented command. + let target_args = (if $target == "" { [] } else { ["--target" $target] }) + print $"building hu \(--features web-plugins\) ($target)" + (^cargo build --release -p hiroz-union --bin hu --features web-plugins ...$target_args) + + let root = (target-root) + let bin_dir = (if $target == "" { $"($root)/release" } else { $"($root)/($target)/release" }) + $"($bin_dir)/hu" + } + + if not ($bin | path exists) { + print $"FAIL: expected binary at ($bin), not found" + exit 1 + } + + let triple = (if $target == "" { host-triple } else { $target }) + let stage = $"($out)/.stage-hu" + rm -rf $stage + mkdir $stage + cp $bin $"($stage)/hu" + cp LICENSE $"($stage)/LICENSE" + install-readme $ver | save --force $"($stage)/README-install.md" + + let tar = $"($out)/hu-($ver)-($triple).tar.gz" + ^tar -czf $tar -C $stage hu LICENSE README-install.md + rm -rf $stage + print $" → ($tar)" +} + +def host-triple [] { + ^rustc -vV | lines | where { |l| $l | str starts-with "host: " } | first | str replace "host: " "" +} + +def build-plugins [ver: string, out: string, validate: bool] { + print "building WASM plugins (wasm32-wasip2)" + for p in $PLUGINS { + (^cargo build --release --target wasm32-wasip2 + --manifest-path $"($PLUGIN_DIR)/($p.dir)/Cargo.toml") + } + + # The plugins are their own workspace, so their artifacts land under the + # plugins dir — unless CARGO_TARGET_DIR redirects everything to one root, + # which is what CI does. Accept either. + let wasm_dir = $"($PLUGIN_DIR)/target/wasm32-wasip2/release" + let alt_dir = $"(target-root)/wasm32-wasip2/release" + + mut entries = [] + let stage = $"($out)/.stage-plugins" + rm -rf $stage + mkdir $stage + + for p in $PLUGINS { + let src = ( + [$"($wasm_dir)/($p.stem).wasm" $"($alt_dir)/($p.stem).wasm"] + | where { |c| $c | path exists } + | first + ) + if ($src | is-empty) { + print $"FAIL: ($p.stem).wasm not found in ($wasm_dir) or ($alt_dir)" + exit 1 + } + + # A plugin that does not compile as a component must never ship. Use a + # host-native `hu` — on a cross leg the freshly built binary cannot run + # here, which is what --no-validate is for. + if $validate { + let hu = $"(target-root)/release/hu" + if ($hu | path exists) { + let r = (do { ^$hu plugin validate $src } | complete) + if $r.exit_code != 0 { + print $"FAIL: ($src) did not validate as a WASM component" + print $r.stderr + exit 1 + } + print $" validated ($p.stem)" + } else { + print $"FAIL: --no-validate not given but no host-native hu at ($hu)" + exit 1 + } + } + + let dest = $"($out)/($p.stem)-($ver).wasm" + cp $src $dest + cp $src $"($stage)/($p.stem).wasm" + print $" → ($dest)" + + $entries = ($entries | append { + name: $p.name + file: $"($p.stem)-($ver).wasm" + version: $ver + sha256: (sha256-of $dest) + world: $p.world + description: $p.description + }) + } + + let tar = $"($out)/hu-plugins-($ver).tar.gz" + ^tar -czf $tar -C $stage ...($PLUGINS | each { |p| $"($p.stem).wasm" }) + rm -rf $stage + print $" → ($tar)" + + let index = { + schema: 1 + hu_version: $ver + wit_world: $WIT_WORLD + plugins: $entries + } + $index | to json --indent 2 | save --force $"($out)/hu-plugins-($ver).json" + print $" → ($out)/hu-plugins-($ver).json" +} + +# Ship the installer itself as a release asset. +# +# It is copied, not generated, so what a user curls is byte-for-byte the script +# in the repo at the tagged commit — and `SHA256SUMS` (written afterwards over +# the whole directory) covers it like any other asset, so a caller passing +# --no-sums still gets it listed when it assembles the sums over the full set. +def stage-installer [out: string] { + if not ($INSTALLER | path exists) { + print $"FAIL: ($INSTALLER) not found — the release notes tell users to curl it" + exit 1 + } + + # A release whose headline command is a syntactically broken shell script + # is worse than one with no installer at all: the failure lands on the + # user's machine, mid-install. `sh -n` parses without executing, so this + # costs nothing and runs on every leg that ships the file. + let syn = (do { ^sh -n $INSTALLER } | complete) + if $syn.exit_code != 0 { + print $"FAIL: ($INSTALLER) is not valid POSIX shell" + print $syn.stderr + exit 1 + } + + let dest = $"($out)/install-hu.sh" + cp $INSTALLER $dest + ^chmod 755 $dest + if ((ls $dest | first | get size) == 0b) { + print $"FAIL: staged ($dest) is empty" + exit 1 + } + print $" → ($dest)" +} + +def write-sums [out: string] { + # `sha256sum -c` format. SHA256SUMS cannot cover itself. + let sums = ( + ls $out + | where type == file + | get name + | each { |f| $f | path basename } + | where { |f| $f != "SHA256SUMS" } + | sort + | each { |f| $"(sha256-of $"($out)/($f)") ($f)" } + | str join "\n" + ) + $"($sums)\n" | save --force $"($out)/SHA256SUMS" + print $" → ($out)/SHA256SUMS" +} + +def install-readme [ver: string] { + $"# hu ($ver) + +`hu` is the command-line companion to the hiroz ROS 2 stack. It needs no ROS 2 +install and no daemon — only a reachable Zenoh router. + +## Install + +Copy the binary somewhere on your PATH: + + install -Dm755 hu ~/.local/bin/hu + +## Plugins + +`hu meter` and `hu monitor` are WASM plugins, not built into this binary. They +ship as a separate `hu-plugins-($ver).tar.gz`. Install them with: + + hu plugin install ./hu_meter-($ver).wasm + hu plugin install ./hu_monitor-($ver).wasm + +or extract the plugins tarball into `~/.local/share/hu/plugins/`. Verify with: + + hu plugin list + +If that list is empty, `hu meter` and `hu monitor` will not work. + +## Verify this download + + sha256sum -c SHA256SUMS + +## Documentation + +https://zettascalelabs.github.io/hiroz/ +" +} diff --git a/scripts/test-release-version-semantics.sh b/scripts/test-release-version-semantics.sh new file mode 100755 index 000000000..3bbdfa9fd --- /dev/null +++ b/scripts/test-release-version-semantics.sh @@ -0,0 +1,323 @@ +#!/bin/sh +# Release version semantics: a tag is THREE different strings, and every +# release channel has to use the right one at each site. +# +# release identity what the tag/release/download path is called 0.2.0-rc1 +# asset core what the FILENAMES carry 0.2.0 +# binary version what `hu --version` prints hu 0.2.0 +# +# They coincide for a normal release and diverge for a pre-release, because +# `build-hu-release.nu` names every asset for the crate version: an rc ships the +# same crate as the release it rehearses. That divergence is why a pre-release +# is the only tag shape that can catch this class of defect — and, since +# `push: tags: v*` is the GitHub release workflow's only trigger, an rc tag is +# also its only rehearsal vehicle. +# +# This test needs no runner, no network and no build. It runs in seconds and is +# the check that would have caught F13 on either channel. +# +# ./scripts/test-release-version-semantics.sh + +set -u + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +GH="$ROOT/.github/workflows/release.yml" +INSTALLER="$ROOT/scripts/install-hu.sh" + +PASS=0 +FAIL=0 + +ok() { PASS=$((PASS + 1)); printf ' ok %s\n' "$1"; } +bad() { FAIL=$((FAIL + 1)); printf ' FAIL %s\n' "$1"; } + +check() { # description, expected, actual + if [ "$2" = "$3" ]; then ok "$1"; else + bad "$1" + printf ' expected: %s\n actual: %s\n' "$2" "$3" + fi +} + +# grep_must / grep_must_not take a file, a description and a BRE. `--` guards +# patterns that begin with a dash (`--version`, `--no-validate`). +grep_must() { + if grep -q -e "$3" -- "$1"; then ok "$2"; else bad "$2 (pattern absent: $3)"; fi +} +grep_must_not() { + if grep -q -e "$3" -- "$1"; then bad "$2 (pattern present: $3)"; else ok "$2"; fi +} + +# --------------------------------------------------------------------------- +# 1. The semantics themselves, table-driven. +# +# `strip` mirrors the workflows: the tag prefix comes off, then the release +# identity is split at the first '-' to get the asset core. +# --------------------------------------------------------------------------- + +release_id() { # tag, prefix + printf '%s' "${1#"$2"}" +} +asset_core() { # release identity + printf '%s' "${1%%-*}" +} +is_prerelease() { # release identity + # Semver: everything after the first `-` is the pre-release identifier. + # This must stay identical to the expression release.yml uses, or the + # table below asserts a rule the workflow does not implement. + case "$1" in + *-*) printf 'true' ;; + *) printf 'false' ;; + esac +} + +# tag | prefix | expected release id | expected asset core | expected hu --version | expected prerelease +TABLE=' +v0.2.0|v|0.2.0|0.2.0|hu 0.2.0|false +v0.2.0-rc1|v|0.2.0-rc1|0.2.0|hu 0.2.0|true +v0.2.0-beta2|v|0.2.0-beta2|0.2.0|hu 0.2.0|true +hu-v0.1.0|hu-v|0.1.0|0.1.0|hu 0.1.0|false +hu-v0.1.0-rc1|hu-v|0.1.0-rc1|0.1.0|hu 0.1.0|true +' + +echo "== version semantics per tag ==" +printf '%s\n' "$TABLE" | while IFS='|' read -r tag prefix want_id want_core want_ver want_pre; do + [ -n "$tag" ] || continue + got_id=$(release_id "$tag" "$prefix") + got_core=$(asset_core "$got_id") + got_pre=$(is_prerelease "$got_id") + printf '%s -> id=%s core=%s pre=%s\n' "$tag" "$got_id" "$got_core" "$got_pre" + [ "$got_id" = "$want_id" ] || { echo " FAIL release identity"; exit 1; } + [ "$got_core" = "$want_core" ] || { echo " FAIL asset core"; exit 1; } + [ "hu $got_core" = "$want_ver" ] || { echo " FAIL binary version"; exit 1; } + [ "$got_pre" = "$want_pre" ] || { echo " FAIL prerelease flag"; exit 1; } +done +if [ $? -eq 0 ]; then + ok "table rows derive id/core/version/prerelease as documented" +else + bad "a table row derived the wrong id/core/version/prerelease (see above)" +fi + +echo +echo "== the two tag shapes differ in exactly the documented way ==" +FINAL_ID=$(release_id v0.2.0 v); FINAL_CORE=$(asset_core "$FINAL_ID") +RC_ID=$(release_id v0.2.0-rc1 v); RC_CORE=$(asset_core "$RC_ID") + +check "a normal tag: identity equals core" "$FINAL_ID" "$FINAL_CORE" +if [ "$RC_ID" = "$RC_CORE" ]; then + bad "a pre-release tag: identity must NOT equal core" +else + ok "a pre-release tag: identity ($RC_ID) differs from core ($RC_CORE)" +fi +check "both tags name the same assets" "$FINAL_CORE" "$RC_CORE" +check "both tags produce the same binary version" "hu $FINAL_CORE" "hu $RC_CORE" + +echo +echo "== the asset set an rc tag actually produces ==" +for f in "hu-$RC_CORE-x86_64-unknown-linux-gnu.tar.gz" \ + "hu_meter-$RC_CORE.wasm" \ + "hu_monitor-$RC_CORE.wasm" \ + "hu-plugins-$RC_CORE.tar.gz" \ + "hu-plugins-$RC_CORE.json"; do + case "$f" in + *"$RC_ID"*) bad "asset $f must not carry the pre-release suffix" ;; + *) ok "asset named for the core version: $f" ;; + esac +done + +# --------------------------------------------------------------------------- +# 2. The workflows must USE those two strings at the right sites. Part 1 alone +# would stay green if a workflow reverted to one variable for both, so these +# contract checks are what make this a detector rather than a self-test. +# --------------------------------------------------------------------------- + +echo +echo "== .github/workflows/release.yml ==" +grep_must "$GH" "defines HU_CORE alongside HU_VERSION" 'HU_CORE=' +grep_must "$GH" "plugin verify loop checks hu_meter-\$HU_CORE" 'hu_meter-\$HU_CORE\.wasm' +grep_must_not "$GH" "plugin verify loop never checks a HU_VERSION-named asset" 'hu_[a-z]*-\$HU_VERSION' +grep_must_not "$GH" "plugins tarball/index never checked under HU_VERSION" 'hu-plugins-\$HU_VERSION' +grep_must "$GH" "release-install asserts the CORE version" 'test "\$got" = "hu \$CORE"' +grep_must_not "$GH" "release-install does not assert the tag version" 'test "\$got" = "hu \$VER"' +grep_must "$GH" "binary packaging arms the tag-vs-crate guard" '--version "\$HU_VERSION"' +grep_must "$GH" "plugin build arms the tag-vs-crate guard" 'plugins-only --version "\$HU_VERSION"' +grep_must_not "$GH" "plugin build no longer skips component validation" '--no-validate' +grep_must "$GH" "a host-native hu is built so validation can run" 'cargo build --release --bin hu --package hiroz-union' +grep_must "$GH" "the release step sets prerelease from the tag shape" 'prerelease: ' +grep_must "$GH" "publish-crates is guarded against pre-release tags" "if: \${{ !contains(github.ref_name, '-') }}" +grep_must "$GH" "prerelease is derived from the semver hyphen, not an rc/alpha/beta list" "prerelease: \${{ contains(github.ref_name, '-') }}" + +echo +echo "== scripts/install-hu.sh ==" +grep_must "$INSTALLER" "splits the requested version into a core version" 'CORE="\${VERSION%%-\*}"' +grep_must "$INSTALLER" "downloads core-named assets" 'hu-\$CORE-\$TARGET\.tar\.gz' +# The offline path used to skip detect_target entirely and take `ls | head -n1`, +# so a directory holding a whole release installed the alphabetically-first +# target -- aarch64-apple-darwin -- on an x86_64 Linux host, with exit 0. +grep_must "$INSTALLER" "the offline path selects the tarball by platform" 'hu-\*-"\$TARGET"\.tar\.gz' +grep_must_not "$INSTALLER" "no lexical tarball pick remains" 'ls "\$SRC"/hu-\*-\*\.tar\.gz 2>/dev/null | grep -v -- .-plugins-. | head -n1' + +# --------------------------------------------------------------------------- +# 3. What the release PUBLISHES, and what it verifies afterwards. +# +# Two defects live here and neither is a version-semantics bug, but they +# share this file because they share its property: they are contract facts +# about the release workflow that can be checked with no runner, no network +# and no build. +# +# D1 — docs/tools/hu-install.md's first instruction is +# `curl -fsSL /install-hu.sh | sh`, and install-hu.sh was never +# published. build-hu-release.nu writes six assets; the installer is +# not one of them, and the release job uploaded `dist/**`. +# +# D7 — the post-publish check stopped at `hu plugin list | grep meter`. +# Plugin discovery reads FILENAMES ONLY and never opens the component, +# so an empty file named hu_meter.wasm passed every assertion this +# channel made. +# --------------------------------------------------------------------------- + +# Line number of the first line matching a BRE, or 0. Used for the ordering +# assertion below: staging AFTER the checksums are assembled would publish an +# unlisted asset, and no grep for either line alone can see that. +line_of() { # file, BRE + awk -v pat="$2" 'index($0, pat) { print NR; exit }' "$1" 2>/dev/null || true +} + +echo +echo "== D1: the documented installer URL is actually published ==" +grep_must "$GH" "the release job stages install-hu.sh into dist/" \ + 'cp scripts/install-hu.sh dist/install-hu.sh' +grep_must "$GH" "staging parse-checks the installer before publishing it" \ + 'sh -n scripts/install-hu.sh' +grep_must "$GH" "SHA256SUMS coverage of install-hu.sh is asserted" \ + 'install-hu\.sh is not covered by SHA256SUMS' +grep_must "$GH" "the published installer URL is fetched back and compared" \ + 'curl -fsSL "\$HU_RELEASE_BASE/install-hu.sh"' +grep_must "$GH" "the fetched installer is diffed against the tagged source" \ + 'diff -u scripts/install-hu.sh fetched-install-hu.sh' + +_stage=$(line_of "$GH" 'cp scripts/install-hu.sh dist/install-hu.sh') +_sums=$(line_of "$GH" 'Assemble SHA256SUMS over the complete asset set') +if [ -n "${_stage:-}" ] && [ -n "${_sums:-}" ] && [ "$_stage" -lt "$_sums" ] 2>/dev/null; then + ok "install-hu.sh is staged BEFORE SHA256SUMS is assembled (line $_stage < $_sums)" +else + bad "install-hu.sh must be staged before SHA256SUMS (stage=${_stage:-none} sums=${_sums:-none})" +fi + +# The asset this publishes must parse. Pure syntax check — nothing is executed. +if sh -n "$INSTALLER" 2>/dev/null; then + ok "scripts/install-hu.sh parses (sh -n)" +else + bad "scripts/install-hu.sh does not parse (sh -n)" +fi + +echo +echo "== D7: the release is checked against its own docs, not just filenames ==" +grep_must "$GH" "runs the docs-reproduction suite after installing from the release" \ + 'nu scripts/test-hu-docs-repro.nu' +grep_must "$GH" "the suite is pointed at the installed-from-release HOME" \ + '--home "\$HUHOME"' +grep_must "$GH" "--require-traffic, so an empty graph fails instead of passing" \ + '--require-traffic' +grep_must "$GH" "a traffic fixture is built from source, not taken from the artifact" \ + 'cargo build --release --example z_pubsub -p hiroz' +grep_must "$GH" "a router is started from the INSTALLED binary" \ + '"\$HUHOME/.local/bin/hu" router' +grep_must "$GH" "the suite exit status is captured, not piped away" \ + 'test "\$rc" -eq 0' + + +# --------------------------------------------------------------------------- +# 4. The derivation itself, by VALUE +# +# Everything above greps for variable NAMES. That is not enough, and this +# section exists because it was measured not to be: reintroducing F13 verbatim +# --- `HU_CORE=${V%%-*}` -> `HU_CORE=$V`, one variable for both strings --- left +# the suite at 42 passed, 0 failed. Every downstream grep still matched, because +# `hu_meter-$HU_CORE.wasm` is textually unchanged. A name-based check cannot see +# a changed value. +# +# So: lift the real derivation lines out of the workflow and RUN them, then +# assert on what they produce. This targets the TAG path only. + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +derive() { # file, ref-var-name, ref-value, assignment-pattern + _f="$1"; _var="$2"; _ref="$3"; _assign="$4" + { + grep -m1 -- "$_assign" "$_f" + grep -m1 'echo "HU_VERSION=' "$_f" + grep -m1 'echo "HU_CORE=' "$_f" + } | sed 's/^[[:space:]]*//' > "$WORK/derive.sh" + : > "$WORK/env" + env "$_var=$_ref" GITHUB_ENV="$WORK/env" sh "$WORK/derive.sh" >/dev/null 2>&1 || true + cat "$WORK/env" +} + +check_derivation() { # label, file, ref-var, ref, assign-pattern, want_version, want_core + _out="$(derive "$2" "$3" "$4" "$5")" + _v="$(printf '%s\n' "$_out" | sed -n 's/^HU_VERSION=//p' | head -n1)" + _c="$(printf '%s\n' "$_out" | sed -n 's/^HU_CORE=//p' | head -n1)" + if [ "$_v" = "$6" ] && [ "$_c" = "$7" ]; then + ok "$1 (HU_VERSION=$_v HU_CORE=$_c)" + else + bad "$1 — got HU_VERSION='$_v' HU_CORE='$_c', wanted '$6' / '$7'" + fi +} + +echo "== the workflows' own derivation, evaluated ==" +check_derivation "GitHub: a pre-release tag splits release id from asset core" \ + "$GH" GITHUB_REF_NAME v0.2.0-rc1 'V="${GITHUB_REF_NAME#v}"' 0.2.0-rc1 0.2.0 +check_derivation "GitHub: a normal tag makes the two identical" \ + "$GH" GITHUB_REF_NAME v0.2.0 'V="${GITHUB_REF_NAME#v}"' 0.2.0 0.2.0 + + +echo +echo "== every workspace crate inherits one version ==" +# `cargo publish --workspace` takes each crate's version from its own manifest, +# not from the tag. A crate carrying a literal version is one the release can +# silently leave behind: it either republishes a version crates.io already has +# (failing the job) or ships a number that disagrees with the tag. hiroz, +# hiroz-protocol and hiroz-union each carried one. +# +# The nested plugins workspace under crates/hiroz-union/plugins is `exclude`d +# and its versions are cosmetic -- release assets are named for the workspace +# version -- so it is deliberately not covered here. +stray="" +for f in "$ROOT"/crates/*/Cargo.toml; do + v=$(grep -m1 '^version' "$f" 2>/dev/null || true) + case "$v" in + *workspace*) ;; + "") ;; + *) stray="$stray $(basename "$(dirname "$f")")" ;; + esac +done +if [ -z "$stray" ]; then + ok "no crate carries a literal version" +else + bad "these crates carry a literal version instead of version.workspace:$stray" +fi + +echo +echo "== the release is not public until it verifies ==" +# Ordering, not just presence. This used to publish immediately and verify +# afterwards, so a failed check left a public release with nothing to de-list +# it. GitHub will not serve draft assets for a download test, so the reachable +# shape is publish -> verify -> withdraw on failure. +grep_must "$GH" "the release is created as a draft" 'draft: true' +grep_must "$GH" "a job promotes the draft" '\-\-draft=false' +grep_must "$GH" "a job withdraws it again" '\-\-draft=true' +# Not a bare `failure()`. That is true when ANY ancestor fails, and the withdraw +# job's ancestors reach back to build-binaries -- so a failed build made it try +# to withdraw a release that was never created, going red under a caption that +# reads as a bad release left live. Pin the property, not the wording: it must +# withdraw only what publish-release actually published. +grep_must_not "$GH" "the withdraw job does not use a bare failure()" 'if: \${{ failure() }}' +grep_must "$GH" "the withdraw job requires a successful publish" \ + "needs.publish-release.result == 'success'" +grep_must "$GH" "the withdraw job fires on a failed download test" \ + "needs.smoke-test-release-install.result == 'failure'" +grep_must "$GH" "the download test waits for the promotion" 'needs: \[publish-release\]' +grep_must "$GH" "crates.io is gated behind the download test" 'needs: \[smoke-test-release-install\]' +echo +echo "-- $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/scripts/test-release-workflow.nu b/scripts/test-release-workflow.nu index fe7c4789f..5acca22b7 100755 --- a/scripts/test-release-workflow.nu +++ b/scripts/test-release-workflow.nu @@ -15,7 +15,21 @@ use lib/common.nu * const REPO = "ZettaScaleLabs/hiroz" -const DEFAULT_TAG = "v0.0.0-smoke-test" +# The smoke tag must carry the CORE version the `hiroz-union` crate is at. +# `build-hu-release.nu` cross-checks the tag against that crate and fails the +# build on a mismatch, so the old hard-coded `v0.0.0-smoke-test` now kills four +# jobs at their first packaging step -- it removed the only documented rehearsal +# GitHub has. A `-smoke-test` suffix keeps it a pre-release, so it is published +# as one and skips crates.io. +def hu-core []: nothing -> string { + open --raw Cargo.toml + | lines + | skip until { |l| ($l | str trim) == "[workspace.package]" } + | where { |l| ($l | str trim | str starts-with "version") } + | first + | split row "\"" + | get 1 +} def cleanup-tag [tag: string] { log-step $"Deleting remote tag ($tag)" @@ -37,10 +51,13 @@ def cleanup-tag [tag: string] { # Smoke-test the release workflow by pushing a temporary prerelease tag. def main [ - --tag: string = $DEFAULT_TAG # Tag to push (deleted on cleanup) + --tag: string = "" # Tag to push (default: v-smoke-test; deleted on cleanup) --no-wait # Push the tag but do not poll for CI result --cleanup # Delete the tag and GitHub Release, then exit ] { + let tag = if ($tag | is-empty) { $"v(hu-core)-smoke-test" } else { $tag } + print $"Smoke tag: ($tag)" + if $cleanup { cleanup-tag $tag log-success "Cleanup done." From 79cde4fd1eac228ccefde9bc0aeb15731b555118 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Thu, 20 Aug 2026 16:53:38 +0800 Subject: [PATCH 05/21] feat(release): install hu without a checkout `install-hu.sh` detects the platform, downloads the matching tarball, verifies it against SHA256SUMS, and installs the binary and both plugins. `--offline` does the same from a directory someone handed you, with no network. A checksum mismatch and a missing SHA256SUMS entry are both refusals: an unlisted file is exactly what a substituted file looks like. `curl --fail` keeps an error page from being written to disk and unpacked as a binary. No credential is embedded, and a token is read from the environment only. 22 assertions cover the refusals, since those are the paths that matter and the ones a happy-path test never reaches. --- scripts/install-hu.sh | 264 +++++++++++++++++++++++++++++++++++++ scripts/test-install-hu.sh | 190 ++++++++++++++++++++++++++ 2 files changed, 454 insertions(+) create mode 100755 scripts/install-hu.sh create mode 100755 scripts/test-install-hu.sh diff --git a/scripts/install-hu.sh b/scripts/install-hu.sh new file mode 100755 index 000000000..8f7d25f58 --- /dev/null +++ b/scripts/install-hu.sh @@ -0,0 +1,264 @@ +#!/bin/sh +# Install `hu` and its WASM plugins from a release. +# +# curl -fsSL /install-hu.sh | sh +# ./install-hu.sh --offline ./downloaded-dir +# +# A token is needed only if the release host is private. +# When one is needed it is read from the environment only, and is NEVER +# embedded in this script. If you have no account, use --offline with files +# someone handed you — that path needs no network and no credentials. +# +# Environment: +# HU_RELEASE_BASE base URL of the release assets (overrides the default) +# HU_VERSION version to install (default: the latest published) +# HU_RELEASE_TOKEN API token, only needed if the release host is private +# HU_PREFIX install prefix (default: $HOME/.local) + +set -eu + +# Release attachments are served from +# ///releases/download// +# and the tag for version X is hu-vX. HU_RELEASE_BASE overrides the whole +# directory, which is what makes it possible to point at a smoke-test tag whose +# filenames carry a different version than its tag. +DEFAULT_HOST="https://github.com" +DEFAULT_REPO_PATH="ZettaScaleLabs/hiroz" +BASE="${HU_RELEASE_BASE:-}" +PREFIX="${HU_PREFIX:-$HOME/.local}" +BIN_DIR="$PREFIX/bin" +PLUGIN_DIR="$PREFIX/share/hu/plugins" +OFFLINE_DIR="" +TARGET="" +VERSION="${HU_VERSION:-}" + +die() { printf 'install-hu: %s\n' "$*" >&2; exit 1; } +info() { printf 'install-hu: %s\n' "$*"; } + +usage() { + cat <<'EOF' +Usage: install-hu.sh [--offline DIR] [--version X.Y.Z] [--prefix DIR] + + --offline DIR install from already-downloaded artifacts in DIR + (no network, no token required) + --version version to install + --prefix install prefix (default: $HOME/.local) +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --offline) OFFLINE_DIR="${2:-}"; [ -n "$OFFLINE_DIR" ] || die "--offline needs a directory"; shift 2 ;; + --version) VERSION="${2:-}"; [ -n "$VERSION" ] || die "--version needs a value"; shift 2 ;; + --prefix) PREFIX="${2:-}"; [ -n "$PREFIX" ] || die "--prefix needs a value" + BIN_DIR="$PREFIX/bin"; PLUGIN_DIR="$PREFIX/share/hu/plugins"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown argument: $1 (try --help)" ;; + esac +done + +# ------------------------------------------------------------ platform detect + +detect_target() { + os="$(uname -s)" + arch="$(uname -m)" + case "$os/$arch" in + Linux/x86_64) echo "x86_64-unknown-linux-gnu" ;; + Linux/aarch64|Linux/arm64) echo "aarch64-unknown-linux-gnu" ;; + Darwin/arm64) echo "aarch64-apple-darwin" ;; + Darwin/x86_64) die "macOS x86_64 is not published; build from source" ;; + *) die "unsupported platform $os/$arch" ;; + esac +} + +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | cut -d' ' -f1 + else + die "need sha256sum or shasum to verify downloads; refusing to install unverified files" + fi +} + +# Verify FILE against the SHA256SUMS in DIR. A missing entry is a failure, not +# a pass — an unlisted file is exactly what a substituted file looks like. +verify() { + _file="$1"; _sums="$2" + _name="$(basename "$_file")" + # Normalize the recorded name before comparing: `sha256sum ./*` writes + # "./file", and binary mode writes "*file". Both are ordinary ways to + # produce a SHA256SUMS, and neither should read as "no entry" — which is + # a refusal, so being strict here fails closed on a valid download. + _want="$(awk -v n="$_name" ' + { f = $2; sub(/^\.\//, "", f); sub(/^\*/, "", f); if (f == n) print $1 } + ' "$_sums" | head -n1)" + [ -n "$_want" ] || die "$_name has no entry in SHA256SUMS — refusing to install" + _got="$(sha256_of "$_file")" + if [ "$_want" != "$_got" ]; then + die "checksum mismatch for $_name + expected $_want + got $_got +This download is corrupt or has been altered. Nothing was installed." + fi +} + +# ------------------------------------------------------------------- download + +# Public releases need no credential. A token is read only from the environment, +# never from a file: an installer that goes looking for credentials on disk is +# the wrong shape, and it embeds none of its own. +resolve_token() { + if [ -n "${HU_RELEASE_TOKEN:-}" ]; then + printf '%s' "$HU_RELEASE_TOKEN" + return 0 + fi + return 1 +} + +fetch() { + _url="$1"; _dest="$2" + # --fail so an HTTP error is an error: without it curl writes the 404 body + # to disk and we would happily "install" an HTML page as a binary. + # No `-k`. The release host presents a real, publicly-trusted certificate, + # so verification succeeds normally — and skipping it here would undo the + # point of the checksums below, since an attacker able to intercept the + # download could serve their own SHA256SUMS alongside it. + # + # The Authorization header is sent only when a token was found. A public + # release host needs none, and demanding one up front would make this + # refuse to install from, say, a public GitHub release that anyone can + # curl. Whether a credential is required is the *host's* business; this + # only reports it if the download actually fails. + if [ -n "$TOKEN" ]; then + _ok=0 + curl -fsSL -H "Authorization: token $TOKEN" "$_url" -o "$_dest" || _ok=$? + else + _ok=0 + curl -fsSL "$_url" -o "$_dest" || _ok=$? + fi + if [ "$_ok" -ne 0 ]; then + if [ -n "$TOKEN" ]; then + die "failed to download $_url +If this is an auth failure, check that your token is valid for the release host." + fi + die "failed to download $_url +No credential was used. If the release host is private, set HU_RELEASE_TOKEN. +" + fi +} + +# --------------------------------------------------------------------- install + +TMP="" +STAGE="" +# `return 0` is load-bearing: this runs as an EXIT trap, and under `set -e` a +# falsy last command here becomes the script's exit status. Without it an +# offline install (where TMP is empty) succeeded and still exited 1. +cleanup() { + [ -n "$TMP" ] && rm -rf "$TMP" + [ -n "$STAGE" ] && rm -rf "$STAGE" + return 0 +} +trap cleanup EXIT INT TERM + +if [ -n "$OFFLINE_DIR" ]; then + [ -d "$OFFLINE_DIR" ] || die "$OFFLINE_DIR is not a directory" + SRC="$OFFLINE_DIR" + info "offline install from $SRC" +else + # A missing credential is not fatal here. Public release hosts need none, + # and refusing up front would block installing from, say, a public GitHub + # release. If the host does require one, `fetch` says so when the download + # fails — which is also the only point at which we actually know. + TOKEN="$(resolve_token || true)" + + TMP="$(mktemp -d)" + SRC="$TMP" + TARGET="$(detect_target)" + + if [ -z "$VERSION" ]; then + die "HU_VERSION (or --version) is required until the release index is published" + fi + + # A pre-release tag and its asset filenames do NOT carry the same version. + # The tag is the full `hu-v0.1.0-rc1`, but build-hu-release.nu names every + # asset for the CORE version (`hu-0.1.0-...`), because an rc ships the same + # crate as the release it rehearses. For a normal release the two strings + # are identical, which is exactly why conflating them survived until the + # first pre-release was cut and every file 404'd. + CORE="${VERSION%%-*}" + + if [ -z "$BASE" ]; then + BASE="$DEFAULT_HOST/$DEFAULT_REPO_PATH/releases/download/hu-v$VERSION" + fi + + info "downloading hu $VERSION for $TARGET" + [ "$CORE" != "$VERSION" ] && info " pre-release: assets are named for core version $CORE" + info " from $BASE" + fetch "$BASE/SHA256SUMS" "$SRC/SHA256SUMS" + fetch "$BASE/hu-$CORE-$TARGET.tar.gz" "$SRC/hu-$CORE-$TARGET.tar.gz" + fetch "$BASE/hu-plugins-$CORE.tar.gz" "$SRC/hu-plugins-$CORE.tar.gz" +fi + +[ -f "$SRC/SHA256SUMS" ] || die "SHA256SUMS not found in $SRC — refusing to install unverified files" + +# Find the artifacts present in SRC. +# +# The offline path never reached detect_target, so this used to be a bare +# `ls | head -n1` -- lexical order. A directory holding a whole release sorts +# aarch64-apple-darwin first, so an x86_64 Linux user who downloaded every +# asset (which docs/tools/hu-install.md invites: "at least SHA256SUMS and the +# binary tarball") installed the macOS binary. It checksum-verified and exited +# 0, because that tarball really is in SHA256SUMS; the failure surfaced later +# as `Exec format error` with nothing pointing back here. +[ -n "$TARGET" ] || TARGET="$(detect_target)" +BIN_TAR="$(ls "$SRC"/hu-*-"$TARGET".tar.gz 2>/dev/null | head -n1 || true)" +if [ -z "$BIN_TAR" ]; then + # Name what was looked for and what is present: on the offline path the + # user assembled this directory themselves, so the actionable fact is + # which target is missing, not that "no tarball" was found. + found="$(ls "$SRC"/hu-*-*.tar.gz 2>/dev/null | grep -v -- '-plugins-' | sed 's|.*/| |' || true)" + # A full `if`, not `[ -n "$found" ] && die`: under `set -e` a falsy AND-list + # is the shape that made a successful offline install exit 1 once already. + if [ -n "$found" ]; then + die "no hu tarball for $TARGET in $SRC. Present: +$found" + fi +fi +PLUGIN_TAR="$(ls "$SRC"/hu-plugins-*.tar.gz 2>/dev/null | head -n1 || true)" +[ -n "$BIN_TAR" ] || die "no hu binary tarball found in $SRC" + +verify "$BIN_TAR" "$SRC/SHA256SUMS" +[ -n "$PLUGIN_TAR" ] && verify "$PLUGIN_TAR" "$SRC/SHA256SUMS" + +STAGE="$(mktemp -d)" + +tar -xzf "$BIN_TAR" -C "$STAGE" +[ -f "$STAGE/hu" ] || die "binary tarball did not contain hu" + +mkdir -p "$BIN_DIR" "$PLUGIN_DIR" +install -m 755 "$STAGE/hu" "$BIN_DIR/hu" +info "installed $BIN_DIR/hu" + +if [ -n "$PLUGIN_TAR" ]; then + tar -xzf "$PLUGIN_TAR" -C "$STAGE" + for w in "$STAGE"/*.wasm; do + [ -f "$w" ] || continue + install -m 644 "$w" "$PLUGIN_DIR/$(basename "$w")" + info "installed plugin $(basename "$w")" + done +else + info "no plugins tarball found — 'hu meter' and 'hu monitor' will not be available" +fi + +# ---------------------------------------------------------------- post-install + +case ":$PATH:" in + *":$BIN_DIR:"*) ;; + *) info "note: $BIN_DIR is not on your PATH; add it to use 'hu' directly" ;; +esac + +info "done. Verify with:" +info " $BIN_DIR/hu --version" +info " $BIN_DIR/hu plugin list" diff --git a/scripts/test-install-hu.sh b/scripts/test-install-hu.sh new file mode 100755 index 000000000..bc3183900 --- /dev/null +++ b/scripts/test-install-hu.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# Test scripts/install-hu.sh against a synthetic release. +# +# Deliberately synthetic: this exercises the installer's own logic — checksum +# enforcement, refusal paths, exit status — and needs no cargo build, so it +# runs in seconds and can gate every PR. The real artifacts are covered +# end-to-end elsewhere. +# +# Every case here is a *refusal* except the first. An installer is only as +# good as what it declines to install, and a refusal path that has never been +# exercised is unverified, not safe. + +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +INSTALLER="$ROOT/scripts/install-hu.sh" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +pass=0 +fail=0 + +check() { + _name="$1"; _want="$2"; _got="$3" + if [ "$_want" = "$_got" ]; then + echo "ok — $_name" + pass=$((pass + 1)) + else + echo "FAIL — $_name (wanted rc=$_want, got rc=$_got)" + fail=$((fail + 1)) + fi +} + +# Build a synthetic release. Second argument is the version, so lifecycle +# tests can install one version over another. +make_dist() { + _d="$1"; _v="${2:-0.1.0}" + mkdir -p "$_d/s" "$_d/p" + printf '#!/bin/sh\necho "hu %s"\n' "$_v" > "$_d/s/hu" + chmod +x "$_d/s/hu" + echo LICENSE > "$_d/s/LICENSE" + echo README > "$_d/s/README-install.md" + # Unversioned inside the tarball, matching what build-hu-release.nu + # stages — the filename is what discovery derives the subcommand from, so + # a versioned name here would give `hu meter-0_1_0`. + printf 'meter %s' "$_v" > "$_d/p/hu_meter.wasm" + printf 'monitor %s' "$_v" > "$_d/p/hu_monitor.wasm" + tar -czf "$_d/hu-$_v-x86_64-unknown-linux-gnu.tar.gz" -C "$_d/s" hu LICENSE README-install.md + tar -czf "$_d/hu-plugins-$_v.tar.gz" -C "$_d/p" hu_meter.wasm hu_monitor.wasm + rm -rf "$_d/s" "$_d/p" + (cd "$_d" && sha256sum ./*.tar.gz > SHA256SUMS) +} + +run_install() { + _home="$1"; shift + mkdir -p "$_home" + env -u HU_RELEASE_TOKEN HOME="$_home" HU_PREFIX="$_home/.local" \ + sh "$INSTALLER" "$@" > "$WORK/out.txt" 2>&1 + echo $? +} + +# Same, but with a credential present. `run_install` strips HU_RELEASE_TOKEN so +# the no-credential paths are honest; this variant exists for the cases that +# are specifically about behaviour when a token IS set. +run_install_with_token() { + _home="$1"; _tok="$2"; shift 2 + mkdir -p "$_home" + env HU_RELEASE_TOKEN="$_tok" HOME="$_home" HU_PREFIX="$_home/.local" \ + sh "$INSTALLER" "$@" > "$WORK/out.txt" 2>&1 + echo $? +} + +# 1. Happy path. This one caught a real bug: the EXIT trap's last command was +# a falsy test when TMP was empty, so a successful offline install exited 1. +make_dist "$WORK/d1" +rc=$(run_install "$WORK/h1" --offline "$WORK/d1") +check "offline install succeeds" 0 "$rc" +[ -x "$WORK/h1/.local/bin/hu" ] \ + && check "hu binary installed" 0 0 \ + || check "hu binary installed" 0 1 +[ -f "$WORK/h1/.local/share/hu/plugins/hu_meter.wasm" ] \ + && check "meter plugin installed" 0 0 \ + || check "meter plugin installed" 0 1 + +# 2. One corrupted byte must be refused, and nothing may be left behind. +make_dist "$WORK/d2" +printf 'X' | dd of="$WORK/d2/hu-0.1.0-x86_64-unknown-linux-gnu.tar.gz" \ + bs=1 seek=100 conv=notrunc status=none +rc=$(run_install "$WORK/h2" --offline "$WORK/d2") +check "corrupted tarball refused" 1 "$rc" +[ -e "$WORK/h2/.local/bin/hu" ] \ + && check "nothing installed after refusal" 0 1 \ + || check "nothing installed after refusal" 0 0 + +# 3. A file absent from SHA256SUMS is what a substituted file looks like. +make_dist "$WORK/d3" +grep -v 'hu-0.1.0-x86_64' "$WORK/d3/SHA256SUMS" > "$WORK/d3/t" && mv "$WORK/d3/t" "$WORK/d3/SHA256SUMS" +rc=$(run_install "$WORK/h3" --offline "$WORK/d3") +check "unlisted file refused" 1 "$rc" + +# 4. No checksum file at all. +make_dist "$WORK/d4" +rm "$WORK/d4/SHA256SUMS" +rc=$(run_install "$WORK/h4" --offline "$WORK/d4") +check "missing SHA256SUMS refused" 1 "$rc" + +# 5. Network path against an unreachable host must fail loudly, and when no +# credential was used the message must say so — a private host is the most +# likely reason. It must NOT refuse before trying: a public release host +# needs no token, and demanding one up front would block installing from, +# say, a public GitHub release. +UNREACHABLE="https://127.0.0.1:1/nope" + +rc=$(HU_RELEASE_BASE="$UNREACHABLE" run_install "$WORK/h5" --version 0.1.0) +check "unreachable host fails" 1 "$rc" +grep -q "failed to download" "$WORK/out.txt" \ + && check "failure names the URL it could not fetch" 0 0 \ + || check "failure names the URL it could not fetch" 0 1 +grep -q "HU_RELEASE_TOKEN" "$WORK/out.txt" \ + && check "no-credential hint mentions HU_RELEASE_TOKEN" 0 0 \ + || check "no-credential hint mentions HU_RELEASE_TOKEN" 0 1 +[ -e "$WORK/h5/.local/bin/hu" ] \ + && check "nothing installed on download failure" 0 1 \ + || check "nothing installed on download failure" 0 0 + +# The hint must be conditional: with a token set, the message should be about +# the token possibly being wrong, not about there being none. +rc=$(HU_RELEASE_BASE="$UNREACHABLE" run_install_with_token "$WORK/h5b" dummy --version 0.1.0) +check "unreachable host fails with a token too" 1 "$rc" +grep -q "token is valid" "$WORK/out.txt" \ + && check "with a token, the message is about validity" 0 0 \ + || check "with a token, the message is about validity" 0 1 + +# ---------------------------------------------------------------- lifecycle +# +# Everything above installs into a fresh HOME. Real users do not: they install +# over an existing install, upgrade, and eventually remove. None of that had +# ever run. + +# 6. Reinstalling the same version is idempotent — no duplicate plugins, and +# the install still works afterwards. +make_dist "$WORK/d6" +H6="$WORK/h6" +rc=$(run_install "$H6" --offline "$WORK/d6"); check "first install" 0 "$rc" +rc=$(run_install "$H6" --offline "$WORK/d6"); check "reinstall succeeds" 0 "$rc" +n=$(find "$H6/.local/share/hu/plugins" -name '*.wasm' | wc -l) +[ "$n" -eq 2 ] \ + && check "reinstall leaves exactly 2 plugins" 0 0 \ + || { echo " (found $n)"; check "reinstall leaves exactly 2 plugins" 0 1; } + +# 7. Upgrading replaces the binary and the plugins rather than accumulating. +# The plugin filenames are unversioned by design, so a new version must +# overwrite; if a release ever shipped versioned names inside the tarball, +# installs would silently pile up and every one but the newest would be +# dead weight that discovery still sees. +make_dist "$WORK/d7" 0.2.0 +rc=$(run_install "$H6" --offline "$WORK/d7"); check "upgrade over an install" 0 "$rc" +got=$("$H6/.local/bin/hu") +[ "$got" = "hu 0.2.0" ] \ + && check "upgrade replaced the binary" 0 0 \ + || { echo " (binary says '$got')"; check "upgrade replaced the binary" 0 1; } +n=$(find "$H6/.local/share/hu/plugins" -name '*.wasm' | wc -l) +[ "$n" -eq 2 ] \ + && check "upgrade left no stale plugins" 0 0 \ + || { echo " (found $n)"; check "upgrade left no stale plugins" 0 1; } +grep -q "0.2.0" "$H6/.local/share/hu/plugins/hu_meter.wasm" \ + && check "upgrade replaced the plugin contents" 0 0 \ + || check "upgrade replaced the plugin contents" 0 1 + +# 8. docs/tools/hu-install.md tells the reader that removing two paths +# uninstalls hu. That is a promise: nothing may be written outside them, +# or the documented uninstall silently leaves things behind. +stray=$(find "$H6" -type f \ + ! -path "$H6/.local/bin/hu" \ + ! -path "$H6/.local/share/hu/*" | head -5) +[ -z "$stray" ] \ + && check "install writes only where the docs say" 0 0 \ + || { echo " (stray: $stray)"; check "install writes only where the docs say" 0 1; } + +# 9. And the documented uninstall really does leave nothing. +rm -f "$H6/.local/bin/hu" +rm -rf "$H6/.local/share/hu" +left=$(find "$H6" -type f | head -5) +[ -z "$left" ] \ + && check "documented uninstall removes everything" 0 0 \ + || { echo " (left: $left)"; check "documented uninstall removes everything" 0 1; } + +echo +echo "$pass passed, $fail failed" +[ "$fail" -eq 0 ] From d1b49e15f9b031b46303a263e5999f9c2c1cfa1c Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Thu, 20 Aug 2026 16:53:50 +0800 Subject: [PATCH 06/21] test(docs): reproduce every documented hu command from a download The acceptance bar for this branch: a user who downloads `hu` can run what the docs promise. The suite extracts every `hu` invocation from the tool docs and runs it against an installed binary in a scratch HOME, with no cargo, no repo on PATH and HU_PLUGIN_PATH unset. A command is classified in the doc itself, next to the promise, so a reviewer sees the reason. An unclassified command defaults to running, so the suite cannot quietly degrade into testing nothing. Running it is what found the defects fixed here, and the docs it corrects: `hu web` does not bind 0.0.0.0, the `hu meter hz` sample output was wrong, and a downloaded `hu` cannot publish at all, because message definitions do not ship. The Quick Start now states that prerequisite instead of implying a download is enough. --- docs/getting-started/quick-start.md | 2 + docs/tools/hu-install.md | 142 ++++++++ docs/tools/hu-plugins.md | 13 + docs/tools/hu-vs-ros2cli.md | 13 + docs/tools/hu.md | 121 ++++++- docs/tools/why-hu.md | 1 + docs/user-guide/examples.md | 6 + mkdocs.yml | 1 + scripts/test-hu-docs-repro.nu | 523 ++++++++++++++++++++++++++++ 9 files changed, 806 insertions(+), 16 deletions(-) create mode 100644 docs/tools/hu-install.md create mode 100755 scripts/test-hu-docs-repro.nu diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index d00c5db52..4505924e0 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -81,6 +81,8 @@ The example you just ran does four things: 3. **Publishes** — `node.create_pub::("/chatter")` sends CDR-serialized ROS 2 messages on a topic. 4. **Subscribes** — `node.create_sub::("/chatter")` receives and deserializes them asynchronously. +The talker also calls `.with_type_description_service()` when building its node. That is what lets consumers with no compiled knowledge of the message type — `hu meter echo /chatter`, dynamic subscribers, bridges — fetch the schema and decode what it publishes. ROS 2's own client libraries serve that service by default; a hiroz node must opt in, so a node of your own needs the same call. + The full example source (with CLI flags and multi-backend support) is at [`examples/z_pubsub.rs`](https://github.com/ZettaScaleLabs/hiroz/blob/main/crates/hiroz/examples/z_pubsub.rs) in the repository. --- diff --git a/docs/tools/hu-install.md b/docs/tools/hu-install.md new file mode 100644 index 000000000..2802ad42a --- /dev/null +++ b/docs/tools/hu-install.md @@ -0,0 +1,142 @@ +# Installing hu + +`hu` ships as two separate things, and you need both: + +- the **`hu` binary** — the plugin host, the TUI, `stream`, `router`, `web` and `plugin` management; +- the **plugins** — `hu_meter.wasm` and `hu_monitor.wasm`. `hu meter` and `hu monitor` are not built into the binary. Without the plugins those subcommands do not exist. + +Everything here works with no ROS 2 install. `hu` only needs to reach a Zenoh router. + +## Quickest path + +Set `HU_RELEASE_BASE` to the release you are installing from, and pass the same version twice — once so `curl` finds the installer, once so the installer finds the assets: + + +```bash +BASE=https://github.com/ZettaScaleLabs/hiroz/releases/download/v0.1.0 +curl -fsSL "$BASE/install-hu.sh" -o install-hu.sh +HU_RELEASE_BASE="$BASE" HU_VERSION=0.1.0 sh install-hu.sh +``` + +**Download the installer, then run it — do not pipe it into a shell.** Two failure modes look like success if you pipe. A wrong URL makes `curl -fsSL` fail silently, `sh` then reads empty input and exits 0, so you see nothing and no error. And a connection that drops mid-transfer still executes every complete line that arrived, which can leave `hu` installed with no plugins. Downloading first makes `curl`'s exit status stop the install, and gives `sh` a complete file. + +That downloads the binary and the plugins, verifies both against `SHA256SUMS`, installs `hu` to `~/.local/bin/` and the plugins to `~/.local/share/hu/plugins/`. + +`HU_RELEASE_BASE` is not optional here, and the reason is worth knowing: piping the script through `curl` sets nothing inside it. Without that variable the installer falls back to its own built-in host, so you would fetch the script from one place and its assets from another — and the download would fail against a host you may not even be able to reach. + +**The base is the release's download directory, and its shape differs per channel.** GitHub publishes the whole workspace on `v` tags, so the path ends `/releases/download/v`. Releases cut on the `hu`-only `hu-v` tags end `/releases/download/hu-v` instead. Point `HU_RELEASE_BASE` at whichever one you were given; nothing below the base differs between them. + +Set `--prefix` (or `HU_PREFIX`) to install somewhere other than `~/.local`. `hu` looks for plugins next to its own binary — under `/share/hu/plugins` — as well as in `~/.local/share/hu/plugins`, so a prefixed install finds its own plugins. + +Verify: + + +```bash +hu --version +hu plugin list +``` + +`hu plugin list` must show `meter` and `monitor`. If it is empty, the plugins did not install and every `hu meter` / `hu monitor` command will fail. + +## Credentials + +Whether you need a token depends on the release host, so the installer does not decide for you. It reads `$HU_RELEASE_TOKEN` from the environment and **never** carries one of its own. It never reads a credential from a file. + +If it finds one it sends it with every download. If it finds none it proceeds without one — a public release host needs no credential, and refusing up front would block installing from, say, a public GitHub release that anyone can `curl`. + +A missing credential therefore surfaces as a failed download, not as an early refusal, because that is the first point at which the host's requirement is actually known. The failure message names the variable to set. Nothing is ever installed from an error page: `curl --fail` makes an HTTP error an error, so a 401 or a 404 body is never written to disk and never unpacked. + +If you have no account, use the offline path below — it needs no network and no credential. + +## Offline install + +Someone hands you the release files; you install from a directory: + + +```bash +install-hu.sh --offline ./hu-release +``` + +The directory needs at least `SHA256SUMS` and the binary tarball for your platform. Include `hu-plugins-.tar.gz` to get `meter` and `monitor` too. Checksums are still enforced — a file with no entry in `SHA256SUMS` is refused, because an unlisted file is exactly what a substituted file looks like. + +## Manual install + +Verify the download first. `sha256sum -c` exits non-zero on a mismatch, so stop here if it does — do not extract a file that failed this check: + + +```bash +sha256sum -c SHA256SUMS +``` + +Then extract and install: + + +```bash +tar -xzf hu-0.1.0-x86_64-unknown-linux-gnu.tar.gz +install -Dm755 hu ~/.local/bin/hu + +mkdir -p ~/.local/share/hu/plugins +tar -xzf hu-plugins-0.1.0.tar.gz -C ~/.local/share/hu/plugins +``` + +## Installing plugins individually + +`hu plugin install` accepts a local file, a URL, or a name from a release index: + + +```bash +hu plugin install ./hu_meter-0.1.0.wasm +hu plugin install https://example.invalid/hu_meter-0.1.0.wasm +hu plugin install meter --registry "$BASE/hu-plugins-0.1.0.json" # $BASE as above +``` + +In every case the file is checked before it is accepted: + +- if a checksum is available — from the index, or from a `.sha256` sitting next to the file — it must match; +- the file must compile as a WASM component, checked in a temporary location so a broken plugin is never briefly discoverable; +- when installing by name, the index's WIT world must match the one this `hu` hosts, so a plugin built for a different `hu` is refused with a readable message instead of a link error later. + +Remove one with: + + +```bash +hu plugin uninstall meter +``` + +`hu plugin install` writes only to `~/.local/share/hu/plugins/`. Directories on `$HU_PLUGIN_PATH` are left alone — those point at build trees during development and are not the installer's to manage. + +### What the checksums do and do not buy you + +They protect against a corrupted or accidentally substituted download. They are **not** authenticity: nothing is signed, there is no registry that vouches for a publisher, and a plugin's declared permissions are self-reported by the plugin itself rather than enforced against it. Installing a plugin means trusting whoever wrote it. + +## Development builds + +To run plugins you are building from source, point `$HU_PLUGIN_PATH` at the build output instead of installing: + +```bash +export HU_PLUGIN_PATH=$PWD/crates/hiroz-union/plugins/target/wasm32-wasip2/release +``` + +`$HU_PLUGIN_PATH` is searched before `~/.local/share/hu/plugins/`, so a development build shadows an installed one of the same name. `hu plugin list` shows the path each plugin was loaded from, plus a `SOURCE` column — `unmanaged` means it was not installed by `hu plugin install`. + +## Uninstalling + + +```bash +rm ~/.local/bin/hu +rm -rf ~/.local/share/hu +``` + +## Platform coverage + +| Platform | Published | +|---|---| +| Linux x86_64 | yes | +| Linux aarch64 | yes, when built | +| macOS aarch64 | yes, when built | +| macOS x86_64 | no — build from source | +| Windows | no | + +The plugins are `wasm32-wasip2` and platform-independent: one `.wasm` runs everywhere `hu` does. + +A release covers only the platforms its build legs produced. If a tarball is missing from a release, it was not produced for that version. diff --git a/docs/tools/hu-plugins.md b/docs/tools/hu-plugins.md index 7391d1b69..32a8d6278 100644 --- a/docs/tools/hu-plugins.md +++ b/docs/tools/hu-plugins.md @@ -122,18 +122,31 @@ Then `cargo build --release` builds the component with no flags (after a one-tim Name the file `.wasm` — `hu` strips any `hu-` prefix when discovering plugins, so `hu-meter.wasm` registers as `meter` and is invoked by `hu meter `. +Prefer `hu plugin install`. It checks the file compiles as a WASM component **before** accepting it, and does that in a temporary location so a broken plugin is never briefly discoverable: + +```sh +# repro: skip my_hu_plugin.wasm is the plugin the reader has just written +hu plugin install target/wasm32-wasip2/release/my_hu_plugin.wasm +``` + +Copying the file by hand works too, and is what you want when iterating — but nothing validates it, and `hu plugin list` reads filenames without opening them, so a corrupt plugin looks identical to a working one until you run it: + ```sh mkdir -p ~/.local/share/hu/plugins +# repro: skip my_hu_plugin.wasm is the plugin the reader has just written cp target/wasm32-wasip2/release/my_hu_plugin.wasm \ ~/.local/share/hu/plugins/my-plugin.wasm ``` +`hu plugin uninstall ` removes one that `hu` installed. It refuses a plugin on `$HU_PLUGIN_PATH`, since that points at a build tree. + Start `hu` and press `5` to open the Plugins panel (TUI plugins), or run `hu my-plugin ` from the terminal (CLI plugins). If `hu` is already running in the TUI, you don't need to restart it — copy the `.wasm` into a plugin directory and press `R` on the Plugins panel to rescan and load it live. ### 6. Run it end-to-end The shipped template (`crates/hiroz-union/plugins/hu-plugin-template/`) is the crate above. Build it, point `HU_PLUGIN_PATH` at the output, and invoke it by its manifest name (`my-plugin`). Its `on_event` handler stores the `Startup` args and prints `hello from WASM!` on every `Tick` (`tick_ms = 1000`), so a running session emits one line per second until interrupted: + ```sh # CARGO_TARGET_DIR pins the output dir; a standalone --manifest-path build # otherwise writes under the plugin crate's own target/ directory. diff --git a/docs/tools/hu-vs-ros2cli.md b/docs/tools/hu-vs-ros2cli.md index 0de8b5c0c..d153fa809 100644 --- a/docs/tools/hu-vs-ros2cli.md +++ b/docs/tools/hu-vs-ros2cli.md @@ -33,6 +33,8 @@ **`hu meter pub` resolves the message schema from `.msg` files on disk**, so — like `ros2 topic pub` — it can publish to an empty topic with no node present. The plugin host reads the type from a `.msg` under `HIROZ_MSG_PATH` (colon-separated package or prefix directories, e.g. an ament `.../share`); if the type isn't on disk it falls back to discovering it from a live publisher or subscriber on the topic. Only when neither is available does it report a clear "could not resolve a message schema" error rather than guessing. +The subscribing side (`hu meter echo`, `hu meter delay`) consults the same two sources in the **opposite** order: live discovery first, `HIROZ_MSG_PATH` only as a fallback. The orders differ because `pub` is told its type by the caller — disk alone is sufficient, and a live node need not exist at all — whereas `subscribe` is given only a topic, so it must consult the graph regardless, both to learn which `.msg` to load and to reproduce the publisher's exact type hash in its key expression. + --- ## Measurement accuracy: `hu meter hz` vs. `ros2 topic hz` @@ -108,6 +110,7 @@ Most `hu` commands emit newline-delimited JSON with `--json`. This makes them co `ros2cli` outputs human-formatted text with no stable machine-readable format. Parsing `ros2 topic list` output requires string splitting on `/` and filtering out blank lines; parsing `ros2 topic info` requires column-counting. Both break across ROS 2 versions. + ```bash # Filter to sensor_msgs topics only hu meter list topics --json | jq '.[] | select(.type | contains("sensor_msgs"))' @@ -132,6 +135,7 @@ hu stream --json >> /var/log/ros-graph-events.jsonl `hu monitor watch` subscribes to Zenoh liveliness tokens, which are the mechanism hiroz and `rmw_zenoh_cpp` use to announce entity existence. It prints a line the moment a node, topic, service, or action appears or disappears — with sub-millisecond latency after the transport propagates the change. + ```bash hu monitor watch ``` @@ -164,6 +168,7 @@ The standard way to read ROS 2 logs from the CLI is `ros2 topic echo /rosout`, w `hu monitor log` decodes `/rosout` at the CDR layer and presents a clean stream: + ```bash # Tail all log messages hu monitor log @@ -186,6 +191,7 @@ hu monitor log --count 50 `hu monitor log-level` works on Humble nodes because it calls the `GetLoggerLevels` / `SetLoggerLevels` services directly via Zenoh, without relying on a ros2cli verb that was only added in Jazzy. + ```bash # Set the planner's root logger to DEBUG hu monitor log-level /planner DEBUG @@ -206,8 +212,10 @@ hu monitor log-level /planner | Zenoh session setup | N/A | N/A | Host opens sessions declared in plugin's manifest; plugin never handles connection setup | | Works in hermetic / offline envs | no — requires pip | no — requires Qt | yes — single binary copy | + ```bash # Drop a .wasm file and it becomes a plugin +# repro: skip my-debug-tool.wasm is a placeholder for a plugin the reader supplies cp ./my-debug-tool.wasm ~/.local/share/hu/plugins/ hu plugin list # shows all .wasm plugins found in search path ``` @@ -238,6 +246,7 @@ For existing rclcpp/rclpy codebases using DDS, ros2cli remains the right tool. F ### Rate and bandwidth + ```bash # Publish rate ros2 topic hz /scan @@ -253,6 +262,7 @@ hu meter delay /scan ### Message inspection + ```bash # Echo messages ros2 topic echo /chatter @@ -265,6 +275,7 @@ hu meter pub /enable --msg-type std_msgs/msg/Bool --yaml '{data: true}' ### Graph introspection + ```bash # List topics ros2 topic list @@ -287,6 +298,7 @@ hu monitor graph ### Services and parameters + ```bash # Call a service ros2 service call /add_two_ints example_interfaces/srv/AddTwoInts '{a: 3, b: 7}' @@ -307,6 +319,7 @@ hu meter param load /talker params.yaml ### Logging + ```bash # Stream logs ros2 topic echo /rosout diff --git a/docs/tools/hu.md b/docs/tools/hu.md index 768251d14..af048e0f9 100644 --- a/docs/tools/hu.md +++ b/docs/tools/hu.md @@ -16,17 +16,22 @@ A few terms recur throughout this page: ### Pre-built binary -**Forthcoming.** Pre-built `hu` binaries are not yet published by the release workflow — the current [Releases page](https://github.com/ZettaScaleLabs/hiroz/releases) does not include a standalone `hu` artifact. Until they land, build from source (see below). Planned artifact names, once the release job builds them: +Releases publish the `hu` binary **and** the reference plugins. See [Installing hu](hu-install.md) for the installer, the offline path, and how to verify a download. -| Platform | File | +| Artifact | What it is | |---|---| -| Linux x86_64 | `bin-hu-x86_64-linux` | -| Linux aarch64 | `bin-hu-aarch64-linux` | -| macOS aarch64 | `bin-hu-aarch64-macos` | +| `hu--x86_64-unknown-linux-gnu.tar.gz` | `hu` binary, Linux x86_64 | +| `hu--aarch64-unknown-linux-gnu.tar.gz` | `hu` binary, Linux aarch64 | +| `hu--aarch64-apple-darwin.tar.gz` | `hu` binary, macOS aarch64 — built by the release job; CI checks on every PR that it packages and reports its version, but no macOS release has been cut yet | +| `hu_meter-.wasm`, `hu_monitor-.wasm` | the reference plugins (`wasm32-wasip2`, platform-independent) | +| `hu-plugins-.tar.gz` | both plugins, for offline install | +| `hu-plugins-.json` | release index — what `hu plugin install ` resolves a name against | +| `install-hu.sh` | the installer itself, so the documented one-liner fetches it from the release it installs | +| `SHA256SUMS` | verify every download against this | `hu` has no ROS 2 dependency — it works with any [`rmw_zenoh_cpp`](https://github.com/ros2/rmw_zenoh) or hiroz deployment. -**Note:** the `meter` and `monitor` subcommands are WASM plugins loaded from the plugin path, not part of the `hu` binary, and the reference plugins are **not yet bundled in the release artifacts**. To use them today you need the Rust toolchain with the `wasm32-wasip2` target, build the plugins from source (see below), and point `HU_PLUGIN_PATH` at them; verify with `hu plugin list`. The single-binary `hu` still gives you the TUI, `stream`, `router`, and `plugin` management commands with no ROS 2 install. +**The plugins are separate on purpose.** `meter` and `monitor` are WASM components loaded from the plugin path, not code inside the `hu` binary, so they are versioned and installed independently. Install both and `hu plugin list` shows them; skip them and `hu meter` / `hu monitor` do not exist, while the TUI, `stream`, `router`, `web` and `plugin` commands still work. ### Build from source @@ -60,6 +65,7 @@ That's shorthand for `cargo build --manifest-path crates/hiroz-union/plugins/Car **3. Put the plugins on the plugin path** — either point `HU_PLUGIN_PATH` at the build output, or copy the `.wasm` files into `~/.local/share/hu/plugins/` (the always-searched dir). The `hu_`/`hu-` prefix is stripped on discovery, so `hu_meter.wasm` becomes `meter`: + ```bash export HU_PLUGIN_PATH=$PWD/crates/hiroz-union/plugins/target/wasm32-wasip2/release # or, to install permanently: @@ -75,11 +81,20 @@ If `hu plugin list` is empty, `hu meter`/`hu monitor` won't work — the plugins This walks through a real end-to-end session: a router, a talker/listener pair, and `hu` observing them. Run each step in its own terminal. -!!! note "Prerequisite" - This uses `hu meter` and `hu monitor`, which are plugins — make sure `hu plugin list` shows `meter` and `monitor` first. If it's empty, build the plugins and set `HU_PLUGIN_PATH` as described under [Build from Source](#build-from-source). +!!! note "Prerequisites" + This uses `hu meter` and `hu monitor`, which are plugins — make sure `hu plugin list` shows `meter` and `monitor` first. If it's empty, see [Installing hu](hu-install.md), or build them and set `HU_PLUGIN_PATH` as described under [Build from Source](#build-from-source). It also needs a **source checkout**: terminals 2 and 3 below use `cargo run --example`, and a downloaded `hu` cannot stand in for them — see below. + +**`hu` observes a deployment; it cannot create one.** That is worth stating before you start, because it shapes what a plain download can do. `hu meter pub` encodes a message by resolving its schema from a `.msg` file on `HIROZ_MSG_PATH`, or by discovering the type from a node already on the topic. A release ships neither message definitions nor nodes, so on an empty graph it reports: + +```text +encode error: could not resolve a message schema for std_msgs/msg/String on /chatter +``` + +Subscribing does not help either: `hu meter echo` needs a schema too, though it gets one a different way — see the note on `echo` below. So a downloaded `hu` is for observing an existing ROS 2 or hiroz deployment, which is what it is for. To generate traffic as well, you need message definitions on `HIROZ_MSG_PATH` (a ROS 2 installation provides these) or a source checkout, which is what the walkthrough below assumes. **Terminal 1 — start the Zenoh router:** + ```bash hu router ``` @@ -100,30 +115,71 @@ Both examples connect to `tcp/127.0.0.1:7447` (never bare peer discovery — see **Terminal 4 — observe with `hu`:** + ```bash # List all topics +# repro-expect: /chatter hu meter list topics # /chatter (std_msgs/msg/String) # Measure the talker's publish rate +# repro-expect: [0-9]+\.[0-9]+ Hz hu meter hz /chatter -# rate: 1.001 Hz +# /chatter: 1.000 Hz (1 samples) # Watch the live graph +# repro: timeout-quiet 8 hu monitor watch # node appeared: /talker # node appeared: /listener # topic appeared: /chatter ``` +The same graph supports the other measurement subcommands. These need the Quick Start running, since they observe the talker's traffic: + + +```bash +# Bandwidth over a sampling window +# repro-expect: (?i)(B/s|KB/s|bandwidth) +hu meter bw /chatter + +# Full introspection of one topic +# repro-expect: (?i)std_msgs.+String +hu meter info topic /chatter +``` + +!!! warning "`echo` and `delay` need a schema for the topic's type" + `hu meter echo` and `hu meter delay` decode message **content**, so they need the type's schema. They resolve it in two steps: first by querying the publishing node's `~/get_type_description` service, then — if that fails — by loading the type named in the publisher's liveliness token from a `.msg` on `HIROZ_MSG_PATH`. Discovery stays authoritative; the disk is only a fallback, the reverse of `hu meter pub`, which reads disk first. + +Standard ROS 2 nodes expose the type description service; a hiroz node exposes it only when built with `.with_type_description_service()` — the publishing examples in this repo do. **Residual limitation:** if the publisher advertises no type at all, neither source can help, because `hu` has no way to be told the type — `subscribe` carries only a topic name and these commands have no `--type` flag. In that case both commands now report the failure and exit non-zero, rather than printing nothing and exiting 0. + +`hz`, `bw`, `list` and `info` still work on a topic whose type cannot be resolved. Their **numbers** never need a schema: `hz` and `bw` are backed by a wildcard subscriber in the plugin host that counts and sizes raw payloads, and `list`/`info` only read the graph. `hu meter echo --raw` also works regardless, since it hex-dumps the CDR bytes instead of decoding them. + +`hz` and `bw` do open one ordinary subscription alongside that, purely so they **announce themselves in the ROS graph** — a publisher that waits for a subscriber before it starts will otherwise never publish, and the measurement would read zero. That subscription resolves a schema like any other, so two things follow on a topic whose type cannot be resolved: the first sample can be delayed by up to the discovery timeout, and the announcement does not happen. The counting is unaffected either way. + + +```bash +# repro-expect: (?i)hello hiroz +hu meter echo /chatter + +# repro-expect: (?i)\[/chatter\] (delay:.*ms|no header\.stamp) +hu meter delay /chatter --duration 5 +``` + +`delay` takes `--duration ` exactly as `hz` and `bw` do; without it the command runs until interrupted. + +`delay` measures the gap between a message's `header.stamp` and its arrival, so it only produces a number on **stamped** messages. `/chatter` carries `std_msgs/String`, which has no header — against it `delay` decodes each message and says so per message (`no header.stamp — cannot measure delay`) rather than reporting a latency. Point it at a stamped topic (anything carrying a `std_msgs/Header`, e.g. `sensor_msgs/LaserScan` from the `laser_scan` example) to get `delay: ms`. + By default `hu` connects to `tcp/127.0.0.1:7447` and uses domain ID `0` — matching the talker/listener above. Override with flags or environment variables: + ```bash hu --connect tcp/192.168.1.10:7447 --domain 5 meter list topics ``` Or set them once for the session: + ```bash export HU_CONNECT=tcp/192.168.1.10:7447 export HU_DOMAIN=5 @@ -132,6 +188,7 @@ hu meter hz /chatter `HU_CONNECT` and `HU_DOMAIN` fully replace the `--connect` / `--domain` flags — once exported, every `hu meter` / `hu monitor` invocation reaches that router with no per-command flags, which is the recommended workflow for an interactive session: + ```bash export HU_CONNECT=tcp/127.0.0.1:7447 hu meter list topics # no --connect needed @@ -144,6 +201,7 @@ The Quick Start covers `list`, `hz`, and `watch`. The subcommands below are the **Call a service** (against an `AddTwoInts` server on `/add_two_ints`): + ```bash # --yaml takes the request as inline YAML; --msg-type names the request type. hu meter service call /add_two_ints \ @@ -155,6 +213,7 @@ hu meter service call /add_two_ints \ The response prints as JSON, so it pipes straight into `jq`: + ```bash hu meter service call /add_two_ints --yaml '{a: 20, b: 22}' \ --msg-type example_interfaces/srv/AddTwoInts_Request | jq '.sum' @@ -163,6 +222,7 @@ hu meter service call /add_two_ints --yaml '{a: 20, b: 22}' \ **Round-trip a parameter** (set then read it back): + ```bash hu meter param set /talker publish_period_ms 500 # OK @@ -173,6 +233,7 @@ hu meter param get /talker publish_period_ms --json **Describe a parameter** as JSON (every meter subcommand supports `--json` for scripting): + ```bash hu meter param describe /talker publish_period_ms --json # {"name":"publish_period_ms","value":500} @@ -180,6 +241,7 @@ hu meter param describe /talker publish_period_ms --json **Stream action feedback** while a goal runs: + ```bash hu meter action echo /fibonacci \ --msg-type example_interfaces/action/Fibonacci --count 3 @@ -190,6 +252,7 @@ hu meter action echo /fibonacci \ **Get / set a node's log level** with `hu monitor`: + ```bash # Read the current logger levels for /talker. hu monitor log-level /talker @@ -249,7 +312,7 @@ Measurement and introspection: | `hu meter bw ` | Bandwidth in KB/s | | `hu meter echo ` | Print arriving messages | | `hu meter echo --raw` | Hex-dump raw CDR bytes, bypassing schema decode (requires the `access-raw-cdr` permission) | -| `hu meter delay ` | End-to-end latency | +| `hu meter delay ` | End-to-end latency, from `header.stamp` to arrival (stamped messages only) | | `hu meter pub ` | Publish a message | | `hu meter list [--find ] [--count ] [--all]` | Enumerate graph entities. `` is `topics` (the default when omitted), `nodes` or `services`. Hidden entities are excluded unless `--all` is given: for topics and services that means any name with a path segment starting with `_`, but for nodes only the bare node name is tested, so a node whose *namespace* has an `_`-prefixed segment stays visible. `--find` matches name or type for topics and services, name only for nodes; `--count` truncates the result. | | `hu meter list find- ` | Shorthand for `list --find `, taking the filter as a positional argument: `find-topics`, `find-services`, `find-nodes`. | @@ -281,8 +344,13 @@ Plugin management: | Command | Description | |---|---| -| `hu plugin list` | List all loaded `.wasm` plugins with name and path | +| `hu plugin list` | List discovered plugins as `PLUGIN VERSION SOURCE PATH`. `SOURCE` is `download`, `local`, `installed`, or `unmanaged` for a file `hu` did not install | | `hu plugin validate ` | Validate that a `.wasm` file compiles as a WASM component | +| `hu plugin install ` | Install from a local path, a URL, or a name resolved against a release index (`--registry`, or `HU_PLUGIN_REGISTRY`). Validates before accepting | +| `hu plugin uninstall ` | Remove a plugin `hu` installed. Refuses one that lives on `$HU_PLUGIN_PATH`, since that is a build tree and not `hu`'s to delete | + +`list` reads filenames only — it never opens a component, so it cannot tell a +valid plugin from a corrupt one. `validate` is the check that does. --- @@ -290,6 +358,7 @@ Plugin management: For continuous monitoring of several topics at once, use the `hu` TUI. Select topics in the Topics panel and press `m` to add them to the Measure panel, which shows a live, per-second rate and bandwidth table for every topic you're tracking — all in one process, instead of one `ros2 topic hz` per topic: + ```bash hu ``` @@ -323,9 +392,11 @@ When a TUI plugin's output pane is focused (select it on the Plugins panel and p Every `hu meter` subcommand accepts `--json` for scripting: + ```bash hu meter hz /scan --duration 5 --json | jq '.rate_hz' hu meter list topics --json | jq '.[].name' +# repro-expect: /chatter hu meter info node /talker --json | jq '.publishers[].name' ``` @@ -337,6 +408,7 @@ hu meter info node /talker --json | jq '.publishers[].name' It first prints the current graph as a snapshot, then one line per change event, each prefixed with a UTC timestamp. Type names appear in their DDS-mangled form (`std_msgs::msg::dds_::String_`), not the ROS `std_msgs/msg/String` form: + ```bash hu stream # Discovered Topics: @@ -354,6 +426,7 @@ hu stream Add `--json` for structured output. Every record is one of two shapes: an object with an `"event"` key naming it, or a `SystemEvent` in serde's externally-tagged form, where the variant name is the sole top-level key. The first line is always `"event":"initial_state"`; graph changes after it are the externally-tagged form. Adding `--echo` interleaves two further `"event"`-keyed shapes, `topic_subscribed` and `message_received`, so a filter must not assume every record after the first has a variant-name key: + ```bash hu stream --json # {"event":"initial_state","timestamp":{"secs_since_epoch":1785829316,"nanos_since_epoch":522800352},"domain_id":0,"topics":[{"name":"/chatter","type":"std_msgs::msg::dds_::String_","publishers":1,"subscribers":0}],"nodes":[{"name":"talker","namespace":"/"}],"services":[{"name":"/talker/get_parameters","type":"rcl_interfaces::srv::dds_::GetParameters_"}]} @@ -365,6 +438,7 @@ The arrays are shown with one entry each for brevity; a real graph also carries Because the variant name is the key rather than a `type` field, filtering with `jq` selects on key presence: + ```bash hu stream --json | jq -c 'select(has("TopicDiscovered")) | .TopicDiscovered.topic' ``` @@ -378,6 +452,7 @@ Two field-naming traps when writing filters: Add `--echo ` to also subscribe to a topic and interleave decoded messages. `--echo` can be repeated for multiple topics: + ```bash hu stream --json --echo /scan --echo /cmd_vel ``` @@ -389,14 +464,24 @@ hu stream --json --echo /scan --echo /cmd_vel ## Web mode -`hu web` starts an HTTP server (default port 8080) that dispatches requests to `hu-web-plugin` WASM plugins. Requires `hu` built with the `web-plugins` feature: +`hu web` starts an HTTP server (default port 8080) that dispatches requests to `hu-web-plugin` WASM plugins. It needs `hu` built with the `web-plugins` feature — the published release binaries are, so a downloaded `hu` has it: + + +```bash +hu web # listen on 127.0.0.1:8080 +hu web --port 9090 # listen on 127.0.0.1:9090 +``` + +It binds **loopback only** by default, so the plugin HTTP surface is not exposed on every interface. Set `HU_WEB_BIND` to widen it deliberately: + ```bash -hu web # listen on 0.0.0.0:8080 -hu web --port 9090 # listen on 0.0.0.0:9090 +HU_WEB_BIND=0.0.0.0 hu web ``` -Each web plugin is reachable at `/plugins//` and `/plugins//*path`. The plugin handles the full HTTP request/response cycle (see [hu Plugin Authoring Guide](hu-plugins.md)). +Each web plugin is reachable at `/plugins//` and `/plugins//`. The plugin handles the full HTTP request/response cycle (see [hu Plugin Authoring Guide](hu-plugins.md)). + +There is no reference `hu-web-plugin` yet — the host is wired and the server runs, but until you write one there is nothing for it to serve. !!! note `hu web` replaces the deprecated `--web [PORT]` flag, which still works as a hidden alias for now. @@ -407,9 +492,12 @@ Each web plugin is reachable at `/plugins//` and `/plugins//*path`. `hu router` starts an embedded Zenoh router configured to match `rmw_zenoh_cpp`, so you don't need a separate `zenohd` install or the `cargo run --example zenoh_router` helper for local development. It listens on `tcp/[::]:7447` by default and runs until Ctrl-C: + ```bash +# repro: skip the suite's router fixture already holds :7447 hu router # listen on tcp/[::]:7447 hu router --listen tcp/0.0.0.0:7448 # custom endpoint (repeatable) +# repro: skip router.json5 is an illustrative filename, not a shipped file hu router --config router.json5 # full JSON5/YAML config, overrides --listen ``` @@ -458,10 +546,11 @@ flowchart TD Any team can ship a `hu-.wasm` file and it becomes a `hu ` subcommand with no build-system changes, no Python packaging, and no shared runtime state: + ```bash # Drop a .wasm file and it becomes available immediately cp ./my-debug-tool.wasm ~/.local/share/hu/plugins/ -hu plugin list # shows all loaded plugins with name and path +hu plugin list # PLUGIN VERSION SOURCE PATH hu my-debug-tool --help ``` diff --git a/docs/tools/why-hu.md b/docs/tools/why-hu.md index bfb215db9..7f610c17e 100644 --- a/docs/tools/why-hu.md +++ b/docs/tools/why-hu.md @@ -75,6 +75,7 @@ ROS 2 ships two standard toolsets: `ros2cli` for the terminal and `rqt` for the **JSON output on most commands** makes it composable with `jq`, shell scripts, CI harnesses, and log pipelines without fragile text parsing. `--json` is a global flag, so it is *accepted* everywhere, but some commands ignore it. Of those, `hu monitor log` and `hu meter param set` still print bare JSON, while `hu monitor watch`/`log-level` and `hu meter echo`/`delay` do not — so check a command's output before depending on it: + ```bash # Check camera rate in CI rate=$(hu meter hz /camera/image_raw --duration 5 --json | jq '.rate_hz') diff --git a/docs/user-guide/examples.md b/docs/user-guide/examples.md index 8ab956e3f..3ab1d2470 100644 --- a/docs/user-guide/examples.md +++ b/docs/user-guide/examples.md @@ -63,6 +63,12 @@ Leave the router running in a separate terminal, then run any example from the h | `z_parameter_yaml` | YAML parameter loading plus programmatic overrides | `cargo run --example z_parameter_yaml` | | `z_parameter_client` | Remote `ParameterClient` calls against a parameter server | `cargo run --example z_parameter_client` | +The CDR **publishing** examples build their nodes with `.with_type_description_service()`, so runtime-typed tools such as `hu meter echo` can fetch the schema and decode their traffic. That call is opt-in on a hiroz node — copy it into your own nodes if you want them to be introspectable the same way. ROS 2 differs here: `rclcpp` and `rclpy` start that service by default, so a C++ or Python node is introspectable without asking. + +The **service** examples do not opt in. `z_srvcli` and `demo_nodes/add_two_ints_server` build plain nodes, so `hu meter service call` cannot resolve their request type and fails rather than decoding it. Add `.with_type_description_service()` to the server's node if you want to drive it from `hu`. + +The `protobuf_interop` and `encoding_demo` examples deliberately leave it off: they publish protobuf-encoded payloads, and advertising a CDR schema for those would invite a consumer to decode them as CDR and print plausible but wrong values. + !!! tip For a detailed walkthrough of creating your own project with hiroz (not using the repository examples), see the [Quick Start](../getting-started/quick-start.md#option-2-create-your-own-project) guide. diff --git a/mkdocs.yml b/mkdocs.yml index 75a1855a8..1fbb2ab2f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -107,6 +107,7 @@ nav: - hu Toolkit: - Overview: tools/why-hu.md - hu (Hiroz Union): tools/hu.md + - Installing hu: tools/hu-install.md - hu vs. ros2cli / rqt: tools/hu-vs-ros2cli.md - hu Plugin Authoring: tools/hu-plugins.md - Language Bindings: diff --git a/scripts/test-hu-docs-repro.nu b/scripts/test-hu-docs-repro.nu new file mode 100755 index 000000000..b1f3b0e89 --- /dev/null +++ b/scripts/test-hu-docs-repro.nu @@ -0,0 +1,523 @@ +#!/usr/bin/env nu +# Reproduce every documented `hu` command against a *downloaded* install. +# +# The bar this enforces: a user who downloads `hu` and its plugins — no repo, +# no cargo, no build tree — can run every `hu` command that appears in the +# docs. A command in the docs is a promise; this executes the promises. +# +# Commands are classified by a `repro:` directive. Two forms, because docs are +# for readers first and the directives must not clutter the rendered page: +# +# 1. Fence-level, as an HTML comment immediately before the fence. Invisible +# in the rendered docs, and covers every command in that fence — which is +# what most fences need, since they tend to be homogeneous: +# +# +# ```bash +# hu monitor log-level /talker +# ``` +# +# 2. Per-command, as a `# repro:` line inside the fence, overriding the +# fence default for the single command that follows. This one IS visible, +# so use it only where the note earns its place. +# +# An unannotated command line defaults to `run` and MUST succeed. Nothing is +# silently ignored — a docs command missing from the report fails the suite. +# +# TWO command classes are extracted, and the second one is easy to forget: +# +# 1. `hu ...` — the promises about the tool. +# 2. INSTALL commands (`curl`, `install-hu.sh`, `tar`, `sha256sum`, `cp`, …) +# — the promises about *getting* the tool. +# +# Class 2 was invisible until 2026-08-15, because extraction took only lines +# starting with `hu`. docs/tools/hu-install.md was in DOC_FILES and carried +# `repro:` directives, so it looked covered while every load-bearing line in it +# — the `curl … | sh` one-liner, `install-hu.sh --offline`, `tar -xzf`, +# `sha256sum -c` — was silently dropped. A one-liner pointing at a URL nothing +# served shipped in the docs under that blind spot. A suite that cannot see a +# command cannot report it missing, and absence of output is UNKNOWN, never +# SUCCESS. + +const DOC_FILES = [ + "docs/tools/hu.md" + "docs/tools/hu-install.md" + "docs/tools/hu-plugins.md" + "docs/tools/hu-vs-ros2cli.md" + "docs/tools/why-hu.md" +] + +# Fences whose contents are shell commands. Everything else (rust, toml, wit, +# mermaid, text) is prose or output and is never executed. +const SHELL_FENCES = ["bash" "sh" "shell" "console"] + +# Command heads that make a documented *installation* step. The list is a +# whitelist, not a heuristic: it names the verbs the install docs actually +# instruct with, so an unrelated shell line in some other fence does not become +# a silent new test. Adding a doc step with a head that is not here reproduces +# the D10 blind spot for that step, so extend this list when the docs grow a +# new install verb. +# +# curl/wget the download one-liner +# install-hu.sh, sh the installer, run directly or piped into a shell +# tar/unzip unpacking a release tarball +# install/cp/mkdir placing the binary and the plugins by hand +# sha256sum/shasum the integrity check the docs tell the reader to run +# rm the uninstall step +const INSTALL_HEADS = [ + "curl" "wget" + "install-hu.sh" "./install-hu.sh" + "sh" "bash" + "tar" "unzip" + "install" "cp" "mkdir" + "sha256sum" "shasum" + "rm" +] + +# Heads that would mutate the very install this suite is measuring. They are +# extracted and reported like anything else, but never executed: `rm -rf +# ~/.local/share/hu` inside the scratch HOME deletes the artifact under test, +# and every command after it then fails for a reason that has nothing to do +# with the artifact — a cascade whose root cause is invisible in the report. +# Refusing is louder than running and louder than ignoring: the entry fails +# with a note naming the directive that resolves it. +const DESTRUCTIVE_HEADS = ["rm"] + +# ---------------------------------------------------------------- extraction + +# First real token of a command, ignoring any leading `KEY=value` environment +# prefixes (`HU_VERSION=0.1.0 install-hu.sh …`). Returns "" for a line that is +# nothing but assignments. +def cmd-head [cmd: string] { + mut toks = ($cmd | split row " " | where { |t| $t != "" }) + while (($toks | length) > 0) and (($toks | first) =~ '^[A-Za-z_][A-Za-z0-9_]*=') { + $toks = ($toks | skip 1) + } + if ($toks | is-empty) { "" } else { $toks | first } +} + +# Pull every `hu ...` invocation out of the shell fences of one markdown file, +# carrying its classification and any `export` lines that precede it in the +# same fence. +def extract-file [file: string] { + mut out = [] + mut in_fence = false + mut fence_is_shell = false + mut pending = null # `# repro:` directive awaiting its command + mut pending_expect = null # `# repro-expect:` regex awaiting its command + mut fence_default = null # `` covering the whole fence + mut next_fence_default = null + mut fence_env = [] # `export K=V` seen earlier in this fence + mut cont = "" # accumulator for `\`-continued lines + mut cont_line = 0 + + # `--raw`: nu parses .md into a table otherwise, and we need the text. + for entry in (open --raw $file | lines | enumerate) { + let lineno = $entry.index + 1 + let raw = $entry.item + let line = ($raw | str trim) + + if ($line | str starts-with "```") { + if $in_fence { + $in_fence = false + $fence_is_shell = false + $fence_env = [] + $pending = null + $pending_expect = null + $fence_default = null + $cont = "" + } else { + let info = ($line | str replace --regex '^`+' "" | str trim | split row " " | first | default "") + $in_fence = true + $fence_is_shell = ($info in $SHELL_FENCES) + $fence_env = [] + $pending = null + $pending_expect = null + $fence_default = $next_fence_default + $cont = "" + } + $next_fence_default = null + continue + } + + if not $in_fence { + # Fence-level directive: an HTML comment, so it never renders. + # Applies to the next fence only; a blank line does not cancel it, + # but any other prose does — keeping its scope obvious to a reader. + if ($line | str starts-with "" "" + | str trim) + } else if $line != "" { + $next_fence_default = null + } + continue + } + + if not $fence_is_shell { continue } + + # Mid-continuation: keep accumulating until a line without a trailing `\`. + if $cont != "" { + if ($line | str ends-with "\\") { + $cont = $"($cont) ($line | str substring 0..<(($line | str length) - 1) | str trim)" + } else { + let full = $"($cont) ($line)" + $out = ($out | append (make-entry $file $cont_line $full ($pending | default $fence_default) $fence_env $pending_expect)) + $cont = "" + $pending = null + $pending_expect = null + } + continue + } + + if ($line | str starts-with "# repro:") { + $pending = ($line | str replace "# repro:" "" | str trim) + continue + } + + # `# repro-expect: ` asserts the command's OUTPUT, not just its + # exit status. It is the difference between "the plugin dispatched" and + # "the plugin computed the right answer" — an audit found every release + # assertion was the former, so a truncated .wasm could pass. Kept + # separate from `# repro:` so a command can carry both a class and an + # expectation without inventing a combined grammar. + if ($line | str starts-with "# repro-expect:") { + $pending_expect = ($line | str replace "# repro-expect:" "" | str trim) + continue + } + + # Other comments and output lines carry no command. + if ($line | str starts-with "#") or ($line == "") { continue } + + if ($line | str starts-with "export ") { + $fence_env = ($fence_env | append ($line | str replace "export " "" | str trim)) + continue + } + + # `$ hu ...` prompt form is accepted too. + let cmd = if ($line | str starts-with "$ ") { $line | str substring 2.. | str trim } else { $line } + + # The `hu` test is unchanged, and is asked FIRST: an install-class head + # can never also be an `hu` line, so nothing is counted twice and the + # set of extracted `hu` commands is byte-for-byte what it was before. + let is_hu = (($cmd | str starts-with "hu ") or ($cmd == "hu")) + # An install line is classified by exactly the same `repro:` directives + # as an `hu` line, and — deliberately — defaults to `run` in exactly + # the same way. A command the fixture cannot satisfy must be declared + # `skip ` in the doc, next to the promise, where a reader sees + # the reason. It must never be dropped by the extractor, which is the + # failure mode this whole change exists to remove. + let is_install = ((not $is_hu) and ((cmd-head $cmd) in $INSTALL_HEADS)) + if not ($is_hu or $is_install) { continue } + + if ($cmd | str ends-with "\\") { + $cont = ($cmd | str substring 0..<(($cmd | str length) - 1) | str trim) + $cont_line = $lineno + continue + } + + $out = ($out | append (make-entry $file $lineno $cmd ($pending | default $fence_default) $fence_env $pending_expect)) + $pending = null + $pending_expect = null + } + + $out +} + +def make-entry [file: string, line: int, cmd: string, directive: any, env_lines: list, expect: any = null] { + let d = ($directive | default "run") + let kind = ($d | split row " " | first) + let rest = ($d | str replace --regex '^\S+\s*' "") + + let class = match $kind { + "skip" => "skip" + "timeout" => "run-timeout" + # For commands that stream *changes* (`hu monitor watch`, `hu stream`), + # silence on an idle graph is the correct behaviour, so requiring + # output would fail a working command. Kept as a separate class rather + # than relaxing `timeout` for everything: a command that silently does + # nothing should still fail unless someone said it may be quiet. + "timeout-quiet" => "run-timeout-quiet" + "run" => "run" + _ => "run" + } + + { + file: $file + line: $line + command: $cmd + class: $class + reason: (if $class == "skip" { $rest } else { "" }) + timeout: (if $class in ["run-timeout" "run-timeout-quiet"] { ($rest | into int) } else { 0 }) + env: $env_lines + expect: ($expect | default "") + } +} + +def extract-all [] { + $DOC_FILES | each { |f| extract-file $f } | flatten +} + +# ------------------------------------------------------------------ execution + +# Build the environment a *downloaded* install runs in: a scratch HOME, a PATH +# holding only the installed bin dir, and no HU_PLUGIN_PATH so discovery must +# fall through to ~/.local/share/hu/plugins — exactly what a user gets. +def clean-env [home: string, extra: list] { + # Prepend the install dir rather than replacing PATH. Replacing it removed + # `bash` itself on NixOS, where the shell lives in the nix store and not in + # /bin — and it would also hide `jq`, which documented commands pipe into. + # `hu` resolving to the installed copy is asserted separately, in main. + let inherited = ($env.PATH? | default [] | str join (char esep)) + mut e = { + HOME: $home + PATH: $"($home)/.local/bin:($inherited)" + HU_PLUGIN_PATH: null + RUSTFLAGS: "" + # hiroz logs a dozen INFO lines per node at startup. Unfiltered they + # bury the actual error in the failure excerpt, which is the one thing + # the report exists to show. + RUST_LOG: "error" + } + for kv in $extra { + let parts = ($kv | split row "=") + if ($parts | length) >= 2 { + $e = ($e | insert ($parts | first) ($parts | skip 1 | str join "=")) + } + } + $e +} + +def run-one [entry: record, home: string] { + if $entry.class == "skip" { + return { result: "skip", rc: 0, note: $entry.reason, out: "" } + } + + # Asked after `skip`, so a doc that declares the reason still wins. Asked + # before execution, so an undeclared uninstall step cannot delete the + # install the remaining commands are measured against — see + # DESTRUCTIVE_HEADS. This is a fail, not a skip: the promise is unproven, + # and the note says what to write in the doc to resolve it. + if (cmd-head $entry.command) in $DESTRUCTIVE_HEADS { + return { + result: "fail" + rc: 0 + note: "destructive command not run — it would delete the install under test; declare it in the doc with ``" + out: "" + } + } + + let env_map = (clean-env $home $entry.env) + let secs = (if $entry.timeout > 0 { $entry.timeout } else { 30 }) + # `timeout` so a streaming command cannot hang the suite. `-k 5` is not + # optional: SIGINT alone is a request, and a TUI or a wedged plugin can + # ignore it — without the follow-up SIGKILL one bad command hangs the whole + # run with no output to say which. + # + # `set -o pipefail` is load-bearing, not hygiene. Several documented + # commands pipe into `jq` (`hu meter hz … --json | jq '.rate_hz'`), and + # bash reports the LAST command's status by default — so a failing `hu` + # whose error went to stderr scored a pass, because jq exited 0 on empty + # input. An audit found two such commands among the "passing" set. With + # pipefail the pipeline carries hu's status, and a timeout still surfaces + # as 124 for the streaming classes. + let wrapped = $"set -o pipefail; timeout -k 5 --preserve-status -s INT ($secs) ($entry.command)" + + # `bash -c`, not `-lc`: a login shell sources the user's profile, which can + # put a system `hu` on PATH and quietly test the wrong binary. + # `^cmd | complete` captures a non-zero exit as data. Wrapping it in a bare + # `do { }` instead makes nu raise on the first failing command, which ends + # the whole suite at the first red — the opposite of what a test runner + # should do. + let res = (with-env $env_map { ^bash -c $wrapped | complete }) + let rc = $res.exit_code + let out = $"($res.stdout)($res.stderr)" + + # An `expect` regex outranks every status rule below. A command can exit 0, + # stream plenty, and still be wrong — which is exactly the hole this closes: + # before, `hu meter hz` reported a pass whatever number it printed, so a + # plugin that dispatched but computed nonsense was indistinguishable from a + # correct one. Checked first so a wrong answer cannot be rescued by a + # generous status rule. + if $entry.expect != "" { + # Whitespace is collapsed first: plugin output is column-aligned, so a + # pattern written against the doc's example would otherwise have to + # encode the exact padding. + let flat = ($out | str replace --all --regex '\s+' " ") + if not ($flat =~ $entry.expect) { + return { result: "fail", rc: $rc, note: $"output did not match expect: ($entry.expect)", out: $out } + } + } + + if $entry.class == "run-timeout-quiet" { + # Survived its window without erroring. No output requirement — see + # the class comment in make-entry. + let alive = ($rc in [124 130 2]) + if $alive or $rc == 0 { + { result: "pass", rc: $rc, note: "ran (quiet allowed)", out: $out } + } else { + { result: "fail", rc: $rc, note: "exited non-zero before timeout", out: $out } + } + } else if $entry.class == "run-timeout" { + # A streaming command is healthy if it was still running when the + # timeout fired (124, or the INT-preserved 130/2) AND it printed + # something. Exiting 0 early is also fine. Anything else is a failure. + let alive = ($rc in [124 130 2]) + if ($alive or $rc == 0) and (($out | str trim) != "") { + { result: "pass", rc: $rc, note: "streamed output", out: $out } + } else if ($alive or $rc == 0) { + { result: "fail", rc: $rc, note: "ran but produced no output", out: $out } + } else { + { result: "fail", rc: $rc, note: "exited non-zero before timeout", out: $out } + } + } else { + if $rc == 0 { + { result: "pass", rc: 0, note: "", out: $out } + } else { + { result: "fail", rc: $rc, note: $"exit ($rc)", out: $out } + } + } +} + +# -------------------------------------------------------------------- report + +# Stand up the graph the docs describe, using a publisher that is NOT the +# artifact under test. +# +# This separation is the point. `hu` cannot generate its own traffic: `hu meter +# pub` needs a message schema from a `.msg` on HIROZ_MSG_PATH or from a live +# node, and a release ships neither. So a suite whose only fixture is `hu +# router` measures an empty graph, and every `hu meter` command it runs is +# reduced to checking that the process started. That is how a truncated plugin +# could pass a release check. +# +# The publisher therefore comes from the build tree — it stands in for the +# "existing ROS 2 or hiroz deployment" that `hu` is documented to observe. +# Using the artifact for both sides would repeat the mistake that hid this: +# a fixture built from the same source as the code under test cannot represent +# a user who only downloaded the code. +def start-traffic [publisher: string, endpoint: string, home: string] { + if $publisher == "" { return null } + if not ($publisher | path exists) { + print $"FAIL: --publisher ($publisher) does not exist" + exit 1 + } + print $"starting traffic fixture: ($publisher)" + # Log beside the scratch HOME, not in a temp dir: it belongs with the rest + # of the run's state, and `$nu.temp-path` does not exist in every nushell. + let log = $"($home)/talker.log" + let pid = ( + job spawn { + with-env { RUST_LOG: "error" } { + ^$publisher --role talker --endpoint $endpoint out+err> $log + } + } + ) + # The graph is liveliness-driven, so a `hu meter list` issued too early + # legitimately sees nothing. Wait for the token to propagate rather than + # sleeping a guessed interval. + sleep 4sec + { job: $pid, log: $log } +} + +def main [ + --home: string # scratch HOME holding the downloaded install + --list # only print what would run + --filter: string = "" # substring filter on the command + --publisher: string = "" # binary that puts real traffic on /chatter (see start-traffic) + --endpoint: string = "tcp/127.0.0.1:7447" + --require-traffic # fail rather than run against an empty graph +] { + let entries = (extract-all | where { |e| ($filter == "") or ($e.command | str contains $filter) }) + + if ($entries | is-empty) { + # Zero extracted commands is a harness failure, not a pass. A suite + # that runs nothing must never report success. + print "FAIL: extracted 0 documented commands from the docs — the extractor is broken" + exit 1 + } + + if $list { + $entries | select file line class command | print + print $"($entries | length) commands" + return + } + + let home = ($home | default "") + if $home == "" or not ($home | path exists) { + print "FAIL: --home must point at a prepared install dir (see scripts/install-hu.sh --offline)" + exit 1 + } + + # The whole point is testing the *downloaded* hu. If PATH resolution picks + # up a system or build-tree copy instead, every result below is about the + # wrong binary and the suite is worse than useless — so prove it first. + let probe = (with-env (clean-env $home []) { ^bash -c "command -v hu" | complete }) + let resolved = ($probe.stdout | str trim) + if $probe.exit_code != 0 or not ($resolved | str starts-with $"($home)/.local/bin/") { + print $"FAIL: `hu` resolves to '($resolved)', not the install under ($home)" + exit 1 + } + print $"testing ($resolved)" + + if $require_traffic and $publisher == "" { + # Guard against the quiet degradation this flag exists to prevent: a + # release gate that silently becomes an exit-status check because + # nobody passed a publisher. + print "FAIL: --require-traffic was set but no --publisher was given" + exit 1 + } + let traffic = (start-traffic $publisher $endpoint $home) + + if $traffic != null { + # Prove the fixture works before trusting any result that depends on + # it. If the talker died on startup, every `hu meter` command below + # would fail for a reason that has nothing to do with the artifact. + let seen = (with-env (clean-env $home []) { + ^bash -c "timeout 15 hu meter list topics 2>&1" | complete + }) + if not ($seen.stdout | str contains "/chatter") { + print "FAIL: traffic fixture produced no /chatter topic — the graph is empty" + print ($seen.stdout | lines | first 10 | str join "\n") + print $"talker log: ($traffic.log)" + if ($traffic.log | path exists) { print (open --raw $traffic.log | lines | last 10 | str join "\n") } + job kill $traffic.job + exit 1 + } + print "traffic fixture live: /chatter visible to the installed hu" + } + + mut rows = [] + let total = ($entries | length) + for it in ($entries | enumerate) { + let e = $it.item + # Print before running, not after: if a command wedges, the last line + # printed names the culprit instead of leaving a silent hang. + print $"[($it.index + 1)/($total)] ($e.class) ($e.command)" + let r = (run-one $e $home) + $rows = ($rows | append { + result: $r.result + where: $"($e.file):($e.line)" + class: $e.class + command: ($e.command | str substring 0..90) + note: $r.note + }) + if $r.result == "fail" { + print $"FAIL ($e.file):($e.line) ($e.command)" + print $" ($r.note)" + print ($r.out | lines | first 6 | each { |l| $" | ($l)" } | str join "\n") + } + } + + if $traffic != null { job kill $traffic.job } + + print "" + $rows | print + let passed = ($rows | where result == "pass" | length) + let failed = ($rows | where result == "fail" | length) + let skipped = ($rows | where result == "skip" | length) + print $"\n($passed) passed, ($failed) failed, ($skipped) skipped, ($rows | length) total" + + if $failed > 0 { exit 1 } +} From b783035fa12e95e29c2029c3ce8f5061af573415 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Thu, 20 Aug 2026 16:54:01 +0800 Subject: [PATCH 07/21] ci: build and test what the release actually ships CI never compiled `web-plugins` and never ran `hiroz-union`'s tests. That is why a run-time panic in `hu web` survived: the file was never compiled, let alone executed. The crate already had 14 tests that had never run once -- `--lib` silently does nothing on a binary-only crate, so it needs `--bins`. Add the docs-repro suite, an aarch64 cross-compile leg, and the release dry-run, so the packaging path is exercised on a pull request instead of first running on a tag. --- .github/workflows/ci.yml | 179 ++++++++++++++++++++++++++++++++++++++ flake.nix | 36 ++++++++ scripts/ci/hu-tests.sh | 102 ++++++++++++++++++++++ scripts/test-pure-rust.nu | 13 ++- 4 files changed, 328 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3fa1430e..7b497871c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -435,6 +435,30 @@ jobs: nu scripts/test-pure-rust.nu check-hu shell: bash + # docs/tools/hu.md publishes an aarch64-apple-darwin tarball, and the + # release workflow has a macOS leg — but no `v*` tag has been cut since + # that leg was written, so nothing has ever confirmed `hu` builds, + # packages and runs on macOS. This job is already on a macOS runner and + # already compiles the crate, so proving the rest costs a few minutes + # and turns a published artifact name into a tested claim. + # + # Release profile deliberately: the release leg builds --release with + # web-plugins, and a debug build proving nothing about that is how an + # untested promise survives looking tested. + - name: hu packages and runs on macOS + if: runner.os == 'macOS' + run: | + set -e + nu scripts/build-hu-release.nu --binary-only --no-sums --out mac-dist + ls -l mac-dist + HUHOME="$RUNNER_TEMP/huhome-mac" + mkdir -p "$HUHOME/.local/bin" + tar -xzf mac-dist/hu-*-aarch64-apple-darwin.tar.gz -C "$HUHOME/.local/bin" hu + got=$(HOME="$HUHOME" "$HUHOME/.local/bin/hu" --version) + echo "macOS build reports: $got" + case "$got" in "hu "*) ;; *) echo "FAIL: no version from the macOS binary"; exit 1 ;; esac + shell: bash + - name: Check all examples run: | if [ "$RUNNER_OS" == "Linux" ]; then @@ -475,6 +499,161 @@ jobs: nu scripts/test-pure-rust.nu clippy-tests shell: bash + hu-aarch64-cross: + name: hu cross-compiles for aarch64 Linux + runs-on: ubuntu-latest + permissions: + contents: read + # docs/tools/hu.md publishes an aarch64 Linux tarball. That it builds at + # all was established once, by hand on an aarch64 runner — hu pulls in + # wasmtime, rusqlite's bundled C, ring's assembly and axum, any of which + # could stop cross-compiling. Nothing guarded it afterwards, so the + # release leg would have found out at tag time. + # + # Deliberately build-and-package only, no execution: qemu-user is + # expensive to provision (it killed a whole job while still realizing) + # and running the binary is a separate question from whether the release + # artifact can be produced. + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-unknown-linux-gnu + + - name: Install cargo-zigbuild + uses: taiki-e/install-action@v2 + with: + tool: cargo-zigbuild + + - name: Install ziglang + run: pip install ziglang + + - name: Install nushell + uses: hustcer/setup-nu@v3 + with: + version: "0.113.1" + + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + with: + shared-key: ubuntu-latest-hu-aarch64-cross + + - name: Cross-build and package + shell: bash + run: | + set -e + TGT=aarch64-unknown-linux-gnu + cargo zigbuild --release --bin hu -p hiroz-union \ + --features web-plugins --no-default-features --target "$TGT" + BIN="target/$TGT/release/hu" + # A cross build that silently emitted x86_64 would otherwise look + # exactly like success. + file "$BIN" + file "$BIN" | grep -q "ARM aarch64" || { + echo "FAIL: not an aarch64 binary"; exit 1; } + nu scripts/build-hu-release.nu --binary-only --binary-from "$BIN" \ + --target "$TGT" --no-sums --out a64-dist + test -s "a64-dist/$(ls a64-dist | head -1)" || { + echo "FAIL: nothing packaged"; exit 1; } + tar -tzf a64-dist/hu-*-$TGT.tar.gz + + hu-docs-repro: + name: hu docs reproduce from a download (ubuntu-latest) + runs-on: ubuntu-latest + permissions: + contents: read + # Enforces the contract that every `hu` command in docs/tools/ is + # runnable by someone who only downloaded a release: it packages the + # artifacts, installs them into a scratch HOME with HU_PLUGIN_PATH unset, + # and executes the documented commands against that install. + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + target: wasm32-wasip2 + + - name: Install nushell and jq + run: | + sudo apt-get update && sudo apt-get install -y jq + cargo install nu --locked --version 0.113.1 + # No `|| true`: a swallowed install failure resurfaces three steps + # later as `nu: command not found`, which names neither the cause + # nor the step that caused it. + nu --version + + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + with: + shared-key: ubuntu-latest-hu-docs-repro + + # release.yml only ever runs on a `v*` tag, so no pull request exercises + # it. This is the one check those steps get before a tag is pushed, and + # it is deliberately pure string arithmetic — no runner, no network, no + # build — so it can run here. It encodes the tag-vs-asset-name split that + # a pre-release tag depends on, which is the defect that broke the first + # pre-release ever cut on the other channel. + - name: Release workflow version semantics + run: bash scripts/test-release-version-semantics.sh + + - name: Installer refusal paths + run: bash scripts/test-install-hu.sh + + - name: Package the release artifacts + run: nu scripts/build-hu-release.nu --out dist + + - name: Install exactly as a user would, offline + run: | + set -e + HUHOME="$RUNNER_TEMP/huhome" + mkdir -p "$HUHOME" + HOME="$HUHOME" HU_PREFIX="$HUHOME/.local" sh scripts/install-hu.sh --offline dist + echo "HUHOME=$HUHOME" >> "$GITHUB_ENV" + + # The traffic source is built from the tree, NOT taken from the artifact. + # `hu` cannot generate its own traffic (`hu meter pub` needs message + # definitions no release ships), so without an external publisher the + # suite measures an empty graph and every `hu meter` command degrades to + # "did the process start". This example stands in for the deployment hu + # is documented to observe. + - name: Build the traffic fixture + run: cargo build --release --example z_pubsub -p hiroz + + - name: Reproduce the documented commands + run: | + set -e + unset HU_PLUGIN_PATH + HOME="$HUHOME" "$HUHOME/.local/bin/hu" router > router.log 2>&1 & + ROUTER_PID=$! + sleep 5 + # A router that failed to bind is otherwise silent, and surfaces as a + # dozen unrelated-looking measurement failures further down. Fail here + # instead, where the message names the cause. + kill -0 "$ROUTER_PID" 2>/dev/null || { + echo "FAIL: router died on startup"; tail -10 router.log; exit 1; } + # --require-traffic so this can never silently degrade back into an + # exit-status check if the publisher argument is dropped. + # Status must not pass through a pipe. + # `set +e` around the run is not optional: with `set -e` active the + # shell exits ON the failing command, so `cat repro.log` never runs + # and the failure is reported with no output at all. Captured rc is + # worthless if the capture is unreachable. + set +e + nu scripts/test-hu-docs-repro.nu \ + --home "$HUHOME" \ + --publisher "${CARGO_TARGET_DIR:-$PWD/target}/release/examples/z_pubsub" \ + --require-traffic > repro.log 2>&1 + rc=$? + set -e + cat repro.log + echo "--- router.log ---"; tail -20 router.log || true + exit $rc + wasm-plugin-tests: name: WASM Plugin Tests (ubuntu-latest) runs-on: ubuntu-latest diff --git a/flake.nix b/flake.nix index 9fcfff808..3e3d36084 100644 --- a/flake.nix +++ b/flake.nix @@ -505,6 +505,42 @@ extraShellHook = ''''; }; + # Cross-compilation shell for the aarch64-unknown-linux-gnu leg of + # the hu release matrix, mirroring what + # .github/workflows/release.yml installs on its runner (rust target + # + ziglang + cargo-zigbuild). + # + # Kept rather than deleted after the G4 investigation, because it is + # what reproduces that result: `hu` DOES cross-compile for aarch64 + # despite wasmtime, rusqlite's bundled C, ring's assembly and axum + # (verified on an aarch64 runner — `file` reports "ELF 64-bit ... ARM aarch64", and + # build-hu-release.nu packages it correctly). Without this shell the + # next person has to rediscover that. + pureRust-cross = mkDevShell { + name = "hiroz-ci-pure-rust-cross"; + packages = [ + (rustToolchain.override { + targets = [ + "wasm32-wasip2" + "aarch64-unknown-linux-gnu" + ]; + }) + pkgs.cargo-zigbuild + pkgs.zig + # NO qemu here, deliberately. `qemu-user` was tried and, despite + # being the small variant, still builds from the qemu source + # tarball and pulls harfbuzz/graphene/gsm/hwdata — job 3803 was + # killed while still realizing it, before running a single line + # of the build it was meant to test. Whether the cross-built + # binary can be *executed* on an x86_64 worker is a separate + # question from whether it *compiles*; answer the compile one + # first, cheaply. + ] + ++ (builtins.filter (p: p != rustToolchain) commonBuildInputs) + ++ testTools; + extraShellHook = ''''; + }; + # Bridge interop test environment (Jazzy + Humble side-by-side). # Used by `cargo test -p hiroz-tests --features bridge-interop-tests,jazzy`. ros-bridge-interop = diff --git a/scripts/ci/hu-tests.sh b/scripts/ci/hu-tests.sh index d6d0b12b3..251b2c7b3 100755 --- a/scripts/ci/hu-tests.sh +++ b/scripts/ci/hu-tests.sh @@ -44,3 +44,105 @@ export PATH="${TARGET_DIR}/debug:${PATH}" # and starve graph discovery into a timeout. Wall-clock ≈ parallel here anyway. cargo test -p hiroz-tests --test hu_meter --features hu-meter-tests,jazzy -- --test-threads=1 cargo test -p hiroz-tests --test hu_monitor --features hu-monitor-tests,jazzy -- --test-threads=1 + +# --------------------------------------------------------------------------- +# `hu plugin install` SUCCESS paths. +# +# crates/hiroz-union/tests/plugin_install.rs covers the refusals (404, checksum +# mismatch, non-component bytes, a WIT world this hu does not host, an unknown +# name, no registry). It cannot cover the success paths: a real WASM component +# is needed to serve, and that crate cannot build one -- the plugins are a +# separate, excluded, wasm32-wasip2 workspace. Its module doc claimed this +# script covered them instead. It did not; nothing did. This is that coverage, +# placed here because this is where genuine plugins exist. +# +# Local loopback only, no network: python3 -m http.server over a fixture dir. +echo "== hu plugin install: success paths ==" +PI_TMP="$(mktemp -d)" +trap 'rm -rf "$PI_TMP"; [ -n "${PI_SRV:-}" ] && kill "$PI_SRV" 2>/dev/null || true' EXIT + +WASM="${HU_PLUGIN_PATH}/hu_meter.wasm" +[ -f "$WASM" ] || { echo "FAIL: no built plugin at $WASM"; exit 1; } + +mkdir -p "$PI_TMP/srv" "$PI_TMP/home" +cp "$WASM" "$PI_TMP/srv/hu_meter-9.9.9.wasm" +# A `.sha256` sidecar is the checksum source the docs describe for a URL +# install, so serve one and prove it is honoured rather than ignored. +( cd "$PI_TMP/srv" && sha256sum hu_meter-9.9.9.wasm | cut -d' ' -f1 > hu_meter-9.9.9.wasm.sha256 ) +# The index must declare the world this hu hosts, or install refuses -- which +# is the guard plugin_install.rs already proves fires. +HOST_WORLD="$(grep -o 'hu:plugin@[0-9.]*' crates/hiroz-union/src/plugin/install.rs | head -1)" +cat > "$PI_TMP/srv/hu-plugins-9.9.9.json" </dev/null 2>&1 ) & +PI_SRV=$! +sleep 2 +curl -fsS -o /dev/null "http://127.0.0.1:8791/hu_meter-9.9.9.wasm" \ + || { echo "FAIL: fixture server did not serve the plugin"; exit 1; } + +# Only `.wasm` files. `installed.json` -- the install database -- lives in this +# same directory by design (install.rs db_path), so listing everything makes +# "nothing left after uninstall" impossible to satisfy. +pi_installed() { ls "$PI_TMP/home/.local/share/hu/plugins"/*.wasm 2>/dev/null | xargs -r -n1 basename | tr "\n" " "; } + +# Every failure here costs a full CI round trip to observe -- there is no hu +# binary on a dev box to reproduce it. So each one prints the whole state at +# once rather than the single assertion that tripped. +pi_dump() { + echo " --- state ---" + echo " plugins dir: $(ls -la "$PI_TMP/home/.local/share/hu/plugins" 2>&1 | tr "\n" "|")" + echo " plugin list: $(env -u HU_PLUGIN_PATH HOME="$PI_TMP/home" hu plugin list 2>&1 | tr "\n" "|")" + echo " served: $(ls "$PI_TMP/srv" 2>&1 | tr "\n" " ")" +} + +# 1. Install by URL. The sidecar checksum must be used, not skipped. +env -u HU_PLUGIN_PATH -u HU_PLUGIN_REGISTRY HOME="$PI_TMP/home" \ + hu plugin install "http://127.0.0.1:8791/hu_meter-9.9.9.wasm" \ + || { echo "FAIL: install by URL"; pi_dump; exit 1; } +case "$(pi_installed)" in + *hu_meter*) echo " ok installed by URL: $(pi_installed)" ;; + *) echo "FAIL: URL install left nothing: '$(pi_installed)'"; pi_dump; exit 1 ;; +esac + +# 2. Install by registry NAME, resolved through the served index. Fresh HOME so +# this proves the registry path, not a leftover from case 1. +rm -rf "$PI_TMP/home"; mkdir -p "$PI_TMP/home" +env -u HU_PLUGIN_PATH HOME="$PI_TMP/home" \ + HU_PLUGIN_REGISTRY="http://127.0.0.1:8791/hu-plugins-9.9.9.json" \ + hu plugin install meter \ + || { echo "FAIL: install by registry name"; pi_dump; exit 1; } +case "$(pi_installed)" in + *hu_meter*|*meter*) echo " ok installed by name: $(pi_installed)" ;; + *) echo "FAIL: registry install left nothing: '$(pi_installed)'"; pi_dump; exit 1 ;; +esac + +# 3. The installed plugin must be listed AND loadable. `plugin list` reads filenames +# and never opens a component, so listing it proves only that a file is there. +env -u HU_PLUGIN_PATH HOME="$PI_TMP/home" hu plugin list | grep -q meter \ + || { echo "FAIL: installed plugin not listed"; pi_dump; exit 1; } +# Then OPEN what was installed. `hu meter` is the wrong probe: the host +# connects to a Zenoh router before dispatching to a plugin, so with no +# router it fails on the connection and never reaches the component -- +# which says nothing about the install. `plugin validate` returns early in +# main() before any session exists, and loads the file as a component, so it +# tests the installed bytes and needs no infrastructure. +PI_WASM="$PI_TMP/home/.local/share/hu/plugins/hu_meter.wasm" +PI_OUT="$(env -u HU_PLUGIN_PATH HOME="$PI_TMP/home" hu plugin validate "$PI_WASM" 2>&1)" || { + echo "FAIL: installed plugin does not load as a component" + echo " output: $PI_OUT" + echo " dir: $(ls -la "$PI_TMP/home/.local/share/hu/plugins" 2>&1 | tr '\n' '|')" + echo " list: $(env -u HU_PLUGIN_PATH HOME="$PI_TMP/home" hu plugin list 2>&1 | tr '\n' '|')" + exit 1 +} +echo " ok installed plugin loads as a component" + +# 4. Uninstall round trip. +env -u HU_PLUGIN_PATH HOME="$PI_TMP/home" hu plugin uninstall meter \ + || { echo "FAIL: uninstall"; pi_dump; exit 1; } +[ -z "$(pi_installed)" ] || { echo "FAIL: uninstall left '$(pi_installed)'"; pi_dump; exit 1; } +echo " ok uninstall removed it" diff --git a/scripts/test-pure-rust.nu b/scripts/test-pure-rust.nu index 3f710ab8a..f58fe8950 100755 --- a/scripts/test-pure-rust.nu +++ b/scripts/test-pure-rust.nu @@ -50,8 +50,17 @@ def check-bundled-msgs [] { def check-hu [] { log-step "Check hiroz-union" - run-cmd "cargo check -p hiroz-union" - run-cmd "cargo clippy -p hiroz-union -- -D warnings" + # `--features web-plugins` and `--all-targets` are both load-bearing. + # Without the feature, CI never compiles modes/web.rs at all — which is how + # `hu web` shipped with an axum 0.7 route string that panics at startup + # under axum 0.8. Without --all-targets, the crate's tests are not built. + run-cmd "cargo check -p hiroz-union --features web-plugins --all-targets" + run-cmd "cargo clippy -p hiroz-union --features web-plugins --all-targets -- -D warnings" + log-step "Test hiroz-union" + # `--bins`, not `--lib`: hiroz-union is a binary-only crate, so `--lib` + # fails with "no library targets found" and every #[cfg(test)] module in it + # silently goes unrun. + run-cmd "cargo test -p hiroz-union --features web-plugins --bins" log-step "Build WASM plugins (wasm32-wasip2)" # Needs the wasm32-wasip2 sysroot: CI uses `.#pureRust-ci`; locally enter # `.#pureRust-wasm` (the default `.#pureRust` shell omits it to stay lean). From 53205d5fad059241afa3c0743a97573b6385dac7 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Thu, 20 Aug 2026 16:54:15 +0800 Subject: [PATCH 08/21] feat(hiroz): expose what the plugin host needs, and name the demo nodes The disk-schema fallback needs the pieces of the graph and dynamic-message API that were previously internal. The examples gain node names. They were "Pub" and "Sub", so the documented output showing `/talker` and `/listener` could not be reproduced by running them -- a docs promise the code did not keep. --- crates/hiroz/examples/demo_nodes/talker.rs | 5 ++++- crates/hiroz/examples/laser_scan.rs | 5 ++++- crates/hiroz/examples/lifecycle/talker.rs | 5 ++++- crates/hiroz/examples/shm_pointcloud2.rs | 15 ++++++++++++--- crates/hiroz/examples/twist_pub.rs | 5 ++++- crates/hiroz/examples/z_cache/talker.rs | 5 ++++- crates/hiroz/examples/z_custom_message.rs | 5 ++++- crates/hiroz/examples/z_pubsub.rs | 7 +++++-- crates/hiroz/src/context.rs | 1 + crates/hiroz/src/dynamic/mod.rs | 3 +++ crates/hiroz/src/dynamic/type_info.rs | 8 +++++++- crates/hiroz/src/lifecycle/node.rs | 14 ++++++++++++++ crates/hiroz/src/node.rs | 9 +++++++++ 13 files changed, 75 insertions(+), 12 deletions(-) diff --git a/crates/hiroz/examples/demo_nodes/talker.rs b/crates/hiroz/examples/demo_nodes/talker.rs index 66608d238..d6cf26737 100644 --- a/crates/hiroz/examples/demo_nodes/talker.rs +++ b/crates/hiroz/examples/demo_nodes/talker.rs @@ -27,7 +27,10 @@ pub async fn run_talker( ) -> Result<()> { // --8<-- [start:node_setup] // Create a node named "talker" - let node = ctx.create_node("talker").build()?; + let node = ctx + .create_node("talker") + .with_type_description_service() + .build()?; // --8<-- [end:node_setup] // --8<-- [start:publisher_setup] diff --git a/crates/hiroz/examples/laser_scan.rs b/crates/hiroz/examples/laser_scan.rs index f52de32d5..d6392289d 100644 --- a/crates/hiroz/examples/laser_scan.rs +++ b/crates/hiroz/examples/laser_scan.rs @@ -25,7 +25,10 @@ fn main() -> Result<()> { fn run_publisher() -> Result<()> { let ctx = ZContextBuilder::default().build()?; - let node = ctx.create_node("laser_scan_publisher").build()?; + let node = ctx + .create_node("laser_scan_publisher") + .with_type_description_service() + .build()?; let zpub = node.create_pub::("scan").build()?; println!("Publishing LaserScan messages on /scan..."); diff --git a/crates/hiroz/examples/lifecycle/talker.rs b/crates/hiroz/examples/lifecycle/talker.rs index 96eab4aef..841e9bae1 100644 --- a/crates/hiroz/examples/lifecycle/talker.rs +++ b/crates/hiroz/examples/lifecycle/talker.rs @@ -18,7 +18,10 @@ fn main() -> Result<()> { // Build a Zenoh context and create a lifecycle node. // The node starts in the Unconfigured state. let ctx = ZContextBuilder::default().build()?; - let mut node = ctx.create_lifecycle_node("lifecycle_talker").build()?; + let mut node = ctx + .create_lifecycle_node("lifecycle_talker") + .with_type_description_service() + .build()?; // Register callbacks for each lifecycle transition. // Each callback receives the previous state and must return diff --git a/crates/hiroz/examples/shm_pointcloud2.rs b/crates/hiroz/examples/shm_pointcloud2.rs index eef24569d..4048d1bc9 100644 --- a/crates/hiroz/examples/shm_pointcloud2.rs +++ b/crates/hiroz/examples/shm_pointcloud2.rs @@ -68,7 +68,10 @@ fn demo_user_managed_shm() -> zenoh::Result<()> { // Step 3: Create node and publisher let ctx = ZContextBuilder::default().build()?; - let node = ctx.create_node("pointcloud_publisher").build()?; + let node = ctx + .create_node("pointcloud_publisher") + .with_type_description_service() + .build()?; let publisher = node .create_pub::("cloud/user_managed") .build()?; @@ -95,7 +98,10 @@ fn demo_automatic_shm() -> zenoh::Result<()> { .build()?; println!(" ✓ Context configured with automatic SHM (threshold: 10KB)"); - let node = ctx.create_node("pointcloud_publisher").build()?; + let node = ctx + .create_node("pointcloud_publisher") + .with_type_description_service() + .build()?; let publisher = node.create_pub::("cloud/automatic").build()?; // Generate point cloud normally (using Vec) @@ -126,7 +132,10 @@ fn demo_automatic_shm() -> zenoh::Result<()> { fn demo_publisher_shm_override() -> zenoh::Result<()> { // Context has no SHM, but publisher has its own config let ctx = ZContextBuilder::default().build()?; - let node = ctx.create_node("pointcloud_publisher").build()?; + let node = ctx + .create_node("pointcloud_publisher") + .with_type_description_service() + .build()?; // Create SHM provider for this publisher only let provider = Arc::new(ShmProviderBuilder::new(30 * 1024 * 1024).build()?); diff --git a/crates/hiroz/examples/twist_pub.rs b/crates/hiroz/examples/twist_pub.rs index 2960b5ffd..8efb62466 100644 --- a/crates/hiroz/examples/twist_pub.rs +++ b/crates/hiroz/examples/twist_pub.rs @@ -5,7 +5,10 @@ use hiroz_msgs::geometry_msgs::{Twist, Vector3}; fn main() -> Result<()> { let ctx = ZContextBuilder::default().build()?; - let node = ctx.create_node("twist_publisher").build()?; + let node = ctx + .create_node("twist_publisher") + .with_type_description_service() + .build()?; let zpub = node.create_pub::("cmd_vel").build()?; println!("Publishing Twist messages on /cmd_vel..."); diff --git a/crates/hiroz/examples/z_cache/talker.rs b/crates/hiroz/examples/z_cache/talker.rs index 2c21937fb..db38a6018 100644 --- a/crates/hiroz/examples/z_cache/talker.rs +++ b/crates/hiroz/examples/z_cache/talker.rs @@ -15,7 +15,10 @@ use hiroz::{Builder, Result, context::ZContextBuilder}; use hiroz_msgs::std_msgs::String as RosString; pub async fn run(ctx: hiroz::context::ZContext, topic: String, count: usize) -> Result<()> { - let node = ctx.create_node("cache_talker").build()?; + let node = ctx + .create_node("cache_talker") + .with_type_description_service() + .build()?; let publisher = node.create_pub::(&topic).build()?; println!("[talker] publishing on '{}' every 100 ms", topic); diff --git a/crates/hiroz/examples/z_custom_message.rs b/crates/hiroz/examples/z_custom_message.rs index 85e6d5202..eb8d39092 100644 --- a/crates/hiroz/examples/z_custom_message.rs +++ b/crates/hiroz/examples/z_custom_message.rs @@ -200,7 +200,10 @@ async fn run_status_subscriber() -> Result<()> { pub fn run_navigation_server(ctx: hiroz::context::ZContext) -> Result<()> { println!("Starting navigation service server..."); - let node = ctx.create_node("navigation_server").build()?; + let node = ctx + .create_node("navigation_server") + .with_type_description_service() + .build()?; let mut zsrv = node.create_service::("/navigate_to").build()?; println!("Navigation server ready, waiting for requests..."); diff --git a/crates/hiroz/examples/z_pubsub.rs b/crates/hiroz/examples/z_pubsub.rs index 7f954eabe..f22e5af68 100644 --- a/crates/hiroz/examples/z_pubsub.rs +++ b/crates/hiroz/examples/z_pubsub.rs @@ -11,7 +11,7 @@ use hiroz_msgs::std_msgs::String as RosString; async fn run_subscriber(ctx: ZContext, topic: String) -> Result<()> { // Create a ROS 2 node - the fundamental unit of computation // Nodes are logical groupings of publishers, subscribers, services, etc. - let node = ctx.create_node("Sub").build()?; + let node = ctx.create_node("listener").build()?; // Create a subscriber for the specified topic // The type parameter RosString determines what message type we'll receive @@ -33,7 +33,10 @@ async fn run_publisher( payload: String, ) -> Result<()> { // Create a ROS 2 node for publishing - let node = ctx.create_node("Pub").build()?; + let node = ctx + .create_node("talker") + .with_type_description_service() + .build()?; // Create a publisher for the specified topic // The type parameter RosString determines what message type we'll send diff --git a/crates/hiroz/src/context.rs b/crates/hiroz/src/context.rs index c5d6e7693..ba6d02f22 100644 --- a/crates/hiroz/src/context.rs +++ b/crates/hiroz/src/context.rs @@ -629,6 +629,7 @@ impl ZContext { Some(self.namespace.clone()) }, enable_communication_interface: true, + type_description_service: false, } } diff --git a/crates/hiroz/src/dynamic/mod.rs b/crates/hiroz/src/dynamic/mod.rs index 182d2bd48..fbd31ab85 100644 --- a/crates/hiroz/src/dynamic/mod.rs +++ b/crates/hiroz/src/dynamic/mod.rs @@ -77,6 +77,8 @@ pub use message::{DynamicMessage, DynamicMessageBuilder}; #[cfg(feature = "dynamic-schema-loader")] pub use registry::load_schema; pub use registry::{SchemaRegistry, get_schema, has_schema, register_schema}; +// Exported next to `load_schema`: the two are used as a pair -- demangle a +// graph-reported type name, then load its schema. pub use schema::{FieldSchema, FieldType, MessageSchema, MessageSchemaBuilder}; pub use serdes::DynamicSerdeCdrSerdes; pub use serialization::SerializationFormat; @@ -88,6 +90,7 @@ pub use type_description_service::{ WireKeyValue, WireTypeDescription, WireTypeSource, schema_to_wire_type_description, wire_to_schema_type_description, }; +pub use type_info::ros_type_name_from_dds; pub use value::{DynamicValue, FromDynamic, IntoDynamic}; pub(crate) use discovery::{SchemaDiscovery, discovered_schema_type_info}; diff --git a/crates/hiroz/src/dynamic/type_info.rs b/crates/hiroz/src/dynamic/type_info.rs index bc1604817..816d298fb 100644 --- a/crates/hiroz/src/dynamic/type_info.rs +++ b/crates/hiroz/src/dynamic/type_info.rs @@ -12,7 +12,13 @@ pub(crate) fn dds_type_name_from_schema(schema: &MessageSchema) -> String { + "_" } -pub(crate) fn ros_type_name_from_dds(dds_name: &str) -> String { +/// Convert a DDS-mangled type name as it appears in liveliness tokens and the +/// graph (`std_msgs::msg::dds_::String_`) into the canonical ROS form the schema +/// registry and `.msg` loader expect (`std_msgs/msg/String`). Public because +/// out-of-crate consumers (e.g. `hu`'s WASM host) resolve graph-reported types +/// against `load_schema` and must use this exact normalisation rather than +/// re-deriving one -- see issue #172. +pub fn ros_type_name_from_dds(dds_name: &str) -> String { dds_name .replace("::msg::dds_::", "/msg/") .replace("::srv::dds_::", "/srv/") diff --git a/crates/hiroz/src/lifecycle/node.rs b/crates/hiroz/src/lifecycle/node.rs index ccc39f84e..ebf882cbc 100644 --- a/crates/hiroz/src/lifecycle/node.rs +++ b/crates/hiroz/src/lifecycle/node.rs @@ -276,6 +276,7 @@ pub struct ZLifecycleNodeBuilder { pub(crate) name: String, pub(crate) namespace: Option, pub enable_communication_interface: bool, + pub(crate) type_description_service: bool, } impl ZLifecycleNodeBuilder { @@ -284,6 +285,16 @@ impl ZLifecycleNodeBuilder { self } + /// Pass-through to [`ZNodeBuilder::with_type_description_service`] on the + /// inner node, so publishers created via [`ZLifecycleNode::create_publisher`] + /// register their schemas and runtime-typed consumers can decode them. + /// + /// [`ZNodeBuilder::with_type_description_service`]: crate::node::ZNodeBuilder::with_type_description_service + pub fn with_type_description_service(mut self) -> Self { + self.type_description_service = true; + self + } + pub fn disable_communication_interface(mut self) -> Self { self.enable_communication_interface = false; self @@ -298,6 +309,9 @@ impl Builder for ZLifecycleNodeBuilder { if let Some(ns) = self.namespace { node_builder = node_builder.with_namespace(ns); } + if self.type_description_service { + node_builder = node_builder.with_type_description_service(); + } let inner = node_builder.build()?; // Shared state machine for service closures diff --git a/crates/hiroz/src/node.rs b/crates/hiroz/src/node.rs index 8a1088a65..2a14cb899 100644 --- a/crates/hiroz/src/node.rs +++ b/crates/hiroz/src/node.rs @@ -136,6 +136,15 @@ impl ZNodeBuilder { /// // Static publishers also auto-register when their message type provides /// // MessageTypeInfo::message_schema() (e.g. generated hiroz messages). /// ``` + /// + /// # Why this is opt-in, and why you probably want it + /// + /// ROS 2 (rclcpp/rclpy) serves the equivalent service by default, and + /// hiroz's own RMW layer forces it on for every node it creates. A plain + /// hiroz node does not: it must opt in here. Without it, runtime-typed + /// consumers that have no compiled knowledge of the message — `hu meter + /// echo`, dynamic subscribers, bridges — cannot obtain the schema and + /// therefore cannot decode this node's messages. pub fn with_type_description_service(mut self) -> Self { self.enable_type_desc_service = true; self From e95de3065fb34b93eaa878b17f89d671645934e2 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Thu, 20 Aug 2026 17:11:36 +0800 Subject: [PATCH 09/21] ci(release): smoke-test the macOS binary, not only the Linux one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build-binaries packages an aarch64-apple-darwin tarball and the release job publishes it, but nothing ever unpacked or ran it: smoke-test-binaries was a single ubuntu-latest job downloading bin-hu-x86_64-linux. A macOS tarball that did not install, or a hu that did not start, would have shipped with every check green. Make the job a matrix over ubuntu-latest and macos-latest, each pulling its own binary artifact and installing it with scripts/install-hu.sh. The plugins artifact is wasm32-wasip2 and platform-independent, so both legs consume the one produced by build-hu-plugins. Passing no target makes install-hu.sh's detect_target pick the tarball, so the macOS leg is also the first thing to exercise its Darwin/arm64 arm against a real release layout. The corrupted-asset refusal now damages the leg's own tarball; against a fixed target the macOS leg would have installed an intact one and proved nothing. Two spellings in the checksum step are Linux-only and had to go, or the macOS leg would have failed before testing anything about the artifact: find's GNU -printf, replaced by -exec basename, and sha256sum, which macOS does not ship. The tool is now picked at run time between sha256sum and shasum -a 256 — the same fallback install-hu.sh already makes, and the two emit and verify an identical format. aarch64-unknown-linux-gnu still has no leg: no GitHub-hosted runner can execute it. That gap is now the only one, and it is visible in the matrix. --- .github/workflows/release.yml | 62 ++++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 662dd3fce..40902dd4d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -453,16 +453,35 @@ jobs: run: .venv/bin/python -c "import hiroz_py; print('hiroz_py import ok')" smoke-test-binaries: - name: Smoke test binaries + name: Smoke test binaries (${{ matrix.target }}) needs: [build-binaries, build-hu-plugins] - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + # One leg per platform whose tarball the release publishes and whose host + # this workflow can run on. Until this was a matrix the macOS tarball was + # built, packaged and published without ever being unpacked or executed by + # anything — a broken macOS artifact would have shipped green. The + # aarch64-linux tarball has no leg here because no GitHub-hosted runner can + # execute it; it stays unexercised by this workflow, deliberately and + # visibly. + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + artifact: bin-hu-x86_64-linux + target: x86_64-unknown-linux-gnu + - os: macos-latest + artifact: bin-hu-aarch64-macos + target: aarch64-apple-darwin steps: - - name: Download hu binary (x86_64) + - name: Download hu binary uses: actions/download-artifact@v4 with: - name: bin-hu-x86_64-linux + name: ${{ matrix.artifact }} path: dist/ + # The .wasm plugins are wasm32-wasip2 and platform-independent, so both + # legs consume the single artifact the one plugins job produced. - name: Download hu plugins uses: actions/download-artifact@v4 with: @@ -473,17 +492,33 @@ jobs: # Assemble the same SHA256SUMS the release job will, so this test # installs from artifacts shaped exactly like the published ones. + # + # Written for both hosts. macOS ships neither `sha256sum` nor GNU find's + # `-printf`, so the Linux-only spellings this step used to carry would + # have failed the macOS leg on its first line — before testing anything + # about the artifact. `basename` via `-exec` is POSIX; `shasum -a 256` + # emits and checks the same format as `sha256sum`, which matters because + # install-hu.sh reads this file with its own equivalent fallback. - name: Assemble SHA256SUMS shell: bash run: | set -e + if command -v sha256sum >/dev/null 2>&1; then + SUM="sha256sum" + else + SUM="shasum -a 256" + fi + echo "using: $SUM" cd dist - files=$(find . -maxdepth 1 -type f -printf '%f\n' | sort) - printf '%s\n' "$files" | xargs sha256sum > SHA256SUMS + # Capture the file list BEFORE the redirect creates SHA256SUMS, or + # the file lists itself with the hash of the empty file and `-c` + # reports FAILED on an otherwise perfect asset set. + files=$(find . -maxdepth 1 -type f -exec basename {} \; | sort) + printf '%s\n' "$files" | xargs $SUM > SHA256SUMS cat SHA256SUMS # Self-check: the file must verify cleanly, including not listing # itself. - sha256sum -c SHA256SUMS + $SUM -c SHA256SUMS # The real question is not "does the binary start" but "can a user who # downloaded this release install it the documented way and then run @@ -491,6 +526,11 @@ jobs: # files: it is the only thing that exercises the artifact *shape*, which # is where this channel was broken — it shipped a bare ELF while the # installer expects a tarball. + # + # No target is passed: install-hu.sh's own detect_target must pick this + # host's tarball out of dist/. On the macOS leg that is the only thing + # that exercises the Darwin/arm64 arm of that function against a real + # release layout. - name: Install from the release artifacts, as a user would shell: bash run: | @@ -511,8 +551,12 @@ jobs: shell: bash run: | set -e - printf 'X' | dd of=dist/hu-*-x86_64-unknown-linux-gnu.tar.gz \ - bs=1 seek=100 conv=notrunc status=none + # Corrupt THIS leg's tarball. Corrupting a fixed target's would leave + # the macOS leg installing an intact tarball and passing a test that + # proved nothing. + tarball=$(echo dist/hu-*-${{ matrix.target }}.tar.gz) + test -f "$tarball" || { echo "FAIL: no tarball for ${{ matrix.target }}"; exit 1; } + printf 'X' | dd of="$tarball" bs=1 seek=100 conv=notrunc 2>/dev/null HUHOME="$RUNNER_TEMP/huhome-bad" mkdir -p "$HUHOME" if HOME="$HUHOME" HU_PREFIX="$HUHOME/.local" sh scripts/install-hu.sh --offline dist; then From a5985f4965a6cddc69957784b2509150dea3639b Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Thu, 20 Aug 2026 17:21:18 +0800 Subject: [PATCH 10/21] ci: exercise the macOS release path on a pull request (TEMPORARY) release.yml runs only on a `v*` tag, so the macOS smoke leg it now carries cannot run here. Its first execution would be a real release, which is the wrong place to find out that the macOS tarball does not install. This job runs the same scripts on macos-latest: build-hu-release.nu packages a native aarch64-apple-darwin build with the tag-vs-crate guard armed, install-hu.sh installs it through the Darwin/arm64 path and the shasum fallback, and the installed binary must run and discover both plugins. A corrupted tarball must still be refused. It is allowed to fail the run. A red job here is the finding. REVERT BEFORE MERGE. The block is marked in the file. --- .github/workflows/ci.yml | 82 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b497871c..ea752b5a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1005,3 +1005,85 @@ jobs: run: | source /tmp/nix-dev-env.sh nu scripts/test-ros.nu --distro ${{ matrix.distro }} + + # --------------------------------------------------------------------------- + # TEMPORARY — REVERT BEFORE MERGE. + # + # release.yml runs only on `push: tags: v*`, so the macOS smoke leg added to + # it cannot be exercised by a pull request. Its first execution would be a + # real release, which is the wrong place to discover that the macOS tarball + # does not install. + # + # This runs the SAME scripts on the same runner OS, so the path is measured + # once here instead. It is deliberately allowed to fail the run: a red job is + # the finding. Delete this block once the result has been read. + # --------------------------------------------------------------------------- + macos-release-smoke-temporary: + name: TEMPORARY macOS release smoke + runs-on: macos-latest + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + targets: wasm32-wasip2 + + # Same action and pin release.yml uses, so this tests that too. + - name: Install nushell + uses: hustcer/setup-nu@v3 + with: + version: "0.113.1" + + # The real packaging script, with the tag-vs-crate guard armed. A host + # build on macos-latest is a native aarch64-apple-darwin build, which is + # exactly what the release leg produces. + - name: Package the release artifacts + run: nu scripts/build-hu-release.nu --version 0.1.0 --out dist + + - name: Show what was produced + run: ls -l dist + + # The real installer, taking the Darwin/arm64 path through detect_target + # and the shasum fallback through sha256_of. No --target is passed, so a + # wrong detection shows up as a missing tarball rather than silently + # installing the wrong one. + - name: Install exactly as a user would + run: | + set -e + HUHOME="$RUNNER_TEMP/huhome" + mkdir -p "$HUHOME" + HOME="$HUHOME" HU_PREFIX="$HUHOME/.local" sh scripts/install-hu.sh --offline dist + echo "HUHOME=$HUHOME" >> "$GITHUB_ENV" + + # Running it is the point: a tarball that unpacks but whose binary will + # not execute would pass every check above. + - name: Run the installed binary and find its plugins + run: | + set -e + export HOME="$HUHOME" + export PATH="$HUHOME/.local/bin:$PATH" + unset HU_PLUGIN_PATH + hu --version + hu plugin list + hu plugin list | grep -q meter || { echo "FAIL: meter plugin not discovered"; exit 1; } + hu plugin list | grep -q monitor || { echo "FAIL: monitor plugin not discovered"; exit 1; } + echo "ok — macOS binary runs and discovers both plugins" + + - name: A corrupted asset must be refused + run: | + set -e + tarball=$(echo dist/hu-*-aarch64-apple-darwin.tar.gz) + test -f "$tarball" || { echo "FAIL: no macOS tarball in dist"; exit 1; } + printf 'X' | dd of="$tarball" bs=1 seek=100 conv=notrunc 2>/dev/null + BAD="$RUNNER_TEMP/huhome-bad" + mkdir -p "$BAD" + if HOME="$BAD" HU_PREFIX="$BAD/.local" sh scripts/install-hu.sh --offline dist; then + echo "FAIL: installer accepted a corrupted tarball"; exit 1 + fi + test ! -e "$BAD/.local/bin/hu" || { echo "FAIL: installed despite refusing"; exit 1; } + echo "ok — corrupted asset refused on macOS" From 38339edd88120f12ac7af2b4df53ce5dccd215ba Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Thu, 20 Aug 2026 17:35:00 +0800 Subject: [PATCH 11/21] ci: use the toolchain action whose input is actually read (TEMPORARY job) The macOS smoke job failed packaging with "can't find crate for `core`" while building the WASM plugins. The cause was a silently ignored input, not a toolchain problem. actions-rust-lang/setup-rust-toolchain takes `target`. It was given `targets`, which it reports as an unexpected input with a WARNING and then ignores, so no wasm target was ever installed. release.yml uses dtolnay/rust-toolchain, where `targets` is the correct name. This job copied the action from ci.yml's macOS legs, which need no wasm target, and the input name from release.yml. release.yml itself is correct and is unaffected. Still TEMPORARY. Revert before merge. --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea752b5a3..6612df415 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1028,8 +1028,13 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + # dtolnay/rust-toolchain, NOT actions-rust-lang/setup-rust-toolchain. + # release.yml uses this one, and only this one accepts `targets` (plural). + # The other takes `target`, so it drops `targets` with a WARNING rather + # than an error, and the wasm build then fails with "can't find crate for + # `core`" -- which reads as a toolchain problem, not a typo'd input. - name: Install Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@v1 + uses: dtolnay/rust-toolchain@stable with: targets: wasm32-wasip2 From 00f05b1cd900cd695b0e6fc5f14906ed887c8bf5 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Thu, 20 Aug 2026 17:59:15 +0800 Subject: [PATCH 12/21] fix(release): check out before downloading, not after Both smoke-test-binaries legs failed on `cd: dist: No such file or directory`. actions/checkout cleans the working directory, and it ran after the two download steps, so it deleted the dist/ they had just populated. The ordering was wrong from the day the job was written. Nothing could say so: release.yml triggers only on a `v*` tag, and no tag had been cut from this branch, so the job had never run. The v0.1.0-rc14 rehearsal is what surfaced it, which is the reason to rehearse before merging rather than after. Same shape as the apt cache key in #308: a step that depended on the checkout ran before it and failed quietly in a way that read as something else. --- .github/workflows/release.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 40902dd4d..b96d2d607 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -474,6 +474,13 @@ jobs: artifact: bin-hu-aarch64-macos target: aarch64-apple-darwin steps: + # MUST be first. `actions/checkout` cleans the working directory, so a + # checkout after the downloads deletes the `dist/` they just populated + # and the next step dies on `cd: dist: No such file or directory`. This + # job had never run -- release.yml triggers only on a `v*` tag -- so the + # ordering was wrong from the day it was written and nothing could say so. + - uses: actions/checkout@v4 + - name: Download hu binary uses: actions/download-artifact@v4 with: @@ -488,8 +495,6 @@ jobs: name: bin-hu-plugins path: dist/ - - uses: actions/checkout@v4 - # Assemble the same SHA256SUMS the release job will, so this test # installs from artifacts shaped exactly like the published ones. # From 0f5dda4670b2deceb0f5ebb017c3f3e737aa749f Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Thu, 20 Aug 2026 18:05:51 +0800 Subject: [PATCH 13/21] feat(hu): make a downloaded hu usable without prior knowledge Four gaps a first-time user hit, each closed at its source. The installer demanded a version. Its own header promised "default: the latest published" and the code died with "HU_VERSION is required" instead. It now asks the release host which release is newest. /releases/latest excludes drafts and pre-releases, so a bare install never lands on a rehearsal tag. The default download URL pointed at a tag prefix this project does not use. GitHub tags releases `v`; the URL was built from `hu-v`, a prefix that belonged to a channel this branch no longer carries, so every default install would have 404'd. `hu plugin install ` refused until the user exported a registry URL -- for the plugins the running binary was released with. It now defaults to the index published alongside its own version, pinned to CARGO_PKG_VERSION so a plugin always matches its host. `hu meter echo` subscribed and then printed nothing, which is correct on an idle topic and indistinguishable from a broken tool. It now says what it is waiting for, on stderr so piped output stays clean. Tests: the suite gains the piped `sh -s --` invocation, which no test covered -- every existing one runs the installer as a file, and piping differs in how `$0` resolves and what consumes stdin. Breaking only that path turns exactly the two new assertions red and leaves the other 22 green. 24 passed, 0 failed. Docs updated to match: the quickest path no longer passes a version twice, and the removed channel's tag shape is gone. --- .../hiroz-union/plugins/hu-meter/src/lib.rs | 7 ++++ crates/hiroz-union/src/plugin/install.rs | 33 ++++++++++++++----- docs/tools/hu-install.md | 11 +++---- scripts/install-hu.sh | 20 +++++++++-- scripts/test-install-hu.sh | 19 +++++++++++ 5 files changed, 73 insertions(+), 17 deletions(-) diff --git a/crates/hiroz-union/plugins/hu-meter/src/lib.rs b/crates/hiroz-union/plugins/hu-meter/src/lib.rs index 01450a860..adcf6737b 100644 --- a/crates/hiroz-union/plugins/hu-meter/src/lib.rs +++ b/crates/hiroz-union/plugins/hu-meter/src/lib.rs @@ -269,6 +269,13 @@ impl HuMeter { return; } }; + // Say that we are listening. A subscribe that succeeds and then prints + // nothing is indistinguishable from a broken one: the user cannot tell + // "no traffic on this topic" from "this tool is not working". stderr, + // not stdout, so `--json | jq` and friends stay clean. + render::eprintln(&format!( + "subscribed to {topic}; waiting for messages (Ctrl-C to stop)" + )); self.mode = Mode::Echo { topic, sub: Some(sub), diff --git a/crates/hiroz-union/src/plugin/install.rs b/crates/hiroz-union/src/plugin/install.rs index 15240aedb..6f5c227f1 100644 --- a/crates/hiroz-union/src/plugin/install.rs +++ b/crates/hiroz-union/src/plugin/install.rs @@ -248,19 +248,34 @@ pub fn install(source: &str, registry: Option<&str>) -> Result { install_from_registry(source, registry) } +/// The index published alongside this `hu`'s own release. +/// +/// Without a default, `hu plugin install meter` refused until the user found +/// and exported a URL — for the plugins this very binary was released with. +/// Pinned to `CARGO_PKG_VERSION` rather than "latest" so a plugin always +/// matches the host that installs it; the WIT-world check below is the +/// backstop, not the first line of defence. +fn default_registry_url() -> String { + format!( + "https://github.com/ZettaScaleLabs/hiroz/releases/download/v{v}/hu-plugins-{v}.json", + v = env!("CARGO_PKG_VERSION") + ) +} + fn install_from_registry(name: &str, registry: Option<&str>) -> Result { let index_url = registry .map(str::to_string) .or_else(|| std::env::var(DEFAULT_REGISTRY_ENV).ok()) - .ok_or_else(|| { - anyhow!( - "'{name}' is not an existing file or a URL, and no plugin registry is configured.\n\ - Set {DEFAULT_REGISTRY_ENV} to a release index URL, pass --registry, or install \ - from a downloaded file:\n hu plugin install ./hu_{name}.wasm" - ) - })?; - - let raw = http_get(&index_url)?; + .unwrap_or_else(default_registry_url); + + let raw = http_get(&index_url).with_context(|| { + format!( + "fetching the plugin index at {index_url}\n\ + '{name}' is not an existing file or a URL, so it was looked up in the index \ + published with this hu. Override with {DEFAULT_REGISTRY_ENV} or --registry, or \ + install from a downloaded file:\n hu plugin install ./hu_{name}.wasm" + ) + })?; let index: RegistryIndex = serde_json::from_slice(&raw) .with_context(|| format!("parsing plugin index at {index_url}"))?; diff --git a/docs/tools/hu-install.md b/docs/tools/hu-install.md index 2802ad42a..47311dbb0 100644 --- a/docs/tools/hu-install.md +++ b/docs/tools/hu-install.md @@ -9,22 +9,21 @@ Everything here works with no ROS 2 install. `hu` only needs to reach a Zenoh ro ## Quickest path -Set `HU_RELEASE_BASE` to the release you are installing from, and pass the same version twice — once so `curl` finds the installer, once so the installer finds the assets: +Download the installer and run it. With no arguments it asks GitHub which release is newest and installs that: ```bash -BASE=https://github.com/ZettaScaleLabs/hiroz/releases/download/v0.1.0 -curl -fsSL "$BASE/install-hu.sh" -o install-hu.sh -HU_RELEASE_BASE="$BASE" HU_VERSION=0.1.0 sh install-hu.sh +curl -fsSL https://github.com/ZettaScaleLabs/hiroz/releases/latest/download/install-hu.sh -o install-hu.sh +sh install-hu.sh ``` **Download the installer, then run it — do not pipe it into a shell.** Two failure modes look like success if you pipe. A wrong URL makes `curl -fsSL` fail silently, `sh` then reads empty input and exits 0, so you see nothing and no error. And a connection that drops mid-transfer still executes every complete line that arrived, which can leave `hu` installed with no plugins. Downloading first makes `curl`'s exit status stop the install, and gives `sh` a complete file. That downloads the binary and the plugins, verifies both against `SHA256SUMS`, installs `hu` to `~/.local/bin/` and the plugins to `~/.local/share/hu/plugins/`. -`HU_RELEASE_BASE` is not optional here, and the reason is worth knowing: piping the script through `curl` sets nothing inside it. Without that variable the installer falls back to its own built-in host, so you would fetch the script from one place and its assets from another — and the download would fail against a host you may not even be able to reach. +To install a specific version rather than the newest, pass `--version X.Y.Z`. To install from somewhere other than this project's GitHub releases, set `HU_RELEASE_BASE` to that release's download directory — then the installer looks nowhere else, which matters if you fetched the script from one place and its assets live in another. -**The base is the release's download directory, and its shape differs per channel.** GitHub publishes the whole workspace on `v` tags, so the path ends `/releases/download/v`. Releases cut on the `hu`-only `hu-v` tags end `/releases/download/hu-v` instead. Point `HU_RELEASE_BASE` at whichever one you were given; nothing below the base differs between them. +**`HU_RELEASE_BASE` is a release's download directory**, ending `/releases/download/v`. Point it at the release you were given; the filenames below it are the same either way. Set `--prefix` (or `HU_PREFIX`) to install somewhere other than `~/.local`. `hu` looks for plugins next to its own binary — under `/share/hu/plugins` — as well as in `~/.local/share/hu/plugins`, so a prefixed install finds its own plugins. diff --git a/scripts/install-hu.sh b/scripts/install-hu.sh index 8f7d25f58..ec78de6cc 100755 --- a/scripts/install-hu.sh +++ b/scripts/install-hu.sh @@ -116,6 +116,18 @@ resolve_token() { return 1 } +# Ask the release host which version is newest, so `curl ... | sh` works with +# no arguments. The header promised this default long before anything +# implemented it, and the installer died demanding HU_VERSION instead. +# +# /releases/latest excludes drafts and pre-releases, which is what we want: a +# bare install should never land on a rehearsal tag. +resolve_latest() { + curl -fsSL "https://api.github.com/repos/$DEFAULT_REPO_PATH/releases/latest" 2>/dev/null \ + | grep -m1 '"tag_name"' \ + | sed 's/.*"tag_name" *: *"//; s/".*//; s/^v//' +} + fetch() { _url="$1"; _dest="$2" # --fail so an HTTP error is an error: without it curl writes the 404 body @@ -178,7 +190,11 @@ else TARGET="$(detect_target)" if [ -z "$VERSION" ]; then - die "HU_VERSION (or --version) is required until the release index is published" + VERSION="$(resolve_latest || true)" + [ -n "$VERSION" ] || die "could not determine the latest version from + $DEFAULT_HOST/$DEFAULT_REPO_PATH +Pass one explicitly: --version X.Y.Z (or set HU_VERSION)." + info "latest release is $VERSION" fi # A pre-release tag and its asset filenames do NOT carry the same version. @@ -190,7 +206,7 @@ else CORE="${VERSION%%-*}" if [ -z "$BASE" ]; then - BASE="$DEFAULT_HOST/$DEFAULT_REPO_PATH/releases/download/hu-v$VERSION" + BASE="$DEFAULT_HOST/$DEFAULT_REPO_PATH/releases/download/v$VERSION" fi info "downloading hu $VERSION for $TARGET" diff --git a/scripts/test-install-hu.sh b/scripts/test-install-hu.sh index bc3183900..4b936d485 100755 --- a/scripts/test-install-hu.sh +++ b/scripts/test-install-hu.sh @@ -185,6 +185,25 @@ left=$(find "$H6" -type f | head -5) && check "documented uninstall removes everything" 0 0 \ || { echo " (left: $left)"; check "documented uninstall removes everything" 0 1; } +# 10. The documented entry point is `curl ... | sh`, and until now every test +# invoked the script as a file. Piping is not the same execution: the +# script arrives on stdin, so `$0` is not a path, and anything that reads +# stdin consumes the un-run remainder of itself. Feed it the way the docs +# tell a user to. +H7="$WORK/h7" +mkdir -p "$H7" +make_dist "$WORK/d7" +if cat "$INSTALLER" | env -u HU_RELEASE_TOKEN HOME="$H7" HU_PREFIX="$H7/.local" \ + sh -s -- --offline "$WORK/d7" > "$WORK/pipe.txt" 2>&1; then + check "installs when piped into sh, as the docs instruct" 0 0 +else + echo " (output: $(tail -3 "$WORK/pipe.txt" | tr '\n' '|'))" + check "installs when piped into sh, as the docs instruct" 0 1 +fi +[ -x "$H7/.local/bin/hu" ] \ + && check "the piped install produced an executable hu" 0 0 \ + || check "the piped install produced an executable hu" 0 1 + echo echo "$pass passed, $fail failed" [ "$fail" -eq 0 ] From cc9aa115803e1f061c3a28457d85f7801ec4f926 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Thu, 20 Aug 2026 18:38:04 +0800 Subject: [PATCH 14/21] ci(release): force the smoke test to fail, to prove withdrawal (TEMPORARY) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rc15 showed withdraw-release correctly staying skipped when verification passes. That is half the claim. A job that never runs also never fires, so the other half needs a failure to observe, and the condition reads this job's result — nothing else can produce it. The step is last, so every real assertion still runs and only the exit status changes. Expected on rc16: the release is created, promoted, then returned to draft by withdraw-release. publish-crates stays skipped. REVERT IMMEDIATELY AFTER THE RUN. --- .github/workflows/release.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b96d2d607..7af90935b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -913,6 +913,25 @@ jobs: test "$rc" -eq 0 || { echo "FAIL: the published release does not reproduce its own docs"; exit 1; } + # --------------------------------------------------------------------- + # TEMPORARY — rc16 ONLY. REVERT IMMEDIATELY AFTER THE RUN. + # + # withdraw-release has never been seen firing. rc15 proved it correctly + # does NOT fire when verification passes, which is only half the claim: a + # job that never runs also never fires. Forcing a failure here is the only + # way to observe the other half, because the condition depends on this + # job's result. + # + # Deliberately last, so every real assertion above still runs and this + # only changes the job's exit status. + # --------------------------------------------------------------------- + - name: DELIBERATE FAILURE — prove withdraw-release fires + run: | + echo "Everything above passed. Failing on purpose so the withdrawal" + echo "path is exercised. If this text is in a released run, the" + echo "temporary step was not reverted." + exit 1 + # If the published release cannot install itself, or cannot reproduce its own # documentation, put it back in the drawer. A draft is invisible to everyone # without push access and keeps its assets, so the run can be diagnosed from From c0126f89d371ebce0b748264be23fa923cde44a1 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Thu, 20 Aug 2026 19:08:57 +0800 Subject: [PATCH 15/21] Revert "ci(release): force the smoke test to fail, to prove withdrawal (TEMPORARY)" This reverts commit cc9aa115803e1f061c3a28457d85f7801ec4f926. --- .github/workflows/release.yml | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7af90935b..b96d2d607 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -913,25 +913,6 @@ jobs: test "$rc" -eq 0 || { echo "FAIL: the published release does not reproduce its own docs"; exit 1; } - # --------------------------------------------------------------------- - # TEMPORARY — rc16 ONLY. REVERT IMMEDIATELY AFTER THE RUN. - # - # withdraw-release has never been seen firing. rc15 proved it correctly - # does NOT fire when verification passes, which is only half the claim: a - # job that never runs also never fires. Forcing a failure here is the only - # way to observe the other half, because the condition depends on this - # job's result. - # - # Deliberately last, so every real assertion above still runs and this - # only changes the job's exit status. - # --------------------------------------------------------------------- - - name: DELIBERATE FAILURE — prove withdraw-release fires - run: | - echo "Everything above passed. Failing on purpose so the withdrawal" - echo "path is exercised. If this text is in a released run, the" - echo "temporary step was not reverted." - exit 1 - # If the published release cannot install itself, or cannot reproduce its own # documentation, put it back in the drawer. A draft is invisible to everyone # without push access and keeps its assets, so the run can be diagnosed from From 0252292ea215f7e7f616af01c83942f2bb5d1c1b Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Thu, 20 Aug 2026 19:09:35 +0800 Subject: [PATCH 16/21] ci: remove the temporary macOS smoke job It existed because release.yml runs only on a `v*` tag, so the macOS smoke leg could not be exercised by a pull request. The v0.1.0-rc15 and rc16 rehearsals ran that leg for real, on both platforms, so this job now duplicates coverage that the release pipeline provides. What it proved while it existed: build-hu-release.nu packages on macOS, install-hu.sh takes the Darwin/arm64 path and the shasum fallback, the installed binary runs and discovers both plugins, and a corrupted macOS tarball is refused. It also caught a typo'd action input -- `targets` given to an action whose input is `target`, dropped with a warning rather than an error. --- .github/workflows/ci.yml | 87 ---------------------------------------- 1 file changed, 87 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6612df415..7b497871c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1005,90 +1005,3 @@ jobs: run: | source /tmp/nix-dev-env.sh nu scripts/test-ros.nu --distro ${{ matrix.distro }} - - # --------------------------------------------------------------------------- - # TEMPORARY — REVERT BEFORE MERGE. - # - # release.yml runs only on `push: tags: v*`, so the macOS smoke leg added to - # it cannot be exercised by a pull request. Its first execution would be a - # real release, which is the wrong place to discover that the macOS tarball - # does not install. - # - # This runs the SAME scripts on the same runner OS, so the path is measured - # once here instead. It is deliberately allowed to fail the run: a red job is - # the finding. Delete this block once the result has been read. - # --------------------------------------------------------------------------- - macos-release-smoke-temporary: - name: TEMPORARY macOS release smoke - runs-on: macos-latest - permissions: - contents: read - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - # dtolnay/rust-toolchain, NOT actions-rust-lang/setup-rust-toolchain. - # release.yml uses this one, and only this one accepts `targets` (plural). - # The other takes `target`, so it drops `targets` with a WARNING rather - # than an error, and the wasm build then fails with "can't find crate for - # `core`" -- which reads as a toolchain problem, not a typo'd input. - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-wasip2 - - # Same action and pin release.yml uses, so this tests that too. - - name: Install nushell - uses: hustcer/setup-nu@v3 - with: - version: "0.113.1" - - # The real packaging script, with the tag-vs-crate guard armed. A host - # build on macos-latest is a native aarch64-apple-darwin build, which is - # exactly what the release leg produces. - - name: Package the release artifacts - run: nu scripts/build-hu-release.nu --version 0.1.0 --out dist - - - name: Show what was produced - run: ls -l dist - - # The real installer, taking the Darwin/arm64 path through detect_target - # and the shasum fallback through sha256_of. No --target is passed, so a - # wrong detection shows up as a missing tarball rather than silently - # installing the wrong one. - - name: Install exactly as a user would - run: | - set -e - HUHOME="$RUNNER_TEMP/huhome" - mkdir -p "$HUHOME" - HOME="$HUHOME" HU_PREFIX="$HUHOME/.local" sh scripts/install-hu.sh --offline dist - echo "HUHOME=$HUHOME" >> "$GITHUB_ENV" - - # Running it is the point: a tarball that unpacks but whose binary will - # not execute would pass every check above. - - name: Run the installed binary and find its plugins - run: | - set -e - export HOME="$HUHOME" - export PATH="$HUHOME/.local/bin:$PATH" - unset HU_PLUGIN_PATH - hu --version - hu plugin list - hu plugin list | grep -q meter || { echo "FAIL: meter plugin not discovered"; exit 1; } - hu plugin list | grep -q monitor || { echo "FAIL: monitor plugin not discovered"; exit 1; } - echo "ok — macOS binary runs and discovers both plugins" - - - name: A corrupted asset must be refused - run: | - set -e - tarball=$(echo dist/hu-*-aarch64-apple-darwin.tar.gz) - test -f "$tarball" || { echo "FAIL: no macOS tarball in dist"; exit 1; } - printf 'X' | dd of="$tarball" bs=1 seek=100 conv=notrunc 2>/dev/null - BAD="$RUNNER_TEMP/huhome-bad" - mkdir -p "$BAD" - if HOME="$BAD" HU_PREFIX="$BAD/.local" sh scripts/install-hu.sh --offline dist; then - echo "FAIL: installer accepted a corrupted tarball"; exit 1 - fi - test ! -e "$BAD/.local/bin/hu" || { echo "FAIL: installed despite refusing"; exit 1; } - echo "ok — corrupted asset refused on macOS" From 8fde08ae6ee9d8d100641cd452a54d8b2ebb8801 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Thu, 20 Aug 2026 21:25:29 +0800 Subject: [PATCH 17/21] docs(release): cut the comments back to what a reader needs there Comments were 30% of this branch's added lines, and release.yml carried the most. Five blocks ran 12 to 18 lines each, restating history, prior defects and rationale that the pull request and #309 already hold. Two were not merely long. The draft/promote block began "derived from the tag shape, mirroring" and then stopped mid-sentence: the scrub that removed the second release channel took the rest of the line with it. And the installer step stacked two comments that contradicted each other, the first describing a piped invocation the code deliberately stopped using. Each block now states what the line does and what breaks without it. 65 lines out, 19 in. --- .github/workflows/release.yml | 84 ++++++++--------------------------- 1 file changed, 19 insertions(+), 65 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b96d2d607..8f952bce6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -222,16 +222,10 @@ jobs: - name: Package binary shell: bash run: | - # Package through the shared script, NOT an ad-hoc `cp`. The tarball - # name and contents are the contract `install-hu.sh` reads, so if - # this channel rolls its own the two release channels drift and a - # GitHub release stops being installable — which is exactly what - # happened while this step was a bare `cp` of the ELF. - # - # --binary-from because the cross legs build with cargo zigbuild - # above; the script packages what they produced rather than - # rebuilding. --no-sums because SHA256SUMS is assembled once, in the - # release job, over every asset from every leg. + # The shared script, never an ad-hoc `cp`: the tarball name and + # contents are the contract install-hu.sh reads. --binary-from packages + # what the cross legs already built; --no-sums because SHA256SUMS is + # assembled once, in the release job, over every leg's assets. # # --version arms the tag-vs-crate guard (build-hu-release.nu:69-77), # so a tag that disagrees with the crate fails here instead of @@ -649,18 +643,10 @@ jobs: path: dist/ merge-multiple: true - # docs/tools/hu-install.md's first instruction is - # curl -fsSL /install-hu.sh | HU_VERSION=… sh - # and until this step existed that URL 404'd on every release ever cut: - # build-hu-release.nu writes six assets and the installer is not one of - # them, so nothing ever put install-hu.sh where the docs point. The - # bootstrap script is an asset like any other and belongs in dist/. - # - # It is staged HERE, before SHA256SUMS is assembled, so the checksum file - # covers it without a special case — the assembling step lists whatever - # is in dist/ at the time it runs. Staging it afterwards would publish an - # unlisted asset, which is the F2 shape: `sha256sum -c` passes while - # saying nothing about the file a user actually pipes into a shell. + # docs/tools/hu-install.md points readers at this URL, and nothing put the + # installer there. Staged BEFORE SHA256SUMS is assembled, so the checksum + # file covers it -- that step lists whatever is in dist/ when it runs, and + # an unlisted asset verifies clean while saying nothing about itself. - name: Stage the installer as a release asset shell: bash run: | @@ -710,24 +696,11 @@ jobs: grep -q ' install-hu\.sh$' SHA256SUMS \ || { echo "FAIL: install-hu.sh is not covered by SHA256SUMS"; exit 1; } - # `prerelease` is derived from the tag shape, mirroring - # Without this an rc tag publishes as a - # full release and takes the "Latest" badge from the real one — and since - # `push: tags: v*` is this workflow's only trigger, an rc tag is the ONLY - # way to rehearse it, so the rehearsal would mislabel the product. - # Created as a DRAFT, then promoted by `publish-release` once the - # artifacts are on it, and withdrawn again by `withdraw-release` if the - # post-publish checks fail. Previously this published immediately and - # `smoke-test-release-install` ran afterwards, so a failed verification - # left a public release wearing the "Latest" badge with nothing to - # de-list it. - # - # The draft cannot be verified before promotion: GitHub does not serve - # draft assets from `browser_download_url` at all -- not anonymously and - # not with a token, only the metadata is readable. So "verify, then - # publish" is not available for a download test, and the honest shape is - # "publish, verify, withdraw on failure". That narrows the exposure from - # permanent to the length of the smoke test rather than removing it. + # Draft, then promoted by `publish-release`, then withdrawn by + # `withdraw-release` if the post-publish checks fail. Publish-then-verify + # rather than verify-then-publish because GitHub does not serve draft + # assets for a download test. `prerelease` comes from the tag shape, so a + # rehearsal tag never takes the "Latest" badge. - name: Create GitHub Release (draft) uses: softprops/action-gh-release@v2 with: @@ -815,14 +788,8 @@ jobs: # so only the base directory differs. That is exactly what # HU_RELEASE_BASE overrides. export HU_RELEASE_BASE="https://github.com/${{ github.repository }}/releases/download/$TAG" - # Run the command docs/tools/hu-install.md actually tells a reader to - # run -- the installer piped from the release into a shell -- not an - # equivalent-looking `sh scripts/install-hu.sh` against the repo copy. - # Two things only this form exercises: reading the script from stdin, - # and passing HU_RELEASE_BASE/HU_VERSION through the pipeline into it. - # The published-vs-source diff below proves the asset is right; it - # cannot prove the documented invocation works. - # Download-then-run, exactly as docs/tools/hu-install.md instructs. + # Download-then-run, exactly as docs/tools/hu-install.md instructs, + # against the published asset rather than the repo copy. # `&&` propagates curl's status, so a 404 stops here. Piping instead # would not: a pipeline's status is its last command's, so `sh` would # read empty stdin, exit 0, and pass this step having installed @@ -868,23 +835,10 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable - # `hu plugin list` above is NOT sufficient on its own, and it is worth - # being explicit about why: discovery derives a plugin's name from its - # FILENAME and never opens the component - # (crates/hiroz-union/src/plugin/wasm/mod.rs), so an empty file named - # hu_meter.wasm satisfies every grep above. Until this step existed that - # was the only check this channel made against what it had just - # published — a release could ship an unloadable plugin and go green. - # The suite has already run against a published - # release; GitHub did not, so the two channels disagreed on what - # "released" proves. - # - # The publisher is built from the SOURCE CHECKOUT, deliberately, not - # taken from the artifact: `hu` cannot generate its own traffic (F12 — - # `hu meter pub` needs a schema no release ships), so a suite whose only - # fixture is `hu router` measures an empty graph and degenerates into an - # exit-status check. That is the exact hole a truncated plugin slips - # through. + # From the source checkout, not the artifact: `hu` cannot generate its own + # traffic, because no release ships message definitions (#309, G2). With + # only `hu router` the suite measures an empty graph and decays into an + # exit-status check -- which a truncated plugin passes. - name: Build the traffic fixture from source run: cargo build --release --example z_pubsub -p hiroz From 778b9f4b49214c755c454f4067885c911391fed3 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Thu, 20 Aug 2026 21:35:11 +0800 Subject: [PATCH 18/21] docs: cut the remaining long comment blocks Four blocks across three files, 12 to 31 lines each, restating defect history the pull request and #309 already hold. Each now states what the code does and what breaks without it. One was damaged rather than merely long. build-hu-release.nu's header read "Both / every release platform calls it, so they cannot drift / cannot drift apart" -- the scrub that removed the second release channel cut a sentence in half and left the remainder duplicated. That is the second such break found; release.yml carried the first. ros.rs 50 comment lines to 22, build-hu-release.nu 22 to 15, test-hu-docs-repro.nu 40 to 29. --- .../hiroz-union/src/plugin/wasm/host/ros.rs | 66 ++++++------------- scripts/build-hu-release.nu | 15 ++--- scripts/test-hu-docs-repro.nu | 22 ++----- 3 files changed, 30 insertions(+), 73 deletions(-) diff --git a/crates/hiroz-union/src/plugin/wasm/host/ros.rs b/crates/hiroz-union/src/plugin/wasm/host/ros.rs index ff6d554b1..003636ebe 100644 --- a/crates/hiroz-union/src/plugin/wasm/host/ros.rs +++ b/crates/hiroz-union/src/plugin/wasm/host/ros.rs @@ -102,37 +102,18 @@ fn dyn_sub_from_local_msg(node: &ZNode, graph: &Graph, topic: &str) -> Result Result, PluginError> { self.require_perm(hu::plugin::types::Permission::SubscribeTopic)?; - // Resolve the schema *before* returning a Subscription. Doing it inside - // the spawned task instead made every failure structurally invisible: - // `subscribe` had already returned Ok, so the plugin's own "Failed to - // subscribe" branch could never fire, and the dropped channel sender - // read as a permanently idle topic (`try_recv` -> None forever). That is - // why `hu meter echo` printed nothing and `hu meter delay` never exited. - // - // Blocking here is the same trade `encode_yaml_to_cdr` and - // `HostServiceClient::call` already make: `block_in_place` hands the - // worker off to the blocking pool, so the zenoh I/O and graph liveliness - // traffic this discovery *depends on* keep progressing while we wait. + // Resolve the schema *before* returning a Subscription. Inside the spawned + // task every failure was invisible: `subscribe` had already returned Ok, + // so the plugin's error branch could never fire and a dropped sender read + // as a permanently idle topic. // - // The `HostBlockGuard` is what makes that trade safe here. The guest is - // dispatched under a ~3 s epoch budget measured in wall clock, so a - // multi-second wait would trap the guest on return -- before it could run - // the very error branch this function exists to give it. The guard stops - // the epoch ticker for the duration of the wait. Without it, the failure - // path below is unobservable: the plugin dies mid-dispatch, `exit_code` - // stays `None`, and the command hangs instead of reporting. + // `block_in_place` hands the worker to the blocking pool, so the zenoh + // I/O this discovery depends on keeps progressing. `HostBlockGuard` stops + // the epoch ticker: the guest runs under a ~3 s wall-clock budget, so + // without it a long wait traps the guest before it can run that error + // branch, and the command hangs instead of reporting. const SUB_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5); let node = self.engine.node.clone(); let discovered = { diff --git a/scripts/build-hu-release.nu b/scripts/build-hu-release.nu index 90502f15c..f1a5778e3 100755 --- a/scripts/build-hu-release.nu +++ b/scripts/build-hu-release.nu @@ -1,9 +1,8 @@ #!/usr/bin/env nu # Produce the `hu` release artifact set into a dist directory. # -# This is the single source of truth for what a `hu` release contains. Both -# every release platform calls it, so they cannot drift -# cannot drift apart in what they ship. +# The single source of truth for what a `hu` release contains. Every release +# platform calls it, so they cannot drift apart in what they ship. # # hu--.tar.gz hu binary + LICENSE + install README # hu_meter-.wasm wasm32-wasip2 — platform-independent @@ -13,13 +12,9 @@ # install-hu.sh the installer the release notes tell users to curl # SHA256SUMS covers every file above # -# The plugins are the point: without them `hu meter` and `hu monitor` do not -# exist, because they are not built into the binary. -# -# `install-hu.sh` ships for a duller but equally concrete reason: every set of -# release notes opens with `curl -fsSL /install-hu.sh | sh`, and until -# the script was staged here it was in the repo and nowhere else — so the -# documented first command of a release 404'd on both channels. +# The plugins are the point: `hu meter` and `hu monitor` do not exist without +# them, because they are not built into the binary. `install-hu.sh` ships +# because the release notes tell users to curl it from the release. const PLUGIN_DIR = "crates/hiroz-union/plugins" const WIT_WORLD = "hu:plugin@0.1.0" diff --git a/scripts/test-hu-docs-repro.nu b/scripts/test-hu-docs-repro.nu index b1f3b0e89..b57afb55e 100755 --- a/scripts/test-hu-docs-repro.nu +++ b/scripts/test-hu-docs-repro.nu @@ -21,23 +21,13 @@ # fence default for the single command that follows. This one IS visible, # so use it only where the note earns its place. # -# An unannotated command line defaults to `run` and MUST succeed. Nothing is -# silently ignored — a docs command missing from the report fails the suite. +# An unannotated command defaults to `run` and MUST succeed. Nothing is silently +# ignored: a docs command missing from the report fails the suite. # -# TWO command classes are extracted, and the second one is easy to forget: -# -# 1. `hu ...` — the promises about the tool. -# 2. INSTALL commands (`curl`, `install-hu.sh`, `tar`, `sha256sum`, `cp`, …) -# — the promises about *getting* the tool. -# -# Class 2 was invisible until 2026-08-15, because extraction took only lines -# starting with `hu`. docs/tools/hu-install.md was in DOC_FILES and carried -# `repro:` directives, so it looked covered while every load-bearing line in it -# — the `curl … | sh` one-liner, `install-hu.sh --offline`, `tar -xzf`, -# `sha256sum -c` — was silently dropped. A one-liner pointing at a URL nothing -# served shipped in the docs under that blind spot. A suite that cannot see a -# command cannot report it missing, and absence of output is UNKNOWN, never -# SUCCESS. +# Two classes are extracted. `hu ...`, the promises about the tool, and INSTALL +# commands (`curl`, `install-hu.sh`, `tar`, `sha256sum`, `cp`), the promises +# about getting it. The second is easy to forget, and was missed once -- a suite +# that cannot see a command cannot report it missing. const DOC_FILES = [ "docs/tools/hu.md" From 3aa1ba6e8085f8667215b4b81940bf347f79a462 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Thu, 20 Aug 2026 21:49:29 +0800 Subject: [PATCH 19/21] fix(ci): invalidate the poisoned interop Rust cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The humble interop leg failed to compile hiroz's build script: libstabby_macros-e78ef2ef160687eb.so: libc.so.6: version `GLIBC_2.39' not found A cached proc-macro built against glibc 2.39 cannot load in the humble container, which is 22.04 and ships 2.35. humble is the only 22.04 image in the matrix. Not transient. A rerun restored the same entry, reported "Cache restored successfully", and failed on the same object hash. The control is the sibling ROS Tests humble leg: same image, different cache key, passing. So the fault is the entry, not the image and not the code — the three commits before this one changed comments only. Bumping shared-key forces one cold rebuild for this leg. ci.yml already carries two -v2 keys, so this failure mode has been met before. --- .github/workflows/test.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4c8264c2c..d2077bf75 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -219,7 +219,10 @@ jobs: - name: Setup Rust cache uses: Swatinem/rust-cache@v2 with: - shared-key: ${{ matrix.distro }}-interop + # -v2 invalidates an entry holding a proc-macro built against a newer + # glibc. humble is the only 22.04 image, and the restored + # libstabby_macros.so required GLIBC_2.39. Bump again if it recurs. + shared-key: ${{ matrix.distro }}-interop-v2 - name: Install cargo-nextest uses: taiki-e/install-action@v2 From ff4c47f35997e6a0660d3225d710f093b0cb3fc2 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Thu, 20 Aug 2026 22:02:45 +0800 Subject: [PATCH 20/21] fix(ci): stop the interop legs sharing one Rust cache The humble leg could not compile hiroz's build script: libstabby_macros-...so: libc.so.6: version `GLIBC_2.39' not found The cause is a second cache nobody declared. actions-rust-lang/setup-rust-toolchain runs its own rust-cache unless told not to, and keys it on the JOB ID. That id is `interop_test` for every distro in the matrix, so all four legs shared one target/. humble is the only 22.04 image, so it restored proc-macros the 24.04 legs had built and failed to load them. The log names it outright: Restored from cache key "v0-rust-interop_test-Linux-x64-..." full match: true An earlier commit bumped the shared-key of the *explicit* cache below, which is already per-distro and was never the problem. That did not help, and the leg failed again with the same object hash. `cache: false` leaves only the per-distro cache. The -v2 suffix stays, to discard anything saved while the shared cache was in use. Blast radius: the embedded key carries OS and arch but not the container image, so only a job matrixed over images on one platform can collide. The other four call sites matrix over ubuntu and macOS, which the key already separates, and rmw-zenoh-rs.yml pins a single image. --- .github/workflows/test.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d2077bf75..5145b0bd3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -214,14 +214,19 @@ jobs: echo "Fixed rmw_zenoh_cpp config by removing invalid transport_optimization section" fi + # `cache: false` is load-bearing. This action runs its own rust-cache with + # a key derived from the JOB ID, which is `interop_test` for every distro + # in the matrix -- so the four legs shared one target/. humble is the only + # 22.04 image, so it restored proc-macros the 24.04 legs had built and + # failed with "GLIBC_2.39 not found". The cache below is per-distro. - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + cache: false - name: Setup Rust cache uses: Swatinem/rust-cache@v2 with: - # -v2 invalidates an entry holding a proc-macro built against a newer - # glibc. humble is the only 22.04 image, and the restored - # libstabby_macros.so required GLIBC_2.39. Bump again if it recurs. + # -v2 discards entries saved while the shared cache above was in use. shared-key: ${{ matrix.distro }}-interop-v2 - name: Install cargo-nextest From 44d11439efe5a32471e03eda8b952404881221c9 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Thu, 20 Aug 2026 23:20:18 +0800 Subject: [PATCH 21/21] refactor(release): assemble SHA256SUMS in one place There were two copies, and they had already drifted. The smoke test used a run-time pick between sha256sum and shasum, and `find -exec basename`. The release job used a bare `sha256sum` and GNU-only `find -printf`, so it would fail on any host without coreutils. It works today only because that job runs on ubuntu. Both carried the same "capture the list before the redirect" warning, written twice in different words. scripts/ci/write-sha256sums.sh is now the only implementation. It refuses a missing directory, an empty one, and no argument, and it verifies what it wrote. The release job keeps its two content assertions, which are not shared: a release must cover a hu tarball and the installer. Folding exposed a coverage hole. Replacing the assembly with `true` left the detector green -- nothing asserted the asset set was checksummed at all. Two assertions now pin it, and both were seen failing: deleting a call site turns one red, reintroducing an inline `xargs sha256sum` turns the other red. 53 passed, 0 failed. --- .github/workflows/release.yml | 49 ++++------------------- scripts/ci/write-sha256sums.sh | 38 ++++++++++++++++++ scripts/test-release-version-semantics.sh | 8 ++++ 3 files changed, 54 insertions(+), 41 deletions(-) create mode 100755 scripts/ci/write-sha256sums.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f952bce6..9afc44cf6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -495,29 +495,10 @@ jobs: # Written for both hosts. macOS ships neither `sha256sum` nor GNU find's # `-printf`, so the Linux-only spellings this step used to carry would # have failed the macOS leg on its first line — before testing anything - # about the artifact. `basename` via `-exec` is POSIX; `shasum -a 256` - # emits and checks the same format as `sha256sum`, which matters because - # install-hu.sh reads this file with its own equivalent fallback. + # about the artifact. - name: Assemble SHA256SUMS shell: bash - run: | - set -e - if command -v sha256sum >/dev/null 2>&1; then - SUM="sha256sum" - else - SUM="shasum -a 256" - fi - echo "using: $SUM" - cd dist - # Capture the file list BEFORE the redirect creates SHA256SUMS, or - # the file lists itself with the hash of the empty file and `-c` - # reports FAILED on an otherwise perfect asset set. - files=$(find . -maxdepth 1 -type f -exec basename {} \; | sort) - printf '%s\n' "$files" | xargs $SUM > SHA256SUMS - cat SHA256SUMS - # Self-check: the file must verify cleanly, including not listing - # itself. - $SUM -c SHA256SUMS + run: scripts/ci/write-sha256sums.sh dist # The real question is not "does the binary start" but "can a user who # downloaded this release install it the documented way and then run @@ -672,27 +653,13 @@ jobs: shell: bash run: | set -e + scripts/ci/write-sha256sums.sh dist cd dist - rm -f SHA256SUMS - # Bare basenames, sorted: the format install-hu.sh and - # `sha256sum -c` both expect. - # - # Capture the file list BEFORE creating any output file. A shell - # redirect creates its target before the command on the left runs, - # so `find ... > SHA256SUMS` lists SHA256SUMS itself, with the hash - # of the empty file — and `sha256sum -c` then reports - # "SHA256SUMS: FAILED" on an otherwise perfect release. Redirecting - # to a temp name instead just moves the bug to the temp name. - files=$(find . -maxdepth 1 -type f -printf '%f\n' | sort) - printf '%s\n' "$files" | xargs sha256sum > SHA256SUMS - echo "covered $(wc -l < SHA256SUMS) assets:" - cat SHA256SUMS - # A release with no hu tarball listed is the defect this replaces — - # fail loudly rather than publishing it again. - grep -q 'hu-.*\.tar\.gz' SHA256SUMS - # Likewise for the installer: the docs tell readers to pipe it into a - # shell, so it must be published AND covered. An unlisted installer - # would still be downloadable and would still be unverifiable. + # The assembly is shared; these two assertions are not. A release with + # no hu tarball, or with the installer the docs point at left + # uncovered, is the defect this step replaces. + grep -q 'hu-.*\.tar\.gz' SHA256SUMS \ + || { echo "FAIL: no hu tarball covered by SHA256SUMS"; exit 1; } grep -q ' install-hu\.sh$' SHA256SUMS \ || { echo "FAIL: install-hu.sh is not covered by SHA256SUMS"; exit 1; } diff --git a/scripts/ci/write-sha256sums.sh b/scripts/ci/write-sha256sums.sh new file mode 100755 index 000000000..55d6825ff --- /dev/null +++ b/scripts/ci/write-sha256sums.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# Write SHA256SUMS over every file in a directory, then verify it. +# +# scripts/ci/write-sha256sums.sh dist +# +# One implementation, because there were two and they drifted: the release job +# used GNU-only `find -printf` and a bare `sha256sum`, so it would have failed +# on any host without coreutils. +# +# Two traps this exists to hold: +# - The file list is captured BEFORE the redirect. A redirect creates its +# target first, so `find ... > SHA256SUMS` lists SHA256SUMS itself with the +# hash of an empty file, and `-c` then fails an otherwise perfect set. +# - `shasum -a 256` emits and checks the same format as `sha256sum`, which +# matters because install-hu.sh reads this file with the same fallback. +set -eu + +DIR="${1:?usage: write-sha256sums.sh }" +[ -d "$DIR" ] || { echo "write-sha256sums: $DIR is not a directory" >&2; exit 1; } + +if command -v sha256sum > /dev/null 2>&1; then + SUM="sha256sum" +else + SUM="shasum -a 256" +fi +echo "write-sha256sums: using $SUM in $DIR" + +cd "$DIR" +rm -f SHA256SUMS +# `-exec basename` rather than `-printf`, which is GNU-only. +files=$(find . -maxdepth 1 -type f -exec basename {} \; | sort) +[ -n "$files" ] || { echo "write-sha256sums: $DIR holds no files" >&2; exit 1; } +printf '%s\n' "$files" | xargs $SUM > SHA256SUMS + +echo "write-sha256sums: covered $(wc -l < SHA256SUMS) files" +cat SHA256SUMS +# Must verify clean, which also proves it does not list itself. +$SUM -c SHA256SUMS diff --git a/scripts/test-release-version-semantics.sh b/scripts/test-release-version-semantics.sh index 3bbdfa9fd..2b1c60bdb 100755 --- a/scripts/test-release-version-semantics.sh +++ b/scripts/test-release-version-semantics.sh @@ -303,6 +303,14 @@ echo "== the release is not public until it verifies ==" # afterwards, so a failed check left a public release with nothing to de-list # it. GitHub will not serve draft assets for a download test, so the reachable # shape is publish -> verify -> withdraw on failure. +# Both the smoke test and the release job must checksum through the shared +# script. They were two inline copies until they drifted: the release job's used +# GNU-only `find -printf` and a bare `sha256sum`, so it would have failed on any +# host without coreutils. Nothing asserted the assembly existed at all. +SUMS_CALLS=$(grep -c 'write-sha256sums\.sh' "$GH") +check "both jobs checksum through the shared script" 2 "$SUMS_CALLS" +grep_must_not "$GH" "no inline checksum assembly remains" 'xargs sha256sum' + grep_must "$GH" "the release is created as a draft" 'draft: true' grep_must "$GH" "a job promotes the draft" '\-\-draft=false' grep_must "$GH" "a job withdraws it again" '\-\-draft=true'