From bdf68ea62708db881732e99faaac3ad74a42c0de Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Fri, 21 Aug 2026 01:10:28 +0800 Subject: [PATCH 1/4] test(hu): verify the release against its own documentation Every defect the release work uncovered lived in a path no test executed, and none would have failed a build. This is the coverage that would have caught them. Runs every documented hu command against an installed binary in a scratch HOME with the plugin path unset, so the suite measures what a download gives a reader rather than what a build tree gives a developer. Encodes the tag-to-asset-name rule as assertions, because the release workflow never runs on a pull request. Adds the installer's own refusal tests. Also repairs two gaps in existing CI: hiroz-union's tests never ran, because --lib matches nothing on a binary-only crate, and the web-plugins feature was never compiled. Both run now. --- .github/workflows/ci.yml | 179 ++++++++ .github/workflows/test.yml | 10 +- docs/getting-started/quick-start.md | 2 + docs/tools/hu-vs-ros2cli.md | 13 + docs/tools/hu.md | 121 ++++- docs/tools/why-hu.md | 1 + docs/user-guide/examples.md | 6 + flake.nix | 36 ++ scripts/ci/hu-tests.sh | 102 +++++ scripts/test-hu-docs-repro.nu | 513 ++++++++++++++++++++++ scripts/test-install-hu.sh | 209 +++++++++ scripts/test-pure-rust.nu | 13 +- scripts/test-release-version-semantics.sh | 331 ++++++++++++++ scripts/test-release-workflow.nu | 21 +- 14 files changed, 1536 insertions(+), 21 deletions(-) create mode 100755 scripts/test-hu-docs-repro.nu create mode 100755 scripts/test-install-hu.sh create mode 100755 scripts/test-release-version-semantics.sh 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/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/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/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..b57afb55e --- /dev/null +++ b/scripts/test-hu-docs-repro.nu @@ -0,0 +1,513 @@ +#!/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. +def extract-file [file: string] { + mut out = [] + mut in_fence = false + mut fence_is_shell = false + mut pending = null # `# repro:` directive awaiting its command + mut pending_expect = null # `# repro-expect:` regex awaiting its command + mut fence_default = null # `` covering the whole fence + mut next_fence_default = null + mut fence_env = [] # `export K=V` seen earlier in this fence + mut cont = "" # accumulator for `\`-continued lines + mut cont_line = 0 + + # `--raw`: nu parses .md into a table otherwise, and we need the text. + for entry in (open --raw $file | lines | enumerate) { + let lineno = $entry.index + 1 + let raw = $entry.item + let line = ($raw | str trim) + + if ($line | str starts-with "```") { + if $in_fence { + $in_fence = false + $fence_is_shell = false + $fence_env = [] + $pending = null + $pending_expect = null + $fence_default = null + $cont = "" + } else { + let info = ($line | str replace --regex '^`+' "" | str trim | split row " " | first | default "") + $in_fence = true + $fence_is_shell = ($info in $SHELL_FENCES) + $fence_env = [] + $pending = null + $pending_expect = null + $fence_default = $next_fence_default + $cont = "" + } + $next_fence_default = null + continue + } + + if not $in_fence { + # Fence-level directive: an HTML comment, so it never renders. + # Applies to the next fence only; a blank line does not cancel it, + # but any other prose does — keeping its scope obvious to a reader. + if ($line | str starts-with "" "" + | str trim) + } else if $line != "" { + $next_fence_default = null + } + continue + } + + if not $fence_is_shell { continue } + + # Mid-continuation: keep accumulating until a line without a trailing `\`. + if $cont != "" { + if ($line | str ends-with "\\") { + $cont = $"($cont) ($line | str substring 0..<(($line | str length) - 1) | str trim)" + } else { + let full = $"($cont) ($line)" + $out = ($out | append (make-entry $file $cont_line $full ($pending | default $fence_default) $fence_env $pending_expect)) + $cont = "" + $pending = null + $pending_expect = null + } + continue + } + + if ($line | str starts-with "# repro:") { + $pending = ($line | str replace "# repro:" "" | str trim) + continue + } + + # `# repro-expect: ` asserts the command's OUTPUT, not just its + # exit status. It is the difference between "the plugin dispatched" and + # "the plugin computed the right answer" — an audit found every release + # assertion was the former, so a truncated .wasm could pass. Kept + # separate from `# repro:` so a command can carry both a class and an + # expectation without inventing a combined grammar. + if ($line | str starts-with "# repro-expect:") { + $pending_expect = ($line | str replace "# repro-expect:" "" | str trim) + continue + } + + # Other comments and output lines carry no command. + if ($line | str starts-with "#") or ($line == "") { continue } + + if ($line | str starts-with "export ") { + $fence_env = ($fence_env | append ($line | str replace "export " "" | str trim)) + continue + } + + # `$ hu ...` prompt form is accepted too. + let cmd = if ($line | str starts-with "$ ") { $line | str substring 2.. | str trim } else { $line } + + # The `hu` test is unchanged, and is asked FIRST: an install-class head + # can never also be an `hu` line, so nothing is counted twice and the + # set of extracted `hu` commands is byte-for-byte what it was before. + let is_hu = (($cmd | str starts-with "hu ") or ($cmd == "hu")) + # An install line is classified by exactly the same `repro:` directives + # as an `hu` line, and — deliberately — defaults to `run` in exactly + # the same way. A command the fixture cannot satisfy must be declared + # `skip ` in the doc, next to the promise, where a reader sees + # the reason. It must never be dropped by the extractor, which is the + # failure mode this whole change exists to remove. + let is_install = ((not $is_hu) and ((cmd-head $cmd) in $INSTALL_HEADS)) + if not ($is_hu or $is_install) { continue } + + if ($cmd | str ends-with "\\") { + $cont = ($cmd | str substring 0..<(($cmd | str length) - 1) | str trim) + $cont_line = $lineno + continue + } + + $out = ($out | append (make-entry $file $lineno $cmd ($pending | default $fence_default) $fence_env $pending_expect)) + $pending = null + $pending_expect = null + } + + $out +} + +def make-entry [file: string, line: int, cmd: string, directive: any, env_lines: list, expect: any = null] { + let d = ($directive | default "run") + let kind = ($d | split row " " | first) + let rest = ($d | str replace --regex '^\S+\s*' "") + + let class = match $kind { + "skip" => "skip" + "timeout" => "run-timeout" + # For commands that stream *changes* (`hu monitor watch`, `hu stream`), + # silence on an idle graph is the correct behaviour, so requiring + # output would fail a working command. Kept as a separate class rather + # than relaxing `timeout` for everything: a command that silently does + # nothing should still fail unless someone said it may be quiet. + "timeout-quiet" => "run-timeout-quiet" + "run" => "run" + _ => "run" + } + + { + file: $file + line: $line + command: $cmd + class: $class + reason: (if $class == "skip" { $rest } else { "" }) + timeout: (if $class in ["run-timeout" "run-timeout-quiet"] { ($rest | into int) } else { 0 }) + env: $env_lines + expect: ($expect | default "") + } +} + +def extract-all [] { + $DOC_FILES | each { |f| extract-file $f } | flatten +} + +# ------------------------------------------------------------------ execution + +# Build the environment a *downloaded* install runs in: a scratch HOME, a PATH +# holding only the installed bin dir, and no HU_PLUGIN_PATH so discovery must +# fall through to ~/.local/share/hu/plugins — exactly what a user gets. +def clean-env [home: string, extra: list] { + # Prepend the install dir rather than replacing PATH. Replacing it removed + # `bash` itself on NixOS, where the shell lives in the nix store and not in + # /bin — and it would also hide `jq`, which documented commands pipe into. + # `hu` resolving to the installed copy is asserted separately, in main. + let inherited = ($env.PATH? | default [] | str join (char esep)) + mut e = { + HOME: $home + PATH: $"($home)/.local/bin:($inherited)" + HU_PLUGIN_PATH: null + RUSTFLAGS: "" + # hiroz logs a dozen INFO lines per node at startup. Unfiltered they + # bury the actual error in the failure excerpt, which is the one thing + # the report exists to show. + RUST_LOG: "error" + } + for kv in $extra { + let parts = ($kv | split row "=") + if ($parts | length) >= 2 { + $e = ($e | insert ($parts | first) ($parts | skip 1 | str join "=")) + } + } + $e +} + +def run-one [entry: record, home: string] { + if $entry.class == "skip" { + return { result: "skip", rc: 0, note: $entry.reason, out: "" } + } + + # Asked after `skip`, so a doc that declares the reason still wins. Asked + # before execution, so an undeclared uninstall step cannot delete the + # install the remaining commands are measured against — see + # DESTRUCTIVE_HEADS. This is a fail, not a skip: the promise is unproven, + # and the note says what to write in the doc to resolve it. + if (cmd-head $entry.command) in $DESTRUCTIVE_HEADS { + return { + result: "fail" + rc: 0 + note: "destructive command not run — it would delete the install under test; declare it in the doc with ``" + out: "" + } + } + + let env_map = (clean-env $home $entry.env) + let secs = (if $entry.timeout > 0 { $entry.timeout } else { 30 }) + # `timeout` so a streaming command cannot hang the suite. `-k 5` is not + # optional: SIGINT alone is a request, and a TUI or a wedged plugin can + # ignore it — without the follow-up SIGKILL one bad command hangs the whole + # run with no output to say which. + # + # `set -o pipefail` is load-bearing, not hygiene. Several documented + # commands pipe into `jq` (`hu meter hz … --json | jq '.rate_hz'`), and + # bash reports the LAST command's status by default — so a failing `hu` + # whose error went to stderr scored a pass, because jq exited 0 on empty + # input. An audit found two such commands among the "passing" set. With + # pipefail the pipeline carries hu's status, and a timeout still surfaces + # as 124 for the streaming classes. + let wrapped = $"set -o pipefail; timeout -k 5 --preserve-status -s INT ($secs) ($entry.command)" + + # `bash -c`, not `-lc`: a login shell sources the user's profile, which can + # put a system `hu` on PATH and quietly test the wrong binary. + # `^cmd | complete` captures a non-zero exit as data. Wrapping it in a bare + # `do { }` instead makes nu raise on the first failing command, which ends + # the whole suite at the first red — the opposite of what a test runner + # should do. + let res = (with-env $env_map { ^bash -c $wrapped | complete }) + let rc = $res.exit_code + let out = $"($res.stdout)($res.stderr)" + + # An `expect` regex outranks every status rule below. A command can exit 0, + # stream plenty, and still be wrong — which is exactly the hole this closes: + # before, `hu meter hz` reported a pass whatever number it printed, so a + # plugin that dispatched but computed nonsense was indistinguishable from a + # correct one. Checked first so a wrong answer cannot be rescued by a + # generous status rule. + if $entry.expect != "" { + # Whitespace is collapsed first: plugin output is column-aligned, so a + # pattern written against the doc's example would otherwise have to + # encode the exact padding. + let flat = ($out | str replace --all --regex '\s+' " ") + if not ($flat =~ $entry.expect) { + return { result: "fail", rc: $rc, note: $"output did not match expect: ($entry.expect)", out: $out } + } + } + + if $entry.class == "run-timeout-quiet" { + # Survived its window without erroring. No output requirement — see + # the class comment in make-entry. + let alive = ($rc in [124 130 2]) + if $alive or $rc == 0 { + { result: "pass", rc: $rc, note: "ran (quiet allowed)", out: $out } + } else { + { result: "fail", rc: $rc, note: "exited non-zero before timeout", out: $out } + } + } else if $entry.class == "run-timeout" { + # A streaming command is healthy if it was still running when the + # timeout fired (124, or the INT-preserved 130/2) AND it printed + # something. Exiting 0 early is also fine. Anything else is a failure. + let alive = ($rc in [124 130 2]) + if ($alive or $rc == 0) and (($out | str trim) != "") { + { result: "pass", rc: $rc, note: "streamed output", out: $out } + } else if ($alive or $rc == 0) { + { result: "fail", rc: $rc, note: "ran but produced no output", out: $out } + } else { + { result: "fail", rc: $rc, note: "exited non-zero before timeout", out: $out } + } + } else { + if $rc == 0 { + { result: "pass", rc: 0, note: "", out: $out } + } else { + { result: "fail", rc: $rc, note: $"exit ($rc)", out: $out } + } + } +} + +# -------------------------------------------------------------------- report + +# Stand up the graph the docs describe, using a publisher that is NOT the +# artifact under test. +# +# This separation is the point. `hu` cannot generate its own traffic: `hu meter +# pub` needs a message schema from a `.msg` on HIROZ_MSG_PATH or from a live +# node, and a release ships neither. So a suite whose only fixture is `hu +# router` measures an empty graph, and every `hu meter` command it runs is +# reduced to checking that the process started. That is how a truncated plugin +# could pass a release check. +# +# The publisher therefore comes from the build tree — it stands in for the +# "existing ROS 2 or hiroz deployment" that `hu` is documented to observe. +# Using the artifact for both sides would repeat the mistake that hid this: +# a fixture built from the same source as the code under test cannot represent +# a user who only downloaded the code. +def start-traffic [publisher: string, endpoint: string, home: string] { + if $publisher == "" { return null } + if not ($publisher | path exists) { + print $"FAIL: --publisher ($publisher) does not exist" + exit 1 + } + print $"starting traffic fixture: ($publisher)" + # Log beside the scratch HOME, not in a temp dir: it belongs with the rest + # of the run's state, and `$nu.temp-path` does not exist in every nushell. + let log = $"($home)/talker.log" + let pid = ( + job spawn { + with-env { RUST_LOG: "error" } { + ^$publisher --role talker --endpoint $endpoint out+err> $log + } + } + ) + # The graph is liveliness-driven, so a `hu meter list` issued too early + # legitimately sees nothing. Wait for the token to propagate rather than + # sleeping a guessed interval. + sleep 4sec + { job: $pid, log: $log } +} + +def main [ + --home: string # scratch HOME holding the downloaded install + --list # only print what would run + --filter: string = "" # substring filter on the command + --publisher: string = "" # binary that puts real traffic on /chatter (see start-traffic) + --endpoint: string = "tcp/127.0.0.1:7447" + --require-traffic # fail rather than run against an empty graph +] { + let entries = (extract-all | where { |e| ($filter == "") or ($e.command | str contains $filter) }) + + if ($entries | is-empty) { + # Zero extracted commands is a harness failure, not a pass. A suite + # that runs nothing must never report success. + print "FAIL: extracted 0 documented commands from the docs — the extractor is broken" + exit 1 + } + + if $list { + $entries | select file line class command | print + print $"($entries | length) commands" + return + } + + let home = ($home | default "") + if $home == "" or not ($home | path exists) { + print "FAIL: --home must point at a prepared install dir (see scripts/install-hu.sh --offline)" + exit 1 + } + + # The whole point is testing the *downloaded* hu. If PATH resolution picks + # up a system or build-tree copy instead, every result below is about the + # wrong binary and the suite is worse than useless — so prove it first. + let probe = (with-env (clean-env $home []) { ^bash -c "command -v hu" | complete }) + let resolved = ($probe.stdout | str trim) + if $probe.exit_code != 0 or not ($resolved | str starts-with $"($home)/.local/bin/") { + print $"FAIL: `hu` resolves to '($resolved)', not the install under ($home)" + exit 1 + } + print $"testing ($resolved)" + + if $require_traffic and $publisher == "" { + # Guard against the quiet degradation this flag exists to prevent: a + # release gate that silently becomes an exit-status check because + # nobody passed a publisher. + print "FAIL: --require-traffic was set but no --publisher was given" + exit 1 + } + let traffic = (start-traffic $publisher $endpoint $home) + + if $traffic != null { + # Prove the fixture works before trusting any result that depends on + # it. If the talker died on startup, every `hu meter` command below + # would fail for a reason that has nothing to do with the artifact. + let seen = (with-env (clean-env $home []) { + ^bash -c "timeout 15 hu meter list topics 2>&1" | complete + }) + if not ($seen.stdout | str contains "/chatter") { + print "FAIL: traffic fixture produced no /chatter topic — the graph is empty" + print ($seen.stdout | lines | first 10 | str join "\n") + print $"talker log: ($traffic.log)" + if ($traffic.log | path exists) { print (open --raw $traffic.log | lines | last 10 | str join "\n") } + job kill $traffic.job + exit 1 + } + print "traffic fixture live: /chatter visible to the installed hu" + } + + mut rows = [] + let total = ($entries | length) + for it in ($entries | enumerate) { + let e = $it.item + # Print before running, not after: if a command wedges, the last line + # printed names the culprit instead of leaving a silent hang. + print $"[($it.index + 1)/($total)] ($e.class) ($e.command)" + let r = (run-one $e $home) + $rows = ($rows | append { + result: $r.result + where: $"($e.file):($e.line)" + class: $e.class + command: ($e.command | str substring 0..90) + note: $r.note + }) + if $r.result == "fail" { + print $"FAIL ($e.file):($e.line) ($e.command)" + print $" ($r.note)" + print ($r.out | lines | first 6 | each { |l| $" | ($l)" } | str join "\n") + } + } + + if $traffic != null { job kill $traffic.job } + + print "" + $rows | print + let passed = ($rows | where result == "pass" | length) + let failed = ($rows | where result == "fail" | length) + let skipped = ($rows | where result == "skip" | length) + print $"\n($passed) passed, ($failed) failed, ($skipped) skipped, ($rows | length) total" + + if $failed > 0 { exit 1 } +} 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..2b1c60bdb --- /dev/null +++ b/scripts/test-release-version-semantics.sh @@ -0,0 +1,331 @@ +#!/bin/sh +# Release version semantics: a tag is THREE different strings, and every +# release channel has to use the right one at each site. +# +# release identity what the tag/release/download path is called 0.2.0-rc1 +# asset core what the FILENAMES carry 0.2.0 +# binary version what `hu --version` prints hu 0.2.0 +# +# They coincide for a normal release and diverge for a pre-release, because +# `build-hu-release.nu` names every asset for the crate version: an rc ships the +# same crate as the release it rehearses. That divergence is why a pre-release +# is the only tag shape that can catch this class of defect — and, since +# `push: tags: v*` is the GitHub release workflow's only trigger, an rc tag is +# also its only rehearsal vehicle. +# +# This test needs no runner, no network and no build. It runs in seconds and is +# the check that would have caught F13 on either channel. +# +# ./scripts/test-release-version-semantics.sh + +set -u + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +GH="$ROOT/.github/workflows/release.yml" +INSTALLER="$ROOT/scripts/install-hu.sh" + +PASS=0 +FAIL=0 + +ok() { PASS=$((PASS + 1)); printf ' ok %s\n' "$1"; } +bad() { FAIL=$((FAIL + 1)); printf ' FAIL %s\n' "$1"; } + +check() { # description, expected, actual + if [ "$2" = "$3" ]; then ok "$1"; else + bad "$1" + printf ' expected: %s\n actual: %s\n' "$2" "$3" + fi +} + +# grep_must / grep_must_not take a file, a description and a BRE. `--` guards +# patterns that begin with a dash (`--version`, `--no-validate`). +grep_must() { + if grep -q -e "$3" -- "$1"; then ok "$2"; else bad "$2 (pattern absent: $3)"; fi +} +grep_must_not() { + if grep -q -e "$3" -- "$1"; then bad "$2 (pattern present: $3)"; else ok "$2"; fi +} + +# --------------------------------------------------------------------------- +# 1. The semantics themselves, table-driven. +# +# `strip` mirrors the workflows: the tag prefix comes off, then the release +# identity is split at the first '-' to get the asset core. +# --------------------------------------------------------------------------- + +release_id() { # tag, prefix + printf '%s' "${1#"$2"}" +} +asset_core() { # release identity + printf '%s' "${1%%-*}" +} +is_prerelease() { # release identity + # Semver: everything after the first `-` is the pre-release identifier. + # This must stay identical to the expression release.yml uses, or the + # table below asserts a rule the workflow does not implement. + case "$1" in + *-*) printf 'true' ;; + *) printf 'false' ;; + esac +} + +# tag | prefix | expected release id | expected asset core | expected hu --version | expected prerelease +TABLE=' +v0.2.0|v|0.2.0|0.2.0|hu 0.2.0|false +v0.2.0-rc1|v|0.2.0-rc1|0.2.0|hu 0.2.0|true +v0.2.0-beta2|v|0.2.0-beta2|0.2.0|hu 0.2.0|true +hu-v0.1.0|hu-v|0.1.0|0.1.0|hu 0.1.0|false +hu-v0.1.0-rc1|hu-v|0.1.0-rc1|0.1.0|hu 0.1.0|true +' + +echo "== version semantics per tag ==" +printf '%s\n' "$TABLE" | while IFS='|' read -r tag prefix want_id want_core want_ver want_pre; do + [ -n "$tag" ] || continue + got_id=$(release_id "$tag" "$prefix") + got_core=$(asset_core "$got_id") + got_pre=$(is_prerelease "$got_id") + printf '%s -> id=%s core=%s pre=%s\n' "$tag" "$got_id" "$got_core" "$got_pre" + [ "$got_id" = "$want_id" ] || { echo " FAIL release identity"; exit 1; } + [ "$got_core" = "$want_core" ] || { echo " FAIL asset core"; exit 1; } + [ "hu $got_core" = "$want_ver" ] || { echo " FAIL binary version"; exit 1; } + [ "$got_pre" = "$want_pre" ] || { echo " FAIL prerelease flag"; exit 1; } +done +if [ $? -eq 0 ]; then + ok "table rows derive id/core/version/prerelease as documented" +else + bad "a table row derived the wrong id/core/version/prerelease (see above)" +fi + +echo +echo "== the two tag shapes differ in exactly the documented way ==" +FINAL_ID=$(release_id v0.2.0 v); FINAL_CORE=$(asset_core "$FINAL_ID") +RC_ID=$(release_id v0.2.0-rc1 v); RC_CORE=$(asset_core "$RC_ID") + +check "a normal tag: identity equals core" "$FINAL_ID" "$FINAL_CORE" +if [ "$RC_ID" = "$RC_CORE" ]; then + bad "a pre-release tag: identity must NOT equal core" +else + ok "a pre-release tag: identity ($RC_ID) differs from core ($RC_CORE)" +fi +check "both tags name the same assets" "$FINAL_CORE" "$RC_CORE" +check "both tags produce the same binary version" "hu $FINAL_CORE" "hu $RC_CORE" + +echo +echo "== the asset set an rc tag actually produces ==" +for f in "hu-$RC_CORE-x86_64-unknown-linux-gnu.tar.gz" \ + "hu_meter-$RC_CORE.wasm" \ + "hu_monitor-$RC_CORE.wasm" \ + "hu-plugins-$RC_CORE.tar.gz" \ + "hu-plugins-$RC_CORE.json"; do + case "$f" in + *"$RC_ID"*) bad "asset $f must not carry the pre-release suffix" ;; + *) ok "asset named for the core version: $f" ;; + esac +done + +# --------------------------------------------------------------------------- +# 2. The workflows must USE those two strings at the right sites. Part 1 alone +# would stay green if a workflow reverted to one variable for both, so these +# contract checks are what make this a detector rather than a self-test. +# --------------------------------------------------------------------------- + +echo +echo "== .github/workflows/release.yml ==" +grep_must "$GH" "defines HU_CORE alongside HU_VERSION" 'HU_CORE=' +grep_must "$GH" "plugin verify loop checks hu_meter-\$HU_CORE" 'hu_meter-\$HU_CORE\.wasm' +grep_must_not "$GH" "plugin verify loop never checks a HU_VERSION-named asset" 'hu_[a-z]*-\$HU_VERSION' +grep_must_not "$GH" "plugins tarball/index never checked under HU_VERSION" 'hu-plugins-\$HU_VERSION' +grep_must "$GH" "release-install asserts the CORE version" 'test "\$got" = "hu \$CORE"' +grep_must_not "$GH" "release-install does not assert the tag version" 'test "\$got" = "hu \$VER"' +grep_must "$GH" "binary packaging arms the tag-vs-crate guard" '--version "\$HU_VERSION"' +grep_must "$GH" "plugin build arms the tag-vs-crate guard" 'plugins-only --version "\$HU_VERSION"' +grep_must_not "$GH" "plugin build no longer skips component validation" '--no-validate' +grep_must "$GH" "a host-native hu is built so validation can run" 'cargo build --release --bin hu --package hiroz-union' +grep_must "$GH" "the release step sets prerelease from the tag shape" 'prerelease: ' +grep_must "$GH" "publish-crates is guarded against pre-release tags" "if: \${{ !contains(github.ref_name, '-') }}" +grep_must "$GH" "prerelease is derived from the semver hyphen, not an rc/alpha/beta list" "prerelease: \${{ contains(github.ref_name, '-') }}" + +echo +echo "== scripts/install-hu.sh ==" +grep_must "$INSTALLER" "splits the requested version into a core version" 'CORE="\${VERSION%%-\*}"' +grep_must "$INSTALLER" "downloads core-named assets" 'hu-\$CORE-\$TARGET\.tar\.gz' +# The offline path used to skip detect_target entirely and take `ls | head -n1`, +# so a directory holding a whole release installed the alphabetically-first +# target -- aarch64-apple-darwin -- on an x86_64 Linux host, with exit 0. +grep_must "$INSTALLER" "the offline path selects the tarball by platform" 'hu-\*-"\$TARGET"\.tar\.gz' +grep_must_not "$INSTALLER" "no lexical tarball pick remains" 'ls "\$SRC"/hu-\*-\*\.tar\.gz 2>/dev/null | grep -v -- .-plugins-. | head -n1' + +# --------------------------------------------------------------------------- +# 3. What the release PUBLISHES, and what it verifies afterwards. +# +# Two defects live here and neither is a version-semantics bug, but they +# share this file because they share its property: they are contract facts +# about the release workflow that can be checked with no runner, no network +# and no build. +# +# D1 — docs/tools/hu-install.md's first instruction is +# `curl -fsSL /install-hu.sh | sh`, and install-hu.sh was never +# published. build-hu-release.nu writes six assets; the installer is +# not one of them, and the release job uploaded `dist/**`. +# +# D7 — the post-publish check stopped at `hu plugin list | grep meter`. +# Plugin discovery reads FILENAMES ONLY and never opens the component, +# so an empty file named hu_meter.wasm passed every assertion this +# channel made. +# --------------------------------------------------------------------------- + +# Line number of the first line matching a BRE, or 0. Used for the ordering +# assertion below: staging AFTER the checksums are assembled would publish an +# unlisted asset, and no grep for either line alone can see that. +line_of() { # file, BRE + awk -v pat="$2" 'index($0, pat) { print NR; exit }' "$1" 2>/dev/null || true +} + +echo +echo "== D1: the documented installer URL is actually published ==" +grep_must "$GH" "the release job stages install-hu.sh into dist/" \ + 'cp scripts/install-hu.sh dist/install-hu.sh' +grep_must "$GH" "staging parse-checks the installer before publishing it" \ + 'sh -n scripts/install-hu.sh' +grep_must "$GH" "SHA256SUMS coverage of install-hu.sh is asserted" \ + 'install-hu\.sh is not covered by SHA256SUMS' +grep_must "$GH" "the published installer URL is fetched back and compared" \ + 'curl -fsSL "\$HU_RELEASE_BASE/install-hu.sh"' +grep_must "$GH" "the fetched installer is diffed against the tagged source" \ + 'diff -u scripts/install-hu.sh fetched-install-hu.sh' + +_stage=$(line_of "$GH" 'cp scripts/install-hu.sh dist/install-hu.sh') +_sums=$(line_of "$GH" 'Assemble SHA256SUMS over the complete asset set') +if [ -n "${_stage:-}" ] && [ -n "${_sums:-}" ] && [ "$_stage" -lt "$_sums" ] 2>/dev/null; then + ok "install-hu.sh is staged BEFORE SHA256SUMS is assembled (line $_stage < $_sums)" +else + bad "install-hu.sh must be staged before SHA256SUMS (stage=${_stage:-none} sums=${_sums:-none})" +fi + +# The asset this publishes must parse. Pure syntax check — nothing is executed. +if sh -n "$INSTALLER" 2>/dev/null; then + ok "scripts/install-hu.sh parses (sh -n)" +else + bad "scripts/install-hu.sh does not parse (sh -n)" +fi + +echo +echo "== D7: the release is checked against its own docs, not just filenames ==" +grep_must "$GH" "runs the docs-reproduction suite after installing from the release" \ + 'nu scripts/test-hu-docs-repro.nu' +grep_must "$GH" "the suite is pointed at the installed-from-release HOME" \ + '--home "\$HUHOME"' +grep_must "$GH" "--require-traffic, so an empty graph fails instead of passing" \ + '--require-traffic' +grep_must "$GH" "a traffic fixture is built from source, not taken from the artifact" \ + 'cargo build --release --example z_pubsub -p hiroz' +grep_must "$GH" "a router is started from the INSTALLED binary" \ + '"\$HUHOME/.local/bin/hu" router' +grep_must "$GH" "the suite exit status is captured, not piped away" \ + 'test "\$rc" -eq 0' + + +# --------------------------------------------------------------------------- +# 4. The derivation itself, by VALUE +# +# Everything above greps for variable NAMES. That is not enough, and this +# section exists because it was measured not to be: reintroducing F13 verbatim +# --- `HU_CORE=${V%%-*}` -> `HU_CORE=$V`, one variable for both strings --- left +# the suite at 42 passed, 0 failed. Every downstream grep still matched, because +# `hu_meter-$HU_CORE.wasm` is textually unchanged. A name-based check cannot see +# a changed value. +# +# So: lift the real derivation lines out of the workflow and RUN them, then +# assert on what they produce. This targets the TAG path only. + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +derive() { # file, ref-var-name, ref-value, assignment-pattern + _f="$1"; _var="$2"; _ref="$3"; _assign="$4" + { + grep -m1 -- "$_assign" "$_f" + grep -m1 'echo "HU_VERSION=' "$_f" + grep -m1 'echo "HU_CORE=' "$_f" + } | sed 's/^[[:space:]]*//' > "$WORK/derive.sh" + : > "$WORK/env" + env "$_var=$_ref" GITHUB_ENV="$WORK/env" sh "$WORK/derive.sh" >/dev/null 2>&1 || true + cat "$WORK/env" +} + +check_derivation() { # label, file, ref-var, ref, assign-pattern, want_version, want_core + _out="$(derive "$2" "$3" "$4" "$5")" + _v="$(printf '%s\n' "$_out" | sed -n 's/^HU_VERSION=//p' | head -n1)" + _c="$(printf '%s\n' "$_out" | sed -n 's/^HU_CORE=//p' | head -n1)" + if [ "$_v" = "$6" ] && [ "$_c" = "$7" ]; then + ok "$1 (HU_VERSION=$_v HU_CORE=$_c)" + else + bad "$1 — got HU_VERSION='$_v' HU_CORE='$_c', wanted '$6' / '$7'" + fi +} + +echo "== the workflows' own derivation, evaluated ==" +check_derivation "GitHub: a pre-release tag splits release id from asset core" \ + "$GH" GITHUB_REF_NAME v0.2.0-rc1 'V="${GITHUB_REF_NAME#v}"' 0.2.0-rc1 0.2.0 +check_derivation "GitHub: a normal tag makes the two identical" \ + "$GH" GITHUB_REF_NAME v0.2.0 'V="${GITHUB_REF_NAME#v}"' 0.2.0 0.2.0 + + +echo +echo "== every workspace crate inherits one version ==" +# `cargo publish --workspace` takes each crate's version from its own manifest, +# not from the tag. A crate carrying a literal version is one the release can +# silently leave behind: it either republishes a version crates.io already has +# (failing the job) or ships a number that disagrees with the tag. hiroz, +# hiroz-protocol and hiroz-union each carried one. +# +# The nested plugins workspace under crates/hiroz-union/plugins is `exclude`d +# and its versions are cosmetic -- release assets are named for the workspace +# version -- so it is deliberately not covered here. +stray="" +for f in "$ROOT"/crates/*/Cargo.toml; do + v=$(grep -m1 '^version' "$f" 2>/dev/null || true) + case "$v" in + *workspace*) ;; + "") ;; + *) stray="$stray $(basename "$(dirname "$f")")" ;; + esac +done +if [ -z "$stray" ]; then + ok "no crate carries a literal version" +else + bad "these crates carry a literal version instead of version.workspace:$stray" +fi + +echo +echo "== the release is not public until it verifies ==" +# Ordering, not just presence. This used to publish immediately and verify +# afterwards, so a failed check left a public release with nothing to de-list +# it. GitHub will not serve draft assets for a download test, so the reachable +# shape is publish -> verify -> withdraw on failure. +# 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'" +grep_must "$GH" "the withdraw job fires on a failed download test" \ + "needs.smoke-test-release-install.result == 'failure'" +grep_must "$GH" "the download test waits for the promotion" 'needs: \[publish-release\]' +grep_must "$GH" "crates.io is gated behind the download test" 'needs: \[smoke-test-release-install\]' +echo +echo "-- $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/scripts/test-release-workflow.nu b/scripts/test-release-workflow.nu index fe7c4789f..5acca22b7 100755 --- a/scripts/test-release-workflow.nu +++ b/scripts/test-release-workflow.nu @@ -15,7 +15,21 @@ use lib/common.nu * const REPO = "ZettaScaleLabs/hiroz" -const DEFAULT_TAG = "v0.0.0-smoke-test" +# The smoke tag must carry the CORE version the `hiroz-union` crate is at. +# `build-hu-release.nu` cross-checks the tag against that crate and fails the +# build on a mismatch, so the old hard-coded `v0.0.0-smoke-test` now kills four +# jobs at their first packaging step -- it removed the only documented rehearsal +# GitHub has. A `-smoke-test` suffix keeps it a pre-release, so it is published +# as one and skips crates.io. +def hu-core []: nothing -> string { + open --raw Cargo.toml + | lines + | skip until { |l| ($l | str trim) == "[workspace.package]" } + | where { |l| ($l | str trim | str starts-with "version") } + | first + | split row "\"" + | get 1 +} def cleanup-tag [tag: string] { log-step $"Deleting remote tag ($tag)" @@ -37,10 +51,13 @@ def cleanup-tag [tag: string] { # Smoke-test the release workflow by pushing a temporary prerelease tag. def main [ - --tag: string = $DEFAULT_TAG # Tag to push (deleted on cleanup) + --tag: string = "" # Tag to push (default: v-smoke-test; deleted on cleanup) --no-wait # Push the tag but do not poll for CI result --cleanup # Delete the tag and GitHub Release, then exit ] { + let tag = if ($tag | is-empty) { $"v(hu-core)-smoke-test" } else { $tag } + print $"Smoke tag: ($tag)" + if $cleanup { cleanup-tag $tag log-success "Cleanup done." From f7a4bbf2082d2d3ee1804deeb6e543372553a2e9 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Sat, 22 Aug 2026 11:52:28 +0800 Subject: [PATCH 2/4] ci(hu): run the docs-reproduction suite against the published release The release workflow gained this step in the distribution branch, which did not carry the script it calls. That branch would have failed every `v*` tag: build, promote, fail here, withdraw the release, skip crates.io. The step was removed there and belongs here, with the script. Also restores the RELEASING.md claim about `scripts/test-release-version-semantics.sh`. The distribution branch promised a script it did not contain, so the sentence was corrected to say nothing enforced the invariant. This branch adds the script and runs it from ci.yml, so the original claim is true again. --- .github/workflows/release.yml | 45 +++++++++++++++++++++++++++++++++-- RELEASING.md | 2 +- 2 files changed, 44 insertions(+), 3 deletions(-) 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/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. From 677c49b1293a90e01c63bbdfbcbe83a85e4b35ef Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Sat, 22 Aug 2026 12:12:25 +0800 Subject: [PATCH 3/4] fix(hu): repair an undefined variable and a stale workflow assertion `install-readme` interpolated `($target)`, and it takes no such parameter. Nushell raised "Variable not found", so `build-hu-release.nu` failed outright on the macOS packaging leg and on the aarch64 cross-compile leg. The line is a hint for a reader checking one file, so it now names no variable at all. That defect entered with the release-pipeline slice, which had no job that runs the script -- the docs-reproduction suite this branch adds is what executes it, and it failed on the first run. `test-release-version-semantics.sh` pinned the withdraw job to `result == 'failure'`. Review found that wrong: GitHub reports a cancelled job as `cancelled` and a skipped one as `skipped`, so equality on 'failure' leaves an unverified release public. The workflow now tests `!= 'success'`, and this asserts that property in both directions. --- scripts/build-hu-release.nu | 2 +- scripts/test-release-version-semantics.sh | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) 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/test-release-version-semantics.sh b/scripts/test-release-version-semantics.sh index 2b1c60bdb..b591f8d25 100755 --- a/scripts/test-release-version-semantics.sh +++ b/scripts/test-release-version-semantics.sh @@ -322,7 +322,13 @@ grep_must "$GH" "a job withdraws it again" '\-\-draft=true' grep_must_not "$GH" "the withdraw job does not use a bare failure()" 'if: \${{ failure() }}' grep_must "$GH" "the withdraw job requires a successful publish" \ "needs.publish-release.result == 'success'" -grep_must "$GH" "the withdraw job fires on a failed download test" \ +# `!= '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\]' From 00dbad922f545c7add22a96d4fcdd5e02eac1146 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Sat, 22 Aug 2026 12:16:31 +0800 Subject: [PATCH 4/4] fix(hu): stop two checks from passing over what they do not read The docs-reproduction extractor classified a line by `starts-with "hu "`. Three documented commands do not begin with that token: HU_WEB_BIND=0.0.0.0 hu web rate=$(hu meter hz /camera/image_raw --duration 5 --json | jq ...) The extractor dropped them, so their `repro:` directives never reached the report either. The suite covered less than it claimed, which is the failure this harness exists to remove -- and the comment directly above the test asserted that nothing is ever dropped. `strip-shell-wrappers` now removes leading environment assignments, a command substitution, and a pipeline tail before the `hu` test runs. An unrecognised form is returned unchanged, so it still reaches the test and is reported rather than dropped. All three lines already carry `repro: skip`, so they are now reported as skips. `test-release-version-semantics.sh` derived the version with `grep -m1`, which reads only the first derivation block. `release.yml` has one per release producer, `build-binaries` and `build-hu-plugins`, and they are independent copies. The second could regress to `HU_CORE=$V` unchecked, publishing a binary and a plugin set that disagree about the version. It now evaluates every block and asserts the count is at least one, so a producer added without a check fails rather than passing silently. Both directions measured. As committed: 56 passed, 0 failed. Regressing only the second derivation block: 55 passed, 1 failed, naming `[block 2/2]` -- the case the previous form could not see. --- scripts/test-hu-docs-repro.nu | 44 ++++++++++++++++++++++- scripts/test-release-version-semantics.sh | 37 +++++++++++++++---- 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/scripts/test-hu-docs-repro.nu b/scripts/test-hu-docs-repro.nu index b57afb55e..5bb644a19 100755 --- a/scripts/test-hu-docs-repro.nu +++ b/scripts/test-hu-docs-repro.nu @@ -89,6 +89,42 @@ def cmd-head [cmd: string] { # 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 @@ -191,7 +227,13 @@ def extract-file [file: string] { # The `hu` test is unchanged, and is asked FIRST: an install-class head # can never also be an `hu` line, so nothing is counted twice and the # set of extracted `hu` commands is byte-for-byte what it was before. - let is_hu = (($cmd | str starts-with "hu ") or ($cmd == "hu")) + # 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 diff --git a/scripts/test-release-version-semantics.sh b/scripts/test-release-version-semantics.sh index b591f8d25..701e31473 100755 --- a/scripts/test-release-version-semantics.sh +++ b/scripts/test-release-version-semantics.sh @@ -241,20 +241,45 @@ grep_must "$GH" "the suite exit status is captured, not piped away" \ WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT -derive() { # file, ref-var-name, ref-value, assignment-pattern - _f="$1"; _var="$2"; _ref="$3"; _assign="$4" +# 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 -m1 -- "$_assign" "$_f" - grep -m1 'echo "HU_VERSION=' "$_f" - grep -m1 'echo "HU_CORE=' "$_f" + 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 - _out="$(derive "$2" "$3" "$4" "$5")" + _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