diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b497871c..deba360c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,62 +3,65 @@ 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). + # + # 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' + - '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). + # + # 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' + - '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 +562,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-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 }} 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..ac2933ca7 --- /dev/null +++ b/.github/workflows/hu-docs.yml @@ -0,0 +1,146 @@ +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/**' + - '.config/nextest.toml' + - '.cargo/config.toml' + - 'Cargo.toml' + - '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] + branches: [main] + paths: + - 'docs/tools/**' + - 'crates/**' + - '!crates/**/*.md' + - 'scripts/**' + - 'nix/**' + - '.config/nextest.toml' + - '.cargo/config.toml' + - 'Cargo.toml' + - '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: + 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..a7f15fbcd --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,78 @@ +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 + + 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/.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/.github/workflows/rmw-zenoh-rs.yml b/.github/workflows/rmw-zenoh-rs.yml index 8bc827833..78a40cb7a 100644 --- a/.github/workflows/rmw-zenoh-rs.yml +++ b/.github/workflows/rmw-zenoh-rs.yml @@ -3,27 +3,55 @@ 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). + # + # 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' + - '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). + # + # 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' + - '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..cec34f6cf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,27 +3,55 @@ 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). + # + # 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' + - '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). + # + # 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' + - 'flake.lock' + - '.github/workflows/test.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} 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", ] 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 new file mode 100644 index 000000000..114faaa70 --- /dev/null +++ b/scripts/check-ci-path-coverage.py @@ -0,0 +1,141 @@ +#!/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/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"), + (".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", "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"), +] + + +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:]))