From 29090c12435e2607eda459e78b55e7a7e06ac366 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Tue, 25 Aug 2026 15:24:16 +0800 Subject: [PATCH 1/7] fix(ci): stop the link check querying the site's own published URL MkDocs writes site_url into site/sitemap.xml and into the rel=canonical tag of every page, so each run asks GitHub Pages about several hundred of its own URLs. That check queries the previous deployment, so a page added in the current build always 404s, and the request burst gets rate-limited. Fixes #323: a 503 reported on a page that is live and returns 200. --- .lychee.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.lychee.toml b/.lychee.toml index 981dfdb0c..9ba38d59d 100644 --- a/.lychee.toml +++ b/.lychee.toml @@ -24,4 +24,10 @@ exclude = [ # file:// URLs are generated when lychee resolves internal links in the built site/ HTML # on the CI runner — they are not real external links and are always false positives "file://", + # The site's own published URL. MkDocs writes it into site/sitemap.xml and into the + # rel="canonical" tag of every page, so every build asks GitHub Pages about several + # hundred of its own URLs. Two reasons this can never be a useful check: + # - it queries the PREVIOUS deployment, so a page added in this build always 404s + # - the request burst gets rate-limited; issue #323 was a 503 on a page that is live + "zettascalelabs\\.github\\.io/hiroz", ] From 69b8920c6ed6fba9dd9356b58473e0902a1372dc Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Tue, 25 Aug 2026 15:34:57 +0800 Subject: [PATCH 2/7] ci: trigger the heavy workflows from an allow-list of build inputs ci.yml, test.yml and rmw-zenoh-rs.yml selected work with paths-ignore. A deny-list has to name every file that must not build, so every file it does not name runs the full ROS matrix. .lychee.toml was one: a one-line comment in it started 4 interop distros, 4 Python distros, ROS tests, WASM, SHM, coverage and both macOS jobs. test.yml and rmw-zenoh-rs.yml also still ignored book/**, a directory the MkDocs migration removed, so every docs/ edit ran both. Each of the three now lists what it actually builds from. Two jobs had a wider interest than the build inputs, so they move out rather than widen the list back: - Check Formatting -> lint.yml, no path filter. The pre-commit check covers .rs, .toml, .yaml, .md, .py and .nix across the tree, so a config-only change must still reach it. It is also the cheapest job in CI. - hu-docs-repro -> hu-docs.yml, which adds docs/tools/**. It replays every documented hu command, and under paths-ignore a docs/** edit could change those commands without ever running it. docs.yml gains .lychee.toml, crates/*/examples/** and check-example-coverage.nu, which its own steps read. --- .github/workflows/ci.yml | 174 ++++++----------------------- .github/workflows/docs.yml | 10 ++ .github/workflows/hu-docs.yml | 134 ++++++++++++++++++++++ .github/workflows/lint.yml | 45 ++++++++ .github/workflows/rmw-zenoh-rs.yml | 46 +++++--- .github/workflows/test.yml | 46 +++++--- 6 files changed, 277 insertions(+), 178 deletions(-) create mode 100644 .github/workflows/hu-docs.yml create mode 100644 .github/workflows/lint.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b497871c..e0975d06f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,62 +3,47 @@ name: CI on: push: branches: [main] - paths-ignore: - - '**.md' - - 'docs/**' - - 'mkdocs.yml' - - '.github/workflows/docs.yml' - - '.github/workflows/mkdocs-preview.yml' - - '.github/workflows/test.yml' - - '.github/workflows/rmw-zenoh-rs.yml' - - '.github/workflows/semantic-pr.yml' - - '.github/workflows/pr-draft-check.yml' + paths: + # Allow-list, not a deny-list: a deny-list has to be extended for every + # new configuration file, and the ones it misses run the whole matrix. + # `.lychee.toml` missed it and ran 4 ROS distros for a link-checker + # comment (see #323). + - 'crates/**' + - '!crates/**/*.md' + - 'scripts/**' + - 'nix/**' + - 'rustfmt.toml' + - 'codecov.yml' + - 'Cargo.toml' + - 'Cargo.lock' + - 'flake.nix' + - 'flake.lock' + - '.github/workflows/ci.yml' pull_request: types: [opened, synchronize, reopened, ready_for_review] branches: [main] - paths-ignore: - - '**.md' - - 'docs/**' - - 'mkdocs.yml' - - '.github/workflows/docs.yml' - - '.github/workflows/mkdocs-preview.yml' - - '.github/workflows/test.yml' - - '.github/workflows/rmw-zenoh-rs.yml' - - '.github/workflows/semantic-pr.yml' - - '.github/workflows/pr-draft-check.yml' + paths: + # Allow-list, not a deny-list: a deny-list has to be extended for every + # new configuration file, and the ones it misses run the whole matrix. + # `.lychee.toml` missed it and ran 4 ROS distros for a link-checker + # comment (see #323). + - 'crates/**' + - '!crates/**/*.md' + - 'scripts/**' + - 'nix/**' + - 'rustfmt.toml' + - 'codecov.yml' + - 'Cargo.toml' + - 'Cargo.lock' + - 'flake.nix' + - 'flake.lock' + - '.github/workflows/ci.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - formatting: - name: Check Formatting - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install Nix - uses: cachix/install-nix-action@v31 - with: - extra_nix_config: | - extra-substituters = https://hiroz.cachix.org - extra-trusted-public-keys = hiroz.cachix.org-1:wKJuqEckTG0DL3Df7Ly9OVsg5S5TGBHtvlPGs+vlqrY= - - - name: Setup Cachix - uses: cachix/cachix-action@v15 - with: - name: hiroz - authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' - skipPush: ${{ github.event_name == 'pull_request' }} - - - name: Check formatting - run: nix build .#checks.x86_64-linux.pre-commit-check -L - apt-retry: name: CI apt wrapper runs-on: ubuntu-latest @@ -559,101 +544,6 @@ jobs: 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/docs.yml b/.github/workflows/docs.yml index 0669f736d..497c4f426 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,12 +6,22 @@ on: - 'docs/**' - 'mkdocs.yml' - '**.md' + # build_doc runs lychee with this config, and check-example-coverage.nu + # compares the docs against the examples that actually exist + - '.lychee.toml' + - 'crates/*/examples/**' + - 'scripts/check-example-coverage.nu' - '.github/workflows/docs.yml' pull_request: paths: - 'docs/**' - 'mkdocs.yml' - '**.md' + # build_doc runs lychee with this config, and check-example-coverage.nu + # compares the docs against the examples that actually exist + - '.lychee.toml' + - 'crates/*/examples/**' + - 'scripts/check-example-coverage.nu' - '.github/workflows/docs.yml' jobs: diff --git a/.github/workflows/hu-docs.yml b/.github/workflows/hu-docs.yml new file mode 100644 index 000000000..2ac264025 --- /dev/null +++ b/.github/workflows/hu-docs.yml @@ -0,0 +1,134 @@ +name: hu docs + +# Separate from ci.yml because its trigger is different: this is the only job +# that reads docs/tools/**, and putting that path into ci.yml would run the +# whole ROS matrix for a prose edit. Its build inputs are listed too — the +# job builds hu from the tree before replaying the documented commands. +on: + push: + branches: [main] + paths: + - 'docs/tools/**' + - 'crates/**' + - '!crates/**/*.md' + - 'scripts/**' + - 'nix/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'flake.nix' + - 'flake.lock' + - '.github/workflows/hu-docs.yml' + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + branches: [main] + paths: + - 'docs/tools/**' + - 'crates/**' + - '!crates/**/*.md' + - 'scripts/**' + - 'nix/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'flake.nix' + - 'flake.lock' + - '.github/workflows/hu-docs.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + 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 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..f92db1016 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,45 @@ +name: Lint + +# No path filter. The pre-commit check covers .rs, .toml, .yaml, .md, .py and +# .nix across the whole tree, so every file it lints must be able to trigger +# it. It is also the cheapest job in CI (~1 min), which is why it is separate +# from ci.yml: ci.yml is allow-listed down to build inputs, and a config-only +# change must still be linted. +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + formatting: + name: Check Formatting + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Nix + uses: cachix/install-nix-action@v31 + with: + extra_nix_config: | + extra-substituters = https://hiroz.cachix.org + extra-trusted-public-keys = hiroz.cachix.org-1:wKJuqEckTG0DL3Df7Ly9OVsg5S5TGBHtvlPGs+vlqrY= + + - name: Setup Cachix + uses: cachix/cachix-action@v15 + with: + name: hiroz + authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' + skipPush: ${{ github.event_name == 'pull_request' }} + + - name: Check formatting + run: nix build .#checks.x86_64-linux.pre-commit-check -L diff --git a/.github/workflows/rmw-zenoh-rs.yml b/.github/workflows/rmw-zenoh-rs.yml index 8bc827833..ac308373f 100644 --- a/.github/workflows/rmw-zenoh-rs.yml +++ b/.github/workflows/rmw-zenoh-rs.yml @@ -3,27 +3,37 @@ name: rmw_zenoh_rs Tests on: push: branches: [main] - paths-ignore: - - '**.md' - - 'book/**' - - '.github/workflows/docs.yml' - - '.github/workflows/mkdocs-preview.yml' - - '.github/workflows/ci.yml' - - '.github/workflows/test.yml' - - '.github/workflows/semantic-pr.yml' - - '.github/workflows/pr-draft-check.yml' + paths: + # Allow-list, not a deny-list: a deny-list has to be extended for every + # new configuration file, and the ones it misses run the whole matrix. + # `.lychee.toml` missed it and ran 4 ROS distros for a link-checker + # comment (see #323). + - 'crates/**' + - '!crates/**/*.md' + - 'scripts/**' + - 'nix/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'flake.nix' + - 'flake.lock' + - '.github/workflows/rmw-zenoh-rs.yml' pull_request: types: [opened, synchronize, reopened, ready_for_review] branches: [main] - paths-ignore: - - '**.md' - - 'book/**' - - '.github/workflows/docs.yml' - - '.github/workflows/mkdocs-preview.yml' - - '.github/workflows/ci.yml' - - '.github/workflows/test.yml' - - '.github/workflows/semantic-pr.yml' - - '.github/workflows/pr-draft-check.yml' + paths: + # Allow-list, not a deny-list: a deny-list has to be extended for every + # new configuration file, and the ones it misses run the whole matrix. + # `.lychee.toml` missed it and ran 4 ROS distros for a link-checker + # comment (see #323). + - 'crates/**' + - '!crates/**/*.md' + - 'scripts/**' + - 'nix/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'flake.nix' + - 'flake.lock' + - '.github/workflows/rmw-zenoh-rs.yml' workflow_dispatch: inputs: test_filter: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5145b0bd3..fb5e02af5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,27 +3,37 @@ name: Interop Tests on: push: branches: [main] - paths-ignore: - - '**.md' - - 'book/**' - - '.github/workflows/docs.yml' - - '.github/workflows/mkdocs-preview.yml' - - '.github/workflows/ci.yml' - - '.github/workflows/rmw-zenoh-rs.yml' - - '.github/workflows/semantic-pr.yml' - - '.github/workflows/pr-draft-check.yml' + paths: + # Allow-list, not a deny-list: a deny-list has to be extended for every + # new configuration file, and the ones it misses run the whole matrix. + # `.lychee.toml` missed it and ran 4 ROS distros for a link-checker + # comment (see #323). + - 'crates/**' + - '!crates/**/*.md' + - 'scripts/**' + - 'nix/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'flake.nix' + - 'flake.lock' + - '.github/workflows/test.yml' pull_request: types: [opened, synchronize, reopened, ready_for_review] branches: [main] - paths-ignore: - - '**.md' - - 'book/**' - - '.github/workflows/docs.yml' - - '.github/workflows/mkdocs-preview.yml' - - '.github/workflows/ci.yml' - - '.github/workflows/rmw-zenoh-rs.yml' - - '.github/workflows/semantic-pr.yml' - - '.github/workflows/pr-draft-check.yml' + paths: + # Allow-list, not a deny-list: a deny-list has to be extended for every + # new configuration file, and the ones it misses run the whole matrix. + # `.lychee.toml` missed it and ran 4 ROS distros for a link-checker + # comment (see #323). + - 'crates/**' + - '!crates/**/*.md' + - 'scripts/**' + - 'nix/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'flake.nix' + - 'flake.lock' + - '.github/workflows/test.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} From fbd97f83706f94dfc920935910f5e57eeee72ae5 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Tue, 25 Aug 2026 15:35:19 +0800 Subject: [PATCH 3/7] ci(docs-links): keep one link report instead of one issue per run The weekly check opened a new issue on every failing run, each with the identical title. Closing one left the others open saying the same thing. The run now looks for an open issue with that title first. It updates that one when links fail, opens a new one only when none is open, and closes the open one with a comment when every link resolves. --- .github/workflows/docs-links.yml | 46 +++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docs-links.yml b/.github/workflows/docs-links.yml index 0f1b27a66..3b10fd3de 100644 --- a/.github/workflows/docs-links.yml +++ b/.github/workflows/docs-links.yml @@ -34,10 +34,54 @@ jobs: ./site fail: false # report only — don't block on transient failures - - name: Open issue on link failures + # The report is one recurring status, not one issue per week. Without + # this lookup every failing run opens another issue with the identical + # title, and closing one leaves the rest open saying the same thing. + - name: Find the existing report issue + id: existing + uses: actions/github-script@v7 + with: + script: | + const found = await github.paginate( + github.rest.issues.listForRepo, + { ...context.repo, state: 'open', labels: 'documentation', + per_page: 100 }, + ) + const match = found.find( + (i) => !i.pull_request && i.title === process.env.TITLE, + ) + core.setOutput('number', match ? String(match.number) : '') + core.info(match ? `reusing #${match.number}` : 'no open report') + env: + TITLE: 'docs: broken external links detected' + + - name: Report link failures if: steps.lychee.outputs.exit_code != 0 uses: peter-evans/create-issue-from-file@v5 with: + # Empty means "create a new one" — that is the first failure after a + # clean stretch. + issue-number: ${{ steps.existing.outputs.number }} title: 'docs: broken external links detected' content-filepath: ./lychee/out.md labels: documentation + + - name: Close the report when every link resolves + if: steps.lychee.outputs.exit_code == 0 && steps.existing.outputs.number != '' + uses: actions/github-script@v7 + with: + script: | + const issue_number = Number('${{ steps.existing.outputs.number }}') + await github.rest.issues.createComment({ + ...context.repo, + issue_number, + body: `Every external link resolved in ${process.env.RUN_URL}. ` + + 'Closing; the next failing run opens a new report.', + }) + await github.rest.issues.update({ + ...context.repo, issue_number, state: 'closed', + }) + env: + RUN_URL: >- + ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ + github.run_id }} From 89c9c4c3190d14da4c3157522cfb89fb2896d807 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Tue, 25 Aug 2026 15:39:49 +0800 Subject: [PATCH 4/7] ci: allow-list the two config files that change test behaviour .config/nextest.toml sets slow-timeout, test-threads and fail-fast for every nextest leg in ci.yml and test.yml, and .cargo/config.toml carries the alias the WASM plugin build uses. Neither was listed, so a change to either ran no test that it changes. Also record why a required status check must not be added on top of these filters: a workflow the filter skips reports nothing, and a required check that never reports blocks the pull request. main requires no checks today. --- .github/workflows/ci.yml | 18 ++++++++++++++++++ .github/workflows/hu-docs.yml | 4 ++++ .github/workflows/rmw-zenoh-rs.yml | 18 ++++++++++++++++++ .github/workflows/test.yml | 18 ++++++++++++++++++ 4 files changed, 58 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0975d06f..deba360c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,12 +8,21 @@ on: # new configuration file, and the ones it misses run the whole matrix. # `.lychee.toml` missed it and ran 4 ROS distros for a link-checker # comment (see #323). + # + # A path filter and a required status check cannot be combined: a + # workflow the filter skips reports nothing, and a required check that + # never reports blocks the pull request forever. `main` requires no + # checks today. If it ever does, move the gating to a job-level `if:` + # -- a job skipped that way reports success -- rather than adding the + # required check on top of this filter. - 'crates/**' - '!crates/**/*.md' - 'scripts/**' - 'nix/**' - 'rustfmt.toml' - 'codecov.yml' + - '.config/nextest.toml' + - '.cargo/config.toml' - 'Cargo.toml' - 'Cargo.lock' - 'flake.nix' @@ -27,12 +36,21 @@ on: # new configuration file, and the ones it misses run the whole matrix. # `.lychee.toml` missed it and ran 4 ROS distros for a link-checker # comment (see #323). + # + # A path filter and a required status check cannot be combined: a + # workflow the filter skips reports nothing, and a required check that + # never reports blocks the pull request forever. `main` requires no + # checks today. If it ever does, move the gating to a job-level `if:` + # -- a job skipped that way reports success -- rather than adding the + # required check on top of this filter. - 'crates/**' - '!crates/**/*.md' - 'scripts/**' - 'nix/**' - 'rustfmt.toml' - 'codecov.yml' + - '.config/nextest.toml' + - '.cargo/config.toml' - 'Cargo.toml' - 'Cargo.lock' - 'flake.nix' diff --git a/.github/workflows/hu-docs.yml b/.github/workflows/hu-docs.yml index 2ac264025..899c1ab77 100644 --- a/.github/workflows/hu-docs.yml +++ b/.github/workflows/hu-docs.yml @@ -13,6 +13,8 @@ on: - '!crates/**/*.md' - 'scripts/**' - 'nix/**' + - '.config/nextest.toml' + - '.cargo/config.toml' - 'Cargo.toml' - 'Cargo.lock' - 'flake.nix' @@ -27,6 +29,8 @@ on: - '!crates/**/*.md' - 'scripts/**' - 'nix/**' + - '.config/nextest.toml' + - '.cargo/config.toml' - 'Cargo.toml' - 'Cargo.lock' - 'flake.nix' diff --git a/.github/workflows/rmw-zenoh-rs.yml b/.github/workflows/rmw-zenoh-rs.yml index ac308373f..78a40cb7a 100644 --- a/.github/workflows/rmw-zenoh-rs.yml +++ b/.github/workflows/rmw-zenoh-rs.yml @@ -8,10 +8,19 @@ on: # new configuration file, and the ones it misses run the whole matrix. # `.lychee.toml` missed it and ran 4 ROS distros for a link-checker # comment (see #323). + # + # A path filter and a required status check cannot be combined: a + # workflow the filter skips reports nothing, and a required check that + # never reports blocks the pull request forever. `main` requires no + # checks today. If it ever does, move the gating to a job-level `if:` + # -- a job skipped that way reports success -- rather than adding the + # required check on top of this filter. - 'crates/**' - '!crates/**/*.md' - 'scripts/**' - 'nix/**' + - '.config/nextest.toml' + - '.cargo/config.toml' - 'Cargo.toml' - 'Cargo.lock' - 'flake.nix' @@ -25,10 +34,19 @@ on: # new configuration file, and the ones it misses run the whole matrix. # `.lychee.toml` missed it and ran 4 ROS distros for a link-checker # comment (see #323). + # + # A path filter and a required status check cannot be combined: a + # workflow the filter skips reports nothing, and a required check that + # never reports blocks the pull request forever. `main` requires no + # checks today. If it ever does, move the gating to a job-level `if:` + # -- a job skipped that way reports success -- rather than adding the + # required check on top of this filter. - 'crates/**' - '!crates/**/*.md' - 'scripts/**' - 'nix/**' + - '.config/nextest.toml' + - '.cargo/config.toml' - 'Cargo.toml' - 'Cargo.lock' - 'flake.nix' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fb5e02af5..cec34f6cf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,10 +8,19 @@ on: # new configuration file, and the ones it misses run the whole matrix. # `.lychee.toml` missed it and ran 4 ROS distros for a link-checker # comment (see #323). + # + # A path filter and a required status check cannot be combined: a + # workflow the filter skips reports nothing, and a required check that + # never reports blocks the pull request forever. `main` requires no + # checks today. If it ever does, move the gating to a job-level `if:` + # -- a job skipped that way reports success -- rather than adding the + # required check on top of this filter. - 'crates/**' - '!crates/**/*.md' - 'scripts/**' - 'nix/**' + - '.config/nextest.toml' + - '.cargo/config.toml' - 'Cargo.toml' - 'Cargo.lock' - 'flake.nix' @@ -25,10 +34,19 @@ on: # new configuration file, and the ones it misses run the whole matrix. # `.lychee.toml` missed it and ran 4 ROS distros for a link-checker # comment (see #323). + # + # A path filter and a required status check cannot be combined: a + # workflow the filter skips reports nothing, and a required check that + # never reports blocks the pull request forever. `main` requires no + # checks today. If it ever does, move the gating to a job-level `if:` + # -- a job skipped that way reports success -- rather than adding the + # required check on top of this filter. - 'crates/**' - '!crates/**/*.md' - 'scripts/**' - 'nix/**' + - '.config/nextest.toml' + - '.cargo/config.toml' - 'Cargo.toml' - 'Cargo.lock' - 'flake.nix' From bcc96776beb8b08dcf76b305ef71d545daa0c22a Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Tue, 25 Aug 2026 15:44:11 +0800 Subject: [PATCH 5/7] ci: fail when a changed file reaches no workflow An allow-list only skips what it does not name, so work landing somewhere new is silently never built. That is the one cost the allow-list trades for, and it is invisible: a green run and a skipped run look the same. lint.yml has no path filter, so it sees every change. It now diffs the pull request against its merge base and requires each file to match some workflow's paths, or to appear in the script's EXEMPT list with a reason. Both outcomes are fine; silence is not. The parse is strict in two ways, each covering a way this could rot quietly. A workflow with a paths: key that yields no patterns is an error, not an empty filter that silently covers nothing. And because Actions rejects YAML anchors, push and pull_request each carry their own copy of the list -- the script requires the copies to stay identical. Running it over the tree found six files that reach nothing: .clang-format, .vale.ini and four .vale/styles files. Both tools ship in the devshell and no job runs either, so they are exempt with that stated as the reason. --- .github/workflows/lint.yml | 33 +++++++ scripts/check-ci-path-coverage.py | 146 ++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 scripts/check-ci-path-coverage.py diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f92db1016..a7f15fbcd 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -43,3 +43,36 @@ jobs: - name: Check formatting run: nix build .#checks.x86_64-linux.pre-commit-check -L + + path-coverage: + name: Every changed file reaches a workflow + runs-on: ubuntu-latest + # Pull requests only. On a push the base is `github.event.before`, which is + # all-zeros for the first push of a branch, so the diff has no floor. + if: github.event_name == 'pull_request' + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: List the files this pull request changes + id: changed + run: | + set -euo pipefail + git fetch --no-tags origin "$GITHUB_BASE_REF" + base=$(git merge-base FETCH_HEAD HEAD) + # Three dots would be the same set here, but naming the merge base + # makes the floor explicit and survives a base branch that moved. + git diff --name-only "$base" HEAD > changed.txt + echo "count=$(wc -l < changed.txt)" >> "$GITHUB_OUTPUT" + cat changed.txt + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + + - name: Check each one triggers a workflow + if: steps.changed.outputs.count != '0' + run: xargs -a changed.txt -d '\n' python3 scripts/check-ci-path-coverage.py diff --git a/scripts/check-ci-path-coverage.py b/scripts/check-ci-path-coverage.py new file mode 100644 index 000000000..87e3c2e96 --- /dev/null +++ b/scripts/check-ci-path-coverage.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Fail when a changed file triggers no workflow and is not exempt. + +Every workflow that builds or tests selects its work with an allow-list of +`paths`. An allow-list only skips what it does not name, so a file added +somewhere new is silently never built -- the failure mode this guard exists +to convert into a red check. + +A file passes if either: + * some workflow's `paths` filter matches it, or + * `EXEMPT` below names it, with a reason. + +Python, not Nushell like the rest of scripts/, because lint.yml runs this on +every pull request and python3 is already on the runner. Pulling in nushell +would cost more setup than the check itself takes. + +Usage: check-ci-path-coverage.py ... +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +WORKFLOWS = Path(".github/workflows") + +# Files that legitimately trigger nothing. Each entry needs a reason: the +# point of the guard is that "nothing runs for this" is a decision, not an +# oversight. +EXEMPT: list[tuple[str, str]] = [ + (".github/workflows/lint.yml", "runs on every change; it has no filter to match"), + ( + ".github/workflows/release.yml", + "runs on v* tags, not on pushes or pull requests", + ), + (".github/workflows/docs-links.yml", "runs on a schedule"), + (".github/workflows/semantic-pr.yml", "runs on every pull request"), + (".github/workflows/pr-draft-check.yml", "runs on every pull request"), + (".github/pull_request_template.md", "text GitHub renders; nothing consumes it"), + (".github/**", "workflow metadata that no job reads"), + (".gitignore", "affects no build"), + (".gitattributes", "affects no build"), + (".envrc", "local developer setup"), + (".taplo.toml", "lint.yml runs taplo on every change"), + (".yamllint*", "lint.yml runs yamllint on every change"), + ("cliff.toml", "git-cliff changelog config, used at release time"), + ("assets/**", "images referenced by prose"), + ("LICENSE", "text"), + (".clang-format", "no job runs clang-format; it configures editors only"), + (".vale.ini", "vale ships in the devshell but no job runs it"), + (".vale/**", "vale ships in the devshell but no job runs it"), + ("*.md", "docs.yml covers docs; root prose triggers nothing else"), +] + + +def to_regex(pattern: str) -> re.Pattern[str]: + """Translate a GitHub path filter to a regex. + + `*` stops at a slash, `**` does not, `?` is one non-slash character. + """ + out, i = "", 0 + while i < len(pattern): + if pattern.startswith("**", i): + out += ".*" + i += 2 + elif pattern[i] == "*": + out += "[^/]*" + i += 1 + elif pattern[i] == "?": + out += "[^/]" + i += 1 + else: + out += re.escape(pattern[i]) + i += 1 + return re.compile("^" + out + "$") + + +def filters() -> dict[str, list[str]]: + """Read each workflow's `paths` list. + + The parse is deliberately strict. A workflow that has a `paths:` key but + yields no patterns means the shape changed, and a silently empty filter + would under-report coverage -- so it raises instead. + """ + found: dict[str, list[str]] = {} + for path in sorted(WORKFLOWS.glob("*.yml")): + text = path.read_text() + if "\n paths:\n" not in text: + continue + blocks = re.findall(r"\n paths:\n((?: .*\n)+)", text) + lists = [re.findall(r"- '([^']*)'", block) for block in blocks] + if not lists or not all(lists): + raise SystemExit(f"{path}: has a paths: key but no patterns parsed") + # Actions rejects YAML anchors, so push and pull_request each carry + # their own copy of the list. Two copies drift, and the drift is + # invisible -- both look maintained. Require them to stay identical. + if any(other != lists[0] for other in lists[1:]): + raise SystemExit(f"{path}: its paths: lists disagree between events") + found[path.name] = lists[0] + if not found: + raise SystemExit(f"{WORKFLOWS}: no workflow declares paths:") + return found + + +def matches(patterns: list[str], changed: str) -> bool: + """Apply GitHub's last-match-wins rule over an ordered pattern list.""" + included = False + for pattern in patterns: + negated = pattern.startswith("!") + if to_regex(pattern[1:] if negated else pattern).match(changed): + included = not negated + return included + + +def main(changed: list[str]) -> int: + workflows = filters() + exempt = [(to_regex(p), p, why) for p, why in EXEMPT] + + uncovered = [] + for name in changed: + if any(patterns and matches(patterns, name) for patterns in workflows.values()): + continue + if any(rx.match(name) for rx, _, _ in exempt): + continue + uncovered.append(name) + + if not uncovered: + print( + f"{len(changed)} changed file(s): every one triggers a workflow or is exempt" + ) + return 0 + + print("These changed files trigger no workflow:\n") + for name in uncovered: + print(f" {name}") + print( + "\nAdd each one to the paths: of the workflow that should build it," + "\nor add it to EXEMPT in scripts/check-ci-path-coverage.py with the" + "\nreason nothing needs to run. Both are fine; silence is not." + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) From 823c3c3a76a35135931213aec3d9d2767c23330f Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Tue, 25 Aug 2026 15:52:25 +0800 Subject: [PATCH 6/7] ci: run clang-format and vale as pre-commit hooks The coverage guard reported .clang-format and .vale.ini as files no job reads. Both are now read, by the hook set that lint.yml already runs on every change -- so they also run locally on commit rather than only in CI. vale could not run at all. .vale.ini named Vocab = ros-z, a name the rename to hiroz left behind, and the vocabulary sat in the Vale 2 location (/Vocabularies/) that Vale 3 replaced with /config/ vocabularies/. Either alone makes vale exit 2 before it lints anything. With both fixed it reported 412 alerts. 333 were spelling, over 50 distinct words, and nearly all were terms this project uses -- hiroz, hu, hiroz_msgs and hiroz_py among them, never added when ros-z was renamed. 50 terms go into the vocabulary and clear all 333. The other 79 were write-good Passive, Weasel and TooWordy. Passive voice is frequently the correct choice in reference prose, so those three rules are a style opinion rather than a defect; they are removed, and .vale.ini records why. Spelling is the rule with an objective answer. 'iroz' and 'nion' were not words: docs/tools/why-hu.md wrote '**H**iroz **U**nion', and mid-word bold splits both words in two. Adding those fragments to the vocabulary would mask real typos, so the line reads '**Hiroz Union**' instead. clang-format found 688 violations. 464 are in crates/hiroz-go/hiroz/hiroz_ffi.h, which cbindgen generates from crates/hiroz/build.rs -- formatting it would be undone by the next build, so the hook excludes it. The remaining 224 are in five hand-written files and this reformats them. --- .vale.ini | 9 +- .../vocabularies}/hiroz/accept.txt | 50 ++++++ .vale/styles/write-good/Passive.yml | 12 -- .vale/styles/write-good/TooWordy.yml | 21 --- .vale/styles/write-good/Weasel.yml | 25 --- crates/hiroz-go/hiroz/callback_bridge.c | 16 +- crates/rmw-zenoh-rs/binding.hpp | 4 +- crates/rmw-zenoh-rs/include/rmw_bridge.h | 142 ++++++------------ crates/rmw-zenoh-rs/include/serde_bridge.h | 23 +-- crates/rmw-zenoh-rs/src/serde_bridge.cc | 43 +++--- docs/tools/why-hu.md | 2 +- nix/pre-commit.nix | 24 +++ scripts/check-ci-path-coverage.py | 6 +- 13 files changed, 171 insertions(+), 206 deletions(-) rename .vale/styles/{Vocabularies => config/vocabularies}/hiroz/accept.txt (78%) delete mode 100644 .vale/styles/write-good/Passive.yml delete mode 100644 .vale/styles/write-good/TooWordy.yml delete mode 100644 .vale/styles/write-good/Weasel.yml diff --git a/.vale.ini b/.vale.ini index 5e1b1bbe7..19ac1334c 100644 --- a/.vale.ini +++ b/.vale.ini @@ -1,8 +1,13 @@ StylesPath = .vale/styles -Vocab = ros-z +Vocab = hiroz MinAlertLevel = warning +# Spelling only. The write-good Passive, Weasel and TooWordy rules came here +# from ros-z and flagged 79 spots in the existing docs. Passive voice is +# frequently the correct choice in reference prose, so those three are a +# style opinion rather than a defect. Spelling is the rule with an objective +# answer, so it is the one CI blocks on. [*.md] -BasedOnStyles = Vale, write-good +BasedOnStyles = Vale Vale.Terms = NO diff --git a/.vale/styles/Vocabularies/hiroz/accept.txt b/.vale/styles/config/vocabularies/hiroz/accept.txt similarity index 78% rename from .vale/styles/Vocabularies/hiroz/accept.txt rename to .vale/styles/config/vocabularies/hiroz/accept.txt index 2c4779d3c..b47bebc64 100644 --- a/.vale/styles/Vocabularies/hiroz/accept.txt +++ b/.vale/styles/config/vocabularies/hiroz/accept.txt @@ -188,3 +188,53 @@ Volatile volatile queries_default_timeout publish_feedback +args +bw +composable +Composable +deserializing +dev +enums +envs +ffi +goroutine +Graphviz +hiroz +Hiroz +hiroz_msgs +hiroz_py +hu +hz +interoperates +Interoperates +params +passthrough +proxied +querier +rescan +rmw_cyclonedds_cpp +rmw_fastrtps_cpp +rqt +rqt_console +rqt_graph +rqt_logger_level +rqt_reconfigure +rqt_topic +runtimes +sandboxed +semver +subcommand +subcommands +Subcommands +take_request +teardown +toolset +toolsets +undercounts +unparseable +Waitsets +wasm +workspace +ZMessage +Dexory +add_two_ints diff --git a/.vale/styles/write-good/Passive.yml b/.vale/styles/write-good/Passive.yml deleted file mode 100644 index 9a15421a5..000000000 --- a/.vale/styles/write-good/Passive.yml +++ /dev/null @@ -1,12 +0,0 @@ -extends: existence -message: "Use active voice. '%s' is passive." -level: warning -ignorecase: true -tokens: - - was \w+ed - - were \w+ed - - is \w+ed - - are \w+ed - - be \w+ed - - been \w+ed - - being \w+ed diff --git a/.vale/styles/write-good/TooWordy.yml b/.vale/styles/write-good/TooWordy.yml deleted file mode 100644 index 1b4c29718..000000000 --- a/.vale/styles/write-good/TooWordy.yml +++ /dev/null @@ -1,21 +0,0 @@ -extends: substitution -message: "Use '%s' instead of '%s'." -level: warning -ignorecase: true -swap: - a number of: many - a large number of: many - a majority of: most - at this point in time: now - at the present time: now - due to the fact that: because - in order to: to - in the event that: if - prior to: before - subsequent to: after - with regard to: about - with respect to: about - it is important to note that: "note that" - it should be noted that: "note that" - make sure that: ensure - please note that: "note that" diff --git a/.vale/styles/write-good/Weasel.yml b/.vale/styles/write-good/Weasel.yml deleted file mode 100644 index edff484c5..000000000 --- a/.vale/styles/write-good/Weasel.yml +++ /dev/null @@ -1,25 +0,0 @@ -extends: existence -message: "'%s' is a weasel word — be specific." -level: warning -ignorecase: true -tokens: - - various - - relatively - - fairly - - quite - - rather - - somewhat - - very - - basically - - generally - - largely - - mostly - - mainly - - simply - - actually - - just - - easily - - clearly - - obviously - - naturally - - of course diff --git a/crates/hiroz-go/hiroz/callback_bridge.c b/crates/hiroz-go/hiroz/callback_bridge.c index cabc685c1..e0ba44ea7 100644 --- a/crates/hiroz-go/hiroz/callback_bridge.c +++ b/crates/hiroz-go/hiroz/callback_bridge.c @@ -1,18 +1,10 @@ #include "_cgo_export.h" #include "hiroz_ffi.h" -hiroz_ServiceCallback getServiceCallback() { - return (hiroz_ServiceCallback)goServiceCallback; -} +hiroz_ServiceCallback getServiceCallback() { return (hiroz_ServiceCallback)goServiceCallback; } -hiroz_ActionGoalCallback getActionGoalCallback() { - return (hiroz_ActionGoalCallback)goActionGoalCallback; -} +hiroz_ActionGoalCallback getActionGoalCallback() { return (hiroz_ActionGoalCallback)goActionGoalCallback; } -hiroz_ActionExecuteCallback getActionExecuteCallback() { - return (hiroz_ActionExecuteCallback)goActionExecuteCallback; -} +hiroz_ActionExecuteCallback getActionExecuteCallback() { return (hiroz_ActionExecuteCallback)goActionExecuteCallback; } -hiroz_MessageCallback getSubscriberCallback() { - return (hiroz_MessageCallback)goSubscriberCallback; -} +hiroz_MessageCallback getSubscriberCallback() { return (hiroz_MessageCallback)goSubscriberCallback; } diff --git a/crates/rmw-zenoh-rs/binding.hpp b/crates/rmw-zenoh-rs/binding.hpp index 7a2ebd1eb..8c60f23e2 100644 --- a/crates/rmw-zenoh-rs/binding.hpp +++ b/crates/rmw-zenoh-rs/binding.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -10,9 +11,8 @@ #include #include #include -#include #include -#include +#include #include // Include rmw.h types manually to avoid dynamic_message_type_support.h diff --git a/crates/rmw-zenoh-rs/include/rmw_bridge.h b/crates/rmw-zenoh-rs/include/rmw_bridge.h index a2bbbbcbf..8d4a9c53d 100644 --- a/crates/rmw-zenoh-rs/include/rmw_bridge.h +++ b/crates/rmw-zenoh-rs/include/rmw_bridge.h @@ -7,15 +7,12 @@ #include "rcutils/allocator.h" #include "rcutils/macros.h" #include "rcutils/types.h" - -#include "rosidl_runtime_c/message_type_support_struct.h" -#include "rosidl_runtime_c/service_type_support_struct.h" - -#include "rmw/types.h" +#include "rmw/publisher_options.h" #include "rmw/qos_profiles.h" #include "rmw/subscription_options.h" -#include "rmw/publisher_options.h" - +#include "rmw/types.h" +#include "rosidl_runtime_c/message_type_support_struct.h" +#include "rosidl_runtime_c/service_type_support_struct.h" #include "rust/cxx.h" using c_void = void; @@ -27,112 +24,65 @@ namespace rmw_bridge { extern "C" { // Initialization and Shutdown -const char * -rmw_get_implementation_identifier_bridge(void); +const char* rmw_get_implementation_identifier_bridge(void); -const char * -rmw_get_serialization_format_bridge(void); +const char* rmw_get_serialization_format_bridge(void); -rmw_node_t * -rmw_create_node_bridge( - rmw_context_t * context, - const char * name, - const char * namespace_); +rmw_node_t* rmw_create_node_bridge(rmw_context_t* context, const char* name, const char* namespace_); -rmw_ret_t -rmw_destroy_node_bridge(rmw_node_t * node); +rmw_ret_t rmw_destroy_node_bridge(rmw_node_t* node); // Publishers -rmw_publisher_t * -rmw_create_publisher_bridge( - const rmw_node_t * node, - const rosidl_message_type_support_t * type_support, - const char * topic_name, - const rmw_qos_profile_t * qos_profile, - const rmw_publisher_options_t * publisher_options); - -rmw_ret_t -rmw_destroy_publisher_bridge(rmw_node_t * node, rmw_publisher_t * publisher); - -rmw_ret_t -rmw_publish_bridge( - const rmw_publisher_t * publisher, - const void * ros_message, - rmw_publisher_allocation_t * allocation); +rmw_publisher_t* rmw_create_publisher_bridge(const rmw_node_t* node, const rosidl_message_type_support_t* type_support, + const char* topic_name, const rmw_qos_profile_t* qos_profile, + const rmw_publisher_options_t* publisher_options); + +rmw_ret_t rmw_destroy_publisher_bridge(rmw_node_t* node, rmw_publisher_t* publisher); + +rmw_ret_t rmw_publish_bridge(const rmw_publisher_t* publisher, const void* ros_message, + rmw_publisher_allocation_t* allocation); // Subscriptions -rmw_subscription_t * -rmw_create_subscription_bridge( - const rmw_node_t * node, - const rosidl_message_type_support_t * type_support, - const char * topic_name, - const rmw_qos_profile_t * qos_policies, - const rmw_subscription_options_t * subscription_options); - -rmw_ret_t -rmw_destroy_subscription_bridge(rmw_node_t * node, rmw_subscription_t * subscription); - -rmw_ret_t -rmw_take_bridge( - const rmw_subscription_t * subscription, - void * ros_message, - bool * taken, - rmw_subscription_allocation_t * allocation); +rmw_subscription_t* rmw_create_subscription_bridge(const rmw_node_t* node, + const rosidl_message_type_support_t* type_support, + const char* topic_name, const rmw_qos_profile_t* qos_policies, + const rmw_subscription_options_t* subscription_options); + +rmw_ret_t rmw_destroy_subscription_bridge(rmw_node_t* node, rmw_subscription_t* subscription); + +rmw_ret_t rmw_take_bridge(const rmw_subscription_t* subscription, void* ros_message, bool* taken, + rmw_subscription_allocation_t* allocation); // Services -rmw_client_t * -rmw_create_client_bridge( - const rmw_node_t * node, - const rosidl_service_type_support_t * type_support, - const char * service_name, - const rmw_qos_profile_t * qos_policies); - -rmw_ret_t -rmw_destroy_client_bridge(rmw_node_t * node, rmw_client_t * client); - -rmw_service_t * -rmw_create_service_bridge( - const rmw_node_t * node, - const rosidl_service_type_support_t * type_support, - const char * service_name, - const rmw_qos_profile_t * qos_profile); - -rmw_ret_t -rmw_destroy_service_bridge(rmw_node_t * node, rmw_service_t * service); +rmw_client_t* rmw_create_client_bridge(const rmw_node_t* node, const rosidl_service_type_support_t* type_support, + const char* service_name, const rmw_qos_profile_t* qos_policies); + +rmw_ret_t rmw_destroy_client_bridge(rmw_node_t* node, rmw_client_t* client); + +rmw_service_t* rmw_create_service_bridge(const rmw_node_t* node, const rosidl_service_type_support_t* type_support, + const char* service_name, const rmw_qos_profile_t* qos_profile); + +rmw_ret_t rmw_destroy_service_bridge(rmw_node_t* node, rmw_service_t* service); // Wait sets -rmw_wait_set_t * -rmw_create_wait_set_bridge(rmw_context_t * context, size_t max_conditions); - -rmw_ret_t -rmw_destroy_wait_set_bridge(rmw_wait_set_t * wait_set); - -rmw_ret_t -rmw_wait_bridge( - rmw_subscriptions_t * subscriptions, - rmw_guard_conditions_t * guard_conditions, - rmw_services_t * services, - rmw_clients_t * clients, - rmw_events_t * events, - rmw_wait_set_t * wait_set, - const rmw_time_t * wait_timeout); +rmw_wait_set_t* rmw_create_wait_set_bridge(rmw_context_t* context, size_t max_conditions); + +rmw_ret_t rmw_destroy_wait_set_bridge(rmw_wait_set_t* wait_set); + +rmw_ret_t rmw_wait_bridge(rmw_subscriptions_t* subscriptions, rmw_guard_conditions_t* guard_conditions, + rmw_services_t* services, rmw_clients_t* clients, rmw_events_t* events, + rmw_wait_set_t* wait_set, const rmw_time_t* wait_timeout); // Guard conditions -rmw_guard_condition_t * -rmw_create_guard_condition_bridge(rmw_context_t * context); +rmw_guard_condition_t* rmw_create_guard_condition_bridge(rmw_context_t* context); -rmw_ret_t -rmw_destroy_guard_condition_bridge(rmw_guard_condition_t * guard_condition); +rmw_ret_t rmw_destroy_guard_condition_bridge(rmw_guard_condition_t* guard_condition); -rmw_ret_t -rmw_trigger_guard_condition_bridge(const rmw_guard_condition_t * guard_condition); +rmw_ret_t rmw_trigger_guard_condition_bridge(const rmw_guard_condition_t* guard_condition); // Graph queries -rmw_ret_t -rmw_get_node_names_bridge( - const rmw_node_t * node, - rcutils_string_array_t * node_names, - rcutils_string_array_t * node_namespaces); +rmw_ret_t rmw_get_node_names_bridge(const rmw_node_t* node, rcutils_string_array_t* node_names, + rcutils_string_array_t* node_namespaces); // Add more functions as needed... diff --git a/crates/rmw-zenoh-rs/include/serde_bridge.h b/crates/rmw-zenoh-rs/include/serde_bridge.h index 06b070d88..7269e4d45 100644 --- a/crates/rmw-zenoh-rs/include/serde_bridge.h +++ b/crates/rmw-zenoh-rs/include/serde_bridge.h @@ -1,8 +1,9 @@ #pragma once +#include #include #include -#include + #include #include "rust/cxx.h" @@ -11,25 +12,25 @@ using c_void = void; namespace serde_bridge { -const rosidl_message_type_support_t *get_message_typesupport(const rosidl_message_type_support_t *ts); +const rosidl_message_type_support_t* get_message_typesupport(const rosidl_message_type_support_t* ts); -size_t get_serialized_size(const rosidl_message_type_support_t *ts, const void *ros_message); +size_t get_serialized_size(const rosidl_message_type_support_t* ts, const void* ros_message); -bool serialize_message(const rosidl_message_type_support_t *ts, const void *ros_message, rust::Vec &out); +bool serialize_message(const rosidl_message_type_support_t* ts, const void* ros_message, rust::Vec& out); -bool deserialize_message(const rosidl_message_type_support_t *ts, const rust::Vec &data, void *ros_message); +bool deserialize_message(const rosidl_message_type_support_t* ts, const rust::Vec& data, void* ros_message); -rust::String get_message_name(const rosidl_message_type_support_t *ts); +rust::String get_message_name(const rosidl_message_type_support_t* ts); -rust::String get_message_namespace(const rosidl_message_type_support_t *ts); +rust::String get_message_namespace(const rosidl_message_type_support_t* ts); -const rosidl_service_type_support_t *get_service_typesupport(const rosidl_service_type_support_t *ts); +const rosidl_service_type_support_t* get_service_typesupport(const rosidl_service_type_support_t* ts); -const rosidl_message_type_support_t *get_request_type_support(const rosidl_service_type_support_t *ts); +const rosidl_message_type_support_t* get_request_type_support(const rosidl_service_type_support_t* ts); -const rosidl_message_type_support_t *get_response_type_support(const rosidl_service_type_support_t *ts); +const rosidl_message_type_support_t* get_response_type_support(const rosidl_service_type_support_t* ts); // Type hash support (returns null stub on Humble) -const rosidl_type_hash_t *get_service_type_hash(const rosidl_service_type_support_t *ts); +const rosidl_type_hash_t* get_service_type_hash(const rosidl_service_type_support_t* ts); }; // namespace serde_bridge diff --git a/crates/rmw-zenoh-rs/src/serde_bridge.cc b/crates/rmw-zenoh-rs/src/serde_bridge.cc index 7d024d63a..c372568aa 100644 --- a/crates/rmw-zenoh-rs/src/serde_bridge.cc +++ b/crates/rmw-zenoh-rs/src/serde_bridge.cc @@ -2,15 +2,16 @@ #include #include +#include #include -#include #include -#include + +#include namespace serde_bridge { // In order to avoid depending on fastcdr libirary in Rust, we don't exposes message_type_support_callbacks_t here. -const rosidl_message_type_support_t *get_message_typesupport(const rosidl_message_type_support_t *ts) { +const rosidl_message_type_support_t* get_message_typesupport(const rosidl_message_type_support_t* ts) { if (!ts) { return nullptr; } @@ -27,34 +28,34 @@ const rosidl_message_type_support_t *get_message_typesupport(const rosidl_messag return type_support; } -size_t get_serialized_size(const rosidl_message_type_support_t *ts, const void *ros_message) { - const message_type_support_callbacks_t *callbacks = static_cast(ts->data); +size_t get_serialized_size(const rosidl_message_type_support_t* ts, const void* ros_message) { + const message_type_support_callbacks_t* callbacks = static_cast(ts->data); return 4 + callbacks->get_serialized_size(ros_message); } -bool serialize_message(const rosidl_message_type_support_t *ts, const void *ros_message, rust::Vec &out) { +bool serialize_message(const rosidl_message_type_support_t* ts, const void* ros_message, rust::Vec& out) { if (!ts || !ros_message) { return false; } - auto callbacks = static_cast(ts->data); + auto callbacks = static_cast(ts->data); - eprosima::fastcdr::FastBuffer buffer(reinterpret_cast(out.data()), out.size()); + eprosima::fastcdr::FastBuffer buffer(reinterpret_cast(out.data()), out.size()); eprosima::fastcdr::Cdr ser(buffer, eprosima::fastcdr::Cdr::DEFAULT_ENDIAN, eprosima::fastcdr::CdrVersion::DDS_CDR); ser.serialize_encapsulation(); return callbacks->cdr_serialize(ros_message, ser); } -bool deserialize_message(const rosidl_message_type_support_t *ts, const rust::Vec &data, void *ros_message) { +bool deserialize_message(const rosidl_message_type_support_t* ts, const rust::Vec& data, void* ros_message) { if (!ts || !ros_message) { return false; } try { - auto callbacks = static_cast(ts->data); + auto callbacks = static_cast(ts->data); - eprosima::fastcdr::FastBuffer buffer(reinterpret_cast(const_cast(data.data())), data.size()); + eprosima::fastcdr::FastBuffer buffer(reinterpret_cast(const_cast(data.data())), data.size()); eprosima::fastcdr::Cdr cdr(buffer); cdr.read_encapsulation(); @@ -64,17 +65,17 @@ bool deserialize_message(const rosidl_message_type_support_t *ts, const rust::Ve } } -rust::String get_message_name(const rosidl_message_type_support_t *ts) { - auto callbacks = static_cast(ts->data); +rust::String get_message_name(const rosidl_message_type_support_t* ts) { + auto callbacks = static_cast(ts->data); return rust::String(callbacks->message_name_); } -rust::String get_message_namespace(const rosidl_message_type_support_t *ts) { - auto callbacks = static_cast(ts->data); +rust::String get_message_namespace(const rosidl_message_type_support_t* ts) { + auto callbacks = static_cast(ts->data); return rust::String(callbacks->message_namespace_); } -const rosidl_service_type_support_t *get_service_typesupport(const rosidl_service_type_support_t *ts) { +const rosidl_service_type_support_t* get_service_typesupport(const rosidl_service_type_support_t* ts) { if (!ts) { return nullptr; } @@ -91,17 +92,17 @@ const rosidl_service_type_support_t *get_service_typesupport(const rosidl_servic return type_support; } -const rosidl_message_type_support_t *get_request_type_support(const rosidl_service_type_support_t *ts) { - auto callbacks = static_cast(ts->data); +const rosidl_message_type_support_t* get_request_type_support(const rosidl_service_type_support_t* ts) { + auto callbacks = static_cast(ts->data); return callbacks->request_members_; } -const rosidl_message_type_support_t *get_response_type_support(const rosidl_service_type_support_t *ts) { - auto callbacks = static_cast(ts->data); +const rosidl_message_type_support_t* get_response_type_support(const rosidl_service_type_support_t* ts) { + auto callbacks = static_cast(ts->data); return callbacks->response_members_; } -const rosidl_type_hash_t *get_service_type_hash(const rosidl_service_type_support_t *ts) { +const rosidl_type_hash_t* get_service_type_hash(const rosidl_service_type_support_t* ts) { if (!ts || !ts->get_type_hash_func) { return nullptr; } diff --git a/docs/tools/why-hu.md b/docs/tools/why-hu.md index 7f610c17e..d732a6dd1 100644 --- a/docs/tools/why-hu.md +++ b/docs/tools/why-hu.md @@ -1,6 +1,6 @@ # hu Toolkit — Overview -`hu` — short for **H**iroz **U**nion, and the crate that builds it is [`hiroz-union`](https://github.com/ZettaScaleLabs/hiroz/tree/main/crates/hiroz-union) — is the tooling ecosystem for the hiroz stack: a single, daemon-free binary that talks directly to Zenoh and grows through WebAssembly plugins. It replaces `ros2 topic`, `ros2 node`, `ros2 service`, `ros2 action`, `ros2 param`, and much of `rqt` — but the more important idea is that most of what `hu` does, including the observation and measurement commands that ship in the box, is delivered as **plugins**. This page introduces that ecosystem; the rest of this section drills into each part. +`hu` — short for **Hiroz Union**, and the crate that builds it is [`hiroz-union`](https://github.com/ZettaScaleLabs/hiroz/tree/main/crates/hiroz-union) — is the tooling ecosystem for the hiroz stack: a single, daemon-free binary that talks directly to Zenoh and grows through WebAssembly plugins. It replaces `ros2 topic`, `ros2 node`, `ros2 service`, `ros2 action`, `ros2 param`, and much of `rqt` — but the more important idea is that most of what `hu` does, including the observation and measurement commands that ship in the box, is delivered as **plugins**. This page introduces that ecosystem; the rest of this section drills into each part. ## The ecosystem at a glance diff --git a/nix/pre-commit.nix b/nix/pre-commit.nix index 44fc41857..2e9c9f671 100644 --- a/nix/pre-commit.nix +++ b/nix/pre-commit.nix @@ -122,6 +122,30 @@ git-hooks.lib.${system}.run { nixfmt-rfc-style.enable = true; + # crates/hiroz-go/hiroz/hiroz_ffi.h is generated by cbindgen from + # crates/hiroz/build.rs, so formatting it would be undone by the next + # `cargo build --features ffi` and show up as a spurious diff. Excluded + # here rather than in .clang-format-ignore so the reason travels with it. + clang-format = { + enable = true; + types_or = [ + "c" + "c++" + ]; + excludes = [ "crates/hiroz-go/hiroz/hiroz_ffi\\.h$" ]; + }; + + # Spelling only -- see .vale.ini for why the write-good rules are not + # enabled. Vale exits non-zero on any alert at or above MinAlertLevel. + vale = { + enable = true; + name = "vale"; + description = "Check documentation prose for unknown words"; + entry = "${pkgs.vale}/bin/vale"; + files = "\\.md$"; + pass_filenames = true; + }; + # Documentation build check mkdocs-build = { enable = false; # re-enable after running `nix develop` to rebuild hooks with correct mkdocsPkg diff --git a/scripts/check-ci-path-coverage.py b/scripts/check-ci-path-coverage.py index 87e3c2e96..f23831cf2 100644 --- a/scripts/check-ci-path-coverage.py +++ b/scripts/check-ci-path-coverage.py @@ -47,9 +47,9 @@ ("cliff.toml", "git-cliff changelog config, used at release time"), ("assets/**", "images referenced by prose"), ("LICENSE", "text"), - (".clang-format", "no job runs clang-format; it configures editors only"), - (".vale.ini", "vale ships in the devshell but no job runs it"), - (".vale/**", "vale ships in the devshell but no job runs it"), + (".clang-format", "lint.yml runs clang-format on every change"), + (".vale.ini", "lint.yml runs vale on every change"), + (".vale/**", "lint.yml runs vale on every change"), ("*.md", "docs.yml covers docs; root prose triggers nothing else"), ] From a2bff597a33259e903ef6883999785a0c94fb034 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Tue, 25 Aug 2026 18:04:52 +0800 Subject: [PATCH 7/7] ci: give release.yml back the only check it has scripts/test-release-version-semantics.sh reads .github/workflows/ release.yml and asserts against it. release.yml itself runs only on a v* tag, so no pull request exercises it and that script is its one check before a tag is pushed. Under the old deny-list an edit to release.yml ran ci.yml, which owned that script. Moving the job to hu-docs.yml dropped it: neither event listed release.yml, so the check silently stopped running. hu-docs.yml now lists it. The coverage guard should have caught this and did not. Its EXEMPT list carried a blanket .github/**, which swallowed every file under that directory -- including the one whose only check had just disappeared. The blanket is gone; a workflow that no path filter reaches now needs its own line and its own reason. Removing it immediately found a second instance: mkdocs-preview.yml has a paths: filter that omitted its own filename, so editing that workflow triggered nothing and it could not test itself. It now lists itself, as every other filtered workflow does. --- .github/workflows/hu-docs.yml | 8 ++++++++ .github/workflows/mkdocs-preview.yml | 1 + scripts/check-ci-path-coverage.py | 5 ----- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/hu-docs.yml b/.github/workflows/hu-docs.yml index 899c1ab77..ac2933ca7 100644 --- a/.github/workflows/hu-docs.yml +++ b/.github/workflows/hu-docs.yml @@ -19,6 +19,10 @@ on: - 'Cargo.lock' - 'flake.nix' - 'flake.lock' + # test-release-version-semantics.sh reads and asserts release.yml, + # and release.yml itself only ever runs on a v* tag. This job is + # the only check it gets before a tag is pushed. + - '.github/workflows/release.yml' - '.github/workflows/hu-docs.yml' pull_request: types: [opened, synchronize, reopened, ready_for_review] @@ -35,6 +39,10 @@ on: - 'Cargo.lock' - 'flake.nix' - 'flake.lock' + # test-release-version-semantics.sh reads and asserts release.yml, + # and release.yml itself only ever runs on a v* tag. This job is + # the only check it gets before a tag is pushed. + - '.github/workflows/release.yml' - '.github/workflows/hu-docs.yml' concurrency: diff --git a/.github/workflows/mkdocs-preview.yml b/.github/workflows/mkdocs-preview.yml index 14680e91b..4bd72d4df 100644 --- a/.github/workflows/mkdocs-preview.yml +++ b/.github/workflows/mkdocs-preview.yml @@ -7,6 +7,7 @@ on: - 'docs/**' - 'mkdocs.yml' - '**.md' + - '.github/workflows/mkdocs-preview.yml' concurrency: group: mkdocs-preview-${{ github.ref }} diff --git a/scripts/check-ci-path-coverage.py b/scripts/check-ci-path-coverage.py index f23831cf2..114faaa70 100644 --- a/scripts/check-ci-path-coverage.py +++ b/scripts/check-ci-path-coverage.py @@ -30,15 +30,10 @@ # oversight. EXEMPT: list[tuple[str, str]] = [ (".github/workflows/lint.yml", "runs on every change; it has no filter to match"), - ( - ".github/workflows/release.yml", - "runs on v* tags, not on pushes or pull requests", - ), (".github/workflows/docs-links.yml", "runs on a schedule"), (".github/workflows/semantic-pr.yml", "runs on every pull request"), (".github/workflows/pr-draft-check.yml", "runs on every pull request"), (".github/pull_request_template.md", "text GitHub renders; nothing consumes it"), - (".github/**", "workflow metadata that no job reads"), (".gitignore", "affects no build"), (".gitattributes", "affects no build"), (".envrc", "local developer setup"),