name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

# Least-privilege default for the auto-injected GITHUB_TOKEN. None of the CI
# jobs write back to the repo — coverage uploads via CODECOV_TOKEN, everything
# else is build/test/lint — so a read-only token is sufficient (OpenSSF
# Scorecard: Token-Permissions).
permissions:
  contents: read

env:
  CARGO_TERM_COLOR: always
  RUSTFLAGS: "-D warnings"
  # Network-flake resilience: retry transient registry/DNS failures at the tool
  # level so a blip fetching crates.io / npm / PyPI inside any build step (cargo,
  # napi, maturin, wasm-pack, npm ci, pip) retries automatically instead of
  # failing the job and needing a manual re-run. Cargo treats "couldn't resolve
  # host" / connect / timeout as spurious and retries with backoff; 10 attempts
  # ride out a transient DNS blip on a runner. Complements the setup-action /
  # cache retries (which only covered toolchain download + cache restore).
  CARGO_NET_RETRY: "10"
  CARGO_NET_GIT_FETCH_WITH_CLI: "true"
  npm_config_fetch_retries: "5"
  npm_config_fetch_retry_maxtimeout: "120000"
  PIP_RETRIES: "5"
  PIP_DEFAULT_TIMEOUT: "120"

jobs:
  rust:
    name: Rust ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
        with:
          components: rustfmt, clippy

      - name: Cache cargo
        uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
        continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
        timeout-minutes: 6

      - name: Warm cargo registry (retry transient DNS/registry flakes)
        shell: bash
        # CARGO_NET_RETRY rides out short blips, but a longer runner DNS outage
        # outlasts cargo's rapid in-process retries: the crates.io index fetch
        # the first cargo step does ("Could not resolve host: index.crates.io")
        # then fails the whole job. Pre-fetch the dependency graph here with real
        # backoff so clippy/build/test resolve from the warmed local cache.
        run: |
          for attempt in 1 2 3 4 5; do
            if cargo fetch; then exit 0; fi
            echo "::warning::cargo fetch failed (attempt $attempt/5) — likely a registry/DNS flake; retrying in $((attempt * 20))s..."
            sleep $((attempt * 20))
          done
          echo "::error::cargo fetch still failing after 5 attempts"
          exit 1

      - name: Format check
        run: cargo fmt --all -- --check

      - name: Clippy (workspace, all targets)
        run: cargo clippy -p wickra-core -p wickra -p wickra-data -p wickra-wasm --all-targets -- -D warnings

      - name: Build
        run: cargo build -p wickra-core -p wickra -p wickra-data --verbose

      - name: Tests (default features)
        run: cargo test -p wickra-core -p wickra -p wickra-data --verbose

      - name: Tests (live-binance feature)
        run: cargo test -p wickra-data --features live-binance --verbose

      - name: Compile benches (smoke)
        run: cargo build -p wickra --benches --verbose

      - name: Compile examples
        # All runnable examples now live in the dedicated wickra-examples crate
        # (examples/rust/src/bin/*.rs) which enables the live-binance feature
        # on its wickra-data dep, so a single --bins build covers backtest,
        # live_binance, fetch_btcusdt, multi_timeframe, parallel_assets and
        # streaming.
        run: cargo build -p wickra-examples --bins

  # Syntax/parse smoke for the non-Rust examples. The Rust examples are built
  # in the `rust` job above (`cargo build -p wickra-examples --bins`); the Node,
  # browser-WASM and Python examples otherwise have no build gate, so a broken
  # edit could land unnoticed. This is a parse-only smoke — actually running the
  # examples needs the built native binding / wasm module / wheel, which the
  # binding jobs provide separately.
  examples-smoke:
    name: Examples (syntax smoke)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Set up Node
        id: setup_node
        continue-on-error: true
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: "20"

      - name: Wait before Node retry
        if: steps.setup_node.outcome == 'failure'
        shell: bash
        run: |
          echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
          sleep 30

      - name: Set up Node (retry)
        if: steps.setup_node.outcome == 'failure'
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: "20"

      - name: Set up Python
        id: setup_python
        continue-on-error: true
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: "3.12"

      - name: Wait before Python retry
        if: steps.setup_python.outcome == 'failure'
        shell: bash
        run: |
          echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
          sleep 30

      - name: Set up Python (retry)
        if: steps.setup_python.outcome == 'failure'
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: "3.12"

      - name: Node examples — syntax check
        run: |
          shopt -s nullglob
          count=0
          for f in examples/node/*.js examples/wasm/*.js; do
            echo "node --check $f"
            node --check "$f"
            count=$((count + 1))
          done
          echo "checked $count Node/WASM .js files"

      - name: WASM demo module scripts — syntax check
        # The .html demos embed an ES module; extract it and parse-check so a
        # broken edit to the in-page strategy logic fails CI.
        run: |
          shopt -s nullglob
          count=0
          for f in examples/wasm/*.html; do
            node -e 'const fs=require("fs");const h=fs.readFileSync(process.argv[1],"utf8");const m=h.match(/<script type="module">([\s\S]*?)<\/script>/);if(!m){console.error("no <script type=module> in "+process.argv[1]);process.exit(1);}fs.writeFileSync("module-check.mjs",m[1]);' "$f"
            echo "node --check (module of) $f"
            node --check module-check.mjs
            count=$((count + 1))
          done
          rm -f module-check.mjs
          echo "checked $count WASM .html module scripts"

      - name: Python examples — byte-compile
        run: |
          shopt -s nullglob
          count=0
          for f in examples/python/*.py; do
            echo "py_compile $f"
            python -m py_compile "$f"
            count=$((count + 1))
          done
          echo "compiled $count Python files"

  # Clippy for the Python and Node bindings. These are kept out of the main
  # `rust` job because PyO3 / napi build scripts need a Python interpreter and
  # a Node toolchain on PATH, which the 3-OS matrix job does not provision.
  # Ubuntu-only is sufficient: the lints are platform-independent.
  clippy-bindings:
    name: Clippy bindings
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
        with:
          components: clippy

      - name: Set up Python
        id: setup_python
        continue-on-error: true
        uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
        with:
          python-version: "3.12"

      - name: Wait before Python retry
        if: steps.setup_python.outcome == 'failure'
        shell: bash
        run: |
          echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
          sleep 30

      - name: Set up Python (retry)
        if: steps.setup_python.outcome == 'failure'
        uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
        with:
          python-version: "3.12"

      - name: Set up Node
        id: setup_node
        continue-on-error: true
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: "20"

      - name: Wait before Node retry
        if: steps.setup_node.outcome == 'failure'
        shell: bash
        run: |
          echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
          sleep 30

      - name: Set up Node (retry)
        if: steps.setup_node.outcome == 'failure'
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: "20"

      - name: Cache cargo
        uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
        continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
        timeout-minutes: 6

      - name: Warm cargo registry (retry transient DNS/registry flakes)
        shell: bash
        # See the rust job: pre-fetch with backoff so the clippy index update
        # can't fail the job on a transient "Could not resolve host" DNS blip.
        run: |
          for attempt in 1 2 3 4 5; do
            if cargo fetch; then exit 0; fi
            echo "::warning::cargo fetch failed (attempt $attempt/5) — likely a registry/DNS flake; retrying in $((attempt * 20))s..."
            sleep $((attempt * 20))
          done
          echo "::error::cargo fetch still failing after 5 attempts"
          exit 1

      - name: Clippy (bindings, all targets)
        run: cargo clippy -p wickra-node -p wickra-python --all-targets -- -D warnings

  # Verify the crates still build and test on their declared minimum supported
  # Rust version. The workspace pins rust-version = "1.86" — that floor is
  # set by criterion 0.8.2 (the bench dev-dep), which itself rolled past the
  # clap_lex 1.1.0 / edition2024 / Rust 1.85 floor and now needs 1.86; the
  # earlier rayon-core 1.13.0 (1.80) and clap_lex (1.85) requirements are
  # subsumed. bindings/node pins rust-version = "1.88" because napi-build
  # 2.3.2 requires it (and that subsumes the older 1.77 floor needed for
  # `cargo::` directives). Without this job an accidental use of a newer
  # API would only surface for downstream users.
  msrv:
    name: ${{ matrix.name }}
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        include:
          - name: MSRV workspace (Rust 1.86)
            toolchain: "1.86"
            packages: "-p wickra-core -p wickra -p wickra-data"
          - name: MSRV node binding (Rust 1.88)
            toolchain: "1.88"
            packages: "-p wickra-node"
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Install Rust ${{ matrix.toolchain }}
        uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
        with:
          toolchain: ${{ matrix.toolchain }}

      - name: Cache cargo
        uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
        continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
        timeout-minutes: 6

      - name: Warm cargo registry (retry transient DNS/registry flakes)
        shell: bash
        # See the rust job: pre-fetch with backoff so the first cargo step can't
        # fail the job on a transient "Could not resolve host" DNS blip.
        run: |
          for attempt in 1 2 3 4 5; do
            if cargo fetch; then exit 0; fi
            echo "::warning::cargo fetch failed (attempt $attempt/5) — likely a registry/DNS flake; retrying in $((attempt * 20))s..."
            sleep $((attempt * 20))
          done
          echo "::error::cargo fetch still failing after 5 attempts"
          exit 1

      - name: Build on MSRV
        run: cargo build ${{ matrix.packages }} --verbose

      - name: Test on MSRV
        run: cargo test ${{ matrix.packages }} --verbose

  # Code coverage for the pure-Rust crates, uploaded to Codecov.
  coverage:
    name: Coverage
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
        with:
          components: llvm-tools-preview

      - name: Cache cargo
        uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
        continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
        timeout-minutes: 6

      - name: Install cargo-llvm-cov
        uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
        timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
        with:
          tool: cargo-llvm-cov

      - name: Generate coverage (lcov)
        run: >
          cargo llvm-cov
          -p wickra-core -p wickra -p wickra-data
          --features wickra-data/live-binance
          --lcov --output-path lcov.info

      - name: Upload to Codecov
        uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
        with:
          files: lcov.info
          fail_ci_if_error: false
        env:
          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

  # Supply-chain audit: security advisories, license policy, banned crates,
  # and source restrictions. Configured by deny.toml at the repo root.
  supply-chain:
    name: Supply-chain (cargo-deny)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: cargo-deny
        uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe # v2.0.20
        with:
          command: check

  # Time-boxed fuzz smoke. Each target runs for ~30 s with libfuzzer; any panic
  # fails the job. The goal is to catch a regression in the harness (e.g. a
  # newly added indicator that panics on a particular input shape), not to
  # discover novel bugs — long fuzz campaigns should be run on dedicated
  # infrastructure with persistent corpora.
  fuzz-smoke:
    name: Fuzz (smoke)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Install nightly Rust
        uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
        with:
          toolchain: nightly

      - name: Cache cargo
        uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
        continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
        timeout-minutes: 6
        with:
          workspaces: fuzz

      - name: Install cargo-fuzz
        # Use the prebuilt binary from taiki-e/install-action instead of
        # `cargo install cargo-fuzz --locked`. The latter resolves the
        # version graph from cargo-fuzz's own Cargo.lock, which pins to
        # rustix 0.36.5 — a version that still uses internal `rustc_*`
        # attributes the modern nightly compiler rejects, so the install
        # never gets off the ground. The prebuilt binary avoids the entire
        # transitive-dep compile.
        uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
        timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
        with:
          tool: cargo-fuzz

      - name: Fuzz csv_reader (30 s)
        run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu csv_reader -- -max_total_time=30
        working-directory: fuzz

      - name: Fuzz binance_envelope (30 s)
        run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu binance_envelope -- -max_total_time=30
        working-directory: fuzz

      - name: Fuzz indicator_update (30 s)
        run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu indicator_update -- -max_total_time=30
        working-directory: fuzz

      - name: Fuzz indicator_update_candle (30 s)
        run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu indicator_update_candle -- -max_total_time=30
        working-directory: fuzz

      - name: Fuzz tick_aggregator (30 s)
        run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu tick_aggregator -- -max_total_time=30
        working-directory: fuzz

  python:
    name: Python ${{ matrix.python-version }} on ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        python-version: ["3.9", "3.11", "3.12", "3.13"]
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27

      - name: Cache cargo
        uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
        continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
        timeout-minutes: 6

      # setup-python downloads the interpreter from the Actions tool cache /
      # nodejs CDN and occasionally hangs or 5xx's on the Windows runners.
      # Run it with continue-on-error, then retry once after a backoff so a
      # single CDN flake does not fail the whole job (see also: GitHub
      # Actions runner-images#7061).
      - name: Set up Python
        id: setup_python
        continue-on-error: true
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: ${{ matrix.python-version }}
          cache: pip
          cache-dependency-path: .github/requirements/ci-dev-*.txt

      - name: Wait before Python retry
        if: steps.setup_python.outcome == 'failure'
        shell: bash
        run: |
          echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
          sleep 30

      - name: Set up Python (retry)
        if: steps.setup_python.outcome == 'failure'
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: ${{ matrix.python-version }}
          cache: pip
          cache-dependency-path: .github/requirements/ci-dev-*.txt

      - name: Install Python dev dependencies
        shell: bash
        run: |
          python -m pip install --upgrade pip
          # Hash-locked dev tooling (OpenSSF Scorecard PinnedDependencies).
          # Split by Python version: numpy ships no single release with wheels
          # for both cp39 and cp313 (<=2.0.2 has cp39 only, >=2.1 drops cp39).
          if [ "${{ matrix.python-version }}" = "3.9" ]; then
            python -m pip install --require-hashes -r .github/requirements/ci-dev-py39.txt
          else
            python -m pip install --require-hashes -r .github/requirements/ci-dev-py3.txt
          fi

      - name: Build wheel
        working-directory: bindings/python
        run: maturin build --release --out dist

      - name: Install wheel
        shell: bash
        working-directory: bindings/python
        # --no-index forces pip to ignore PyPI; --no-deps skips re-resolving
        # numpy (already installed in the previous step). Without --no-index
        # pip prefers the PyPI 0.2.x wheel over our freshly built one when
        # platform tags overlap (e.g. macOS arm64), so tests would run
        # against the released package and miss any new symbols the PR adds.
        run: python -m pip install --no-index --find-links dist --force-reinstall --no-deps wickra

      - name: Run Python tests
        working-directory: bindings/python
        run: pytest -v

  wasm:
    name: WASM build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Install Rust toolchain (with wasm target)
        uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
        with:
          targets: wasm32-unknown-unknown

      - name: Cache cargo
        uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
        continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
        timeout-minutes: 6

      - name: Install wasm-pack
        # jetli/wasm-pack-action@v0.4.0 with no `version:` input installs an
        # old wasm-pack (~0.10.x) whose `build` subcommand does not yet accept
        # `--features`, so `wasm-pack build … --features panic-hook` fails
        # with "Found argument '--features' which wasn't expected". Use the
        # same taiki-e prebuilt-binary installer we already use for
        # cargo-llvm-cov and cargo-fuzz; it tracks the latest wasm-pack
        # release, which has `--features` as a top-level flag (since 0.12).
        uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
        timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
        with:
          tool: wasm-pack

      - name: Build WASM package
        run: wasm-pack build bindings/wasm --target web --release --features panic-hook

      - name: Run WASM tests
        run: wasm-pack test --node bindings/wasm

      - name: Verify generated artefacts
        run: |
          test -f bindings/wasm/pkg/wickra_wasm.js
          test -f bindings/wasm/pkg/wickra_wasm_bg.wasm
          test -f bindings/wasm/pkg/wickra_wasm.d.ts

  node:
    name: Node ${{ matrix.node-version }} on ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        node-version: ["18", "20"]
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27

      - name: Cache cargo
        uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
        continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
        timeout-minutes: 6

      # setup-node downloads Node from nodejs.org and we've seen it fail on
      # Windows runners with "Attempting to download 18..." followed by a
      # silent hang or curl error. Retry once after a backoff so a single
      # CDN flake does not fail the whole job.
      - name: Set up Node
        id: setup_node
        continue-on-error: true
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: ${{ matrix.node-version }}
          cache: npm
          cache-dependency-path: bindings/node/package-lock.json

      - name: Wait before Node retry
        if: steps.setup_node.outcome == 'failure'
        shell: bash
        run: |
          echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
          sleep 30

      - name: Set up Node (retry)
        if: steps.setup_node.outcome == 'failure'
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: ${{ matrix.node-version }}
          cache: npm
          cache-dependency-path: bindings/node/package-lock.json

      - name: Install Node dependencies
        working-directory: bindings/node
        run: npm ci

      - name: Build native module
        working-directory: bindings/node
        # --platform puts the target triple into the filename so the loader's
        # `wickra.<target>.node` lookup finds the freshly built binary instead
        # of falling back to the per-platform npm subpackage (which doesn't
        # exist yet for win32-x64-msvc).
        run: npx napi build --platform --release

      - name: Run Node tests
        working-directory: bindings/node
        run: node --test __tests__/

  # The cross-library benchmark has moved to a dedicated scheduled workflow
  # (.github/workflows/bench.yml) — see audit finding R10. It runs nightly
  # at 03:00 UTC and on-demand via `workflow_dispatch`, and is no longer on
  # the every-push / every-PR critical path.
