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/.github/workflows/release.yml b/.github/workflows/release.yml index c3eec741f..9dcc1d437 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -794,8 +794,49 @@ jobs: || { 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" - # If the published release cannot install itself, put it back in the drawer. - # A draft is invisible to everyone without push access and keeps its assets, + - name: Install nushell + uses: hustcer/setup-nu@v3 + with: + version: "0.113.1" + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + # 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 + + - 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. # diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4c8264c2c..5145b0bd3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -214,12 +214,20 @@ 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: - shared-key: ${{ matrix.distro }}-interop + # -v2 discards entries saved while the shared cache above was in use. + shared-key: ${{ matrix.distro }}-interop-v2 - name: Install cargo-nextest uses: taiki-e/install-action@v2 diff --git a/RELEASING.md b/RELEASING.md index 99ba088f1..7794e932e 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -14,7 +14,7 @@ Before releasing, bump the version in all three places consistently: 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. Nothing yet enforces that automatically, so a reintroduced literal is caught by review, not by CI. +**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. `ci.yml` runs it on every pull request. `hu` ships from the workspace's own `v*` tags. It has neither an independent number nor, at present, an independent tag namespace. 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-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/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/build-hu-release.nu b/scripts/build-hu-release.nu index 566f923f9..b02462211 100755 --- a/scripts/build-hu-release.nu +++ b/scripts/build-hu-release.nu @@ -333,7 +333,7 @@ failures. Check the files you actually downloaded: Older coreutils has no `--ignore-missing`. Check one file instead: - grep hu-($ver)-($target).tar.gz SHA256SUMS | sha256sum -c - + grep SHA256SUMS | sha256sum -c - ## Documentation 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-hu-docs-repro.nu b/scripts/test-hu-docs-repro.nu new file mode 100755 index 000000000..5bb644a19 --- /dev/null +++ b/scripts/test-hu-docs-repro.nu @@ -0,0 +1,555 @@ +#!/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 defaults to `run` and MUST succeed. Nothing is silently +# ignored: a docs command missing from the report fails the suite. +# +# 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" + "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. + +# Reduce a documented line to the command it runs, so the `hu` test below sees +# through the shell forms the docs use. +# +# Handled, because the docs contain each of them: +# VAR=value hu ... leading environment assignments +# name=$(hu ...) a command substitution assigning the result +# $(hu ...) / `hu ...` a bare substitution +# +# Anything else is returned unchanged, so an unrecognised form still reaches the +# `hu` test and is either matched or reported -- never dropped for being odd. +def strip-shell-wrappers [line: string] { + mut c = ($line | str trim) + + # `name=$(...)` or a bare `$(...)`: take what is inside. + let sub = ($c | parse -r '^(?:[A-Za-z_][A-Za-z0-9_]*=)?\$\((?.+)\)$') + if ($sub | is-not-empty) { + $c = ($sub | get 0.inner | str trim) + } else { + let bt = ($c | parse -r '^(?:[A-Za-z_][A-Za-z0-9_]*=)?`(?.+)`$') + if ($bt | is-not-empty) { $c = ($bt | get 0.inner | str trim) } + } + + # Leading `VAR=value` assignments, however many. + mut guard = 0 + while $guard < 8 { + let m = ($c | parse -r '^[A-Za-z_][A-Za-z0-9_]*=[^\s]* +(?.+)$') + if ($m | is-empty) { break } + $c = ($m | get 0.rest | str trim) + $guard = $guard + 1 + } + + # A pipeline's first stage is the command being documented. + ($c | split row "|" | get 0 | str trim) +} + +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. + # Strip the shell forms the docs actually use before asking whether this + # is an `hu` line. `starts-with "hu "` alone drops + # `HU_WEB_BIND=0.0.0.0 hu web` and `rate=$(hu meter hz ... )`, which are + # both in the docs today -- so their `repro:` directives never reach the + # report either, and the suite silently covers less than it claims. + let bare = (strip-shell-wrappers $cmd) + let is_hu = (($bare | str starts-with "hu ") or ($bare == "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 } +} diff --git a/scripts/test-install-hu.sh b/scripts/test-install-hu.sh new file mode 100755 index 000000000..4b936d485 --- /dev/null +++ b/scripts/test-install-hu.sh @@ -0,0 +1,209 @@ +#!/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; } + +# 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 ] 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). diff --git a/scripts/test-release-version-semantics.sh b/scripts/test-release-version-semantics.sh new file mode 100755 index 000000000..701e31473 --- /dev/null +++ b/scripts/test-release-version-semantics.sh @@ -0,0 +1,362 @@ +#!/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 + +# Evaluate the Nth derivation block, 1-based. `release.yml` carries one per +# release producer -- `build-binaries` and `build-hu-plugins` today -- and they +# are independent copies. A `grep -m1` here validated only the first, so the +# second could regress to `HU_CORE=$V` and this test would stay green. +derive_nth() { # file, ref-var-name, ref-value, assignment-pattern, n + _f="$1"; _var="$2"; _ref="$3"; _assign="$4"; _n="$5" + { + grep -- "$_assign" "$_f" | sed -n "${_n}p" + grep 'echo "HU_VERSION=' "$_f" | sed -n "${_n}p" + grep 'echo "HU_CORE=' "$_f" | sed -n "${_n}p" + } | 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" +} + +# How many derivation blocks the file has. Asserted below, so a producer added +# without a matching check is a failure rather than a silent gap. +derivation_count() { # file, assignment-pattern + grep -c -- "$2" "$1" +} + +# Every derivation block must agree. A release whose binary leg and plugin leg +# disagree about the core version publishes assets that cannot install together. +check_derivation() { # label, file, ref-var, ref, assign-pattern, want_version, want_core + _total="$(derivation_count "$2" "$5")" + if [ "$_total" -lt 1 ]; then + bad "$1 (no derivation block matched $5)" + return + fi + _i=1 + while [ "$_i" -le "$_total" ]; do + check_one_derivation "$1 [block $_i/$_total]" "$2" "$3" "$4" "$5" "$6" "$7" "$_i" + _i=$((_i + 1)) + done +} + +check_one_derivation() { # label, file, ref-var, ref, assign-pattern, want_version, want_core, n + _out="$(derive_nth "$2" "$3" "$4" "$5" "$8")" + _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. +# 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' +# 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'" +# `!= 'success'`, not `== 'failure'`. GitHub reports a cancelled job as +# `cancelled` and a skipped one as `skipped`, and promotion has already happened +# by then -- so equality on 'failure' leaves an unverified release public with +# the *Latest* badge, which is the answer install-hu.sh's resolve_latest gives. +grep_must "$GH" "the withdraw job fires on any non-success download test" \ + "needs.smoke-test-release-install.result != 'success'" +grep_must_not "$GH" "the withdraw job does not test equality with failure" \ + "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."