Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
418 changes: 402 additions & 16 deletions .github/workflows/release.yml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 17 additions & 8 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>=<version>` — 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. Nothing yet enforces that automatically, so a reintroduced literal is caught by review, not by CI.

`hu` ships from the workspace's own `v*` tags. It has neither an independent number nor, at present, an independent tag namespace.

> **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 <name>` 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:
Expand All @@ -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<crate-version>-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
Expand All @@ -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-<ver>.tar.gz` and the `hu-plugins-<ver>.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

Expand Down
1 change: 1 addition & 0 deletions crates/hiroz-union/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
144 changes: 141 additions & 3 deletions crates/hiroz-union/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ impl From<Backend> 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
)]
Expand Down Expand Up @@ -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<String>,
},
/// Remove an installed plugin
Uninstall {
/// Plugin name as shown by `hu plugin list` (e.g. `meter`)
name: String,
},
}

#[tokio::main]
Expand All @@ -140,6 +157,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
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;
}
Expand Down Expand Up @@ -318,33 +341,148 @@ fn run_plugin_list(json: bool) -> Result<(), Box<dyn std::error::Error + Send +
#[cfg(feature = "wasm-plugins")]
{
let plugins = plugin::wasm::discover_wasm_plugins();
// Installed plugins carry a version and provenance; ones dropped on
// the path by hand do not, and the listing should say so rather than
// present them as equivalent.
let db = plugin::install::load_db();
// Match the managed FILE, not just the name. Discovery can return the
// same plugin name from `$HU_PLUGIN_PATH`, the executable-relative
// prefix and the managed directory; matching on the name alone labels a
// development build with the installed plugin's version and source.
let managed_dir = plugin::install::install_dir().ok();
let meta = |name: &str, path: &std::path::Path| {
db.plugins.iter().find(|p| {
p.name == name
&& managed_dir
.as_ref()
.is_some_and(|d| d.join(&p.file) == *path)
})
};

if json {
let entries: Vec<_> = plugins
.iter()
.map(|(name, path)| {
let m = meta(name, path);
serde_json::json!({
"name": name,
"path": path.to_string_lossy(),
"kind": "wasm",
"version": m.and_then(|m| m.version.clone()),
"origin": m.and_then(|m| m.origin.clone()),
"source": m.map(|m| m.source.clone()).unwrap_or_else(|| "unmanaged".into()),
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&entries)?);
} else {
if plugins.is_empty() {
println!("No WASM plugins found in $HU_PLUGIN_PATH or ~/.local/share/hu/plugins/.");
println!("Install one with: hu plugin install <file|url|name>");
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, path);
println!(
"{:<16} {:<10} {:<10} {}",
name,
m.and_then(|m| m.version.as_deref()).unwrap_or("-"),
m.map(|m| source_label(&m.source)).unwrap_or("unmanaged"),
path.to_string_lossy()
);
}
}
Ok(())
}
}

#[cfg(feature = "wasm-plugins")]
/// Render the recorded source kind. `InstalledEntry::source` holds one of
/// `local`, `url` or `registry`; anything else comes from a record written by
/// an older hu, and is shown verbatim rather than guessed at.
fn source_label(source: &str) -> &str {
match source {
"url" => "download",
other => other,
}
}

fn run_plugin_install(
source: &str,
registry: Option<&str>,
json: bool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
#[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<dyn std::error::Error + Send + Sync>> {
#[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,
Expand Down
Loading
Loading