From 04fbaad40011aac5592c4fe5465e31d63897bdfc Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Fri, 2 Oct 2026 10:35:11 +1000 Subject: [PATCH 1/4] ci: port the stack-* crate, binding and Go workflows Adds the root workflows for the imported set (plan section 7.5): tests-crates, crap-crates, mutants, fuzz, miri, tests-golang and udeps, ported from the suite's test-unit (the set's share), test-stack-auth, test-stack-profile, crap-stack-auth, crap-stack-encrypt, mutants, fuzz, miri, test-wasi and test-no-unused-cargo-dependencies. Every job runs mise at the root with `working_directory: .`. The root workspace is tested with nextest only; plain `cargo test` races the stack-kms tests that set environment variables. tests-crates.yml rebuilds both node bindings and fails if that leaves the auth or profile package changed, untracked files included: napi must write native.d.ts and never the hand-written index.d.ts. tests.yml's binding-build step, from the import PR, now points at that check. Registers the new jobs with the workflow guards: the four PR-only gates in workflow-paths-filter-parity, the mutants comment job in workflow-publish-permissions, and the Go live job in ffi-binding-step-order. The EQL rust-cache workspace check now applies to EQL's jobs only, with a non-empty floor. AGENTS.md and docs/fuzzing.md describe the CI that now exists. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- .github/workflows/crap-crates.yml | 96 +++++ .github/workflows/fuzz.yml | 180 ++++++++++ .github/workflows/miri.yml | 66 ++++ .github/workflows/mutants.yml | 201 +++++++++++ .github/workflows/tests-crates.yml | 205 +++++++++++ .github/workflows/tests-golang.yml | 329 ++++++++++++++++++ .github/workflows/tests.yml | 3 +- .github/workflows/udeps.yml | 85 +++++ AGENTS.md | 24 +- docs/fuzzing.md | 6 +- packages/stack-auth/tasks.toml | 2 +- packages/stack-encrypt/tasks.toml | 2 +- scripts/__tests__/eql-suite-ci.test.mjs | 27 +- .../__tests__/ffi-binding-step-order.test.mjs | 5 + .../workflow-paths-filter-parity.test.mjs | 20 ++ .../workflow-publish-permissions.test.mjs | 3 + scripts/go-binding-test.sh | 2 +- 17 files changed, 1241 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/crap-crates.yml create mode 100644 .github/workflows/fuzz.yml create mode 100644 .github/workflows/miri.yml create mode 100644 .github/workflows/mutants.yml create mode 100644 .github/workflows/tests-crates.yml create mode 100644 .github/workflows/tests-golang.yml create mode 100644 .github/workflows/udeps.yml diff --git a/.github/workflows/crap-crates.yml b/.github/workflows/crap-crates.yml new file mode 100644 index 000000000..58e1ad292 --- /dev/null +++ b/.github/workflows/crap-crates.yml @@ -0,0 +1,96 @@ +name: CRAP gate (crates) + +# Gates pull requests that touch stack-auth or stack-encrypt on the CRAP +# (Change Risk Anti-Patterns) metric: cyclomatic complexity weighted by test +# coverage, so it surfaces complex, under-tested functions. Ported from +# cipherstash-suite's `crap-stack-auth.yml` and `crap-stack-encrypt.yml`, as +# one job per crate in one file. +# +# Blocking: each `crap:` mise task runs `cargo crap --fail-above`, so the +# job fails when any function scores above the threshold in .cargo-crap.toml +# (30). mise appends the trailing args after `--` to the task's LAST command, +# which is `cargo crap`; `--format github` turns each offending function into +# an inline `::warning` annotation before the job fails. +# +# Pull requests only, so `push` has no filter to mirror; recorded in +# scripts/__tests__/workflow-paths-filter-parity.test.mjs. + +on: + pull_request: + paths: + - packages/stack-auth/** + - packages/stack-encrypt/** + - packages/stack-encrypt-derive/** + - Cargo.toml + - Cargo.lock + - mise.toml + - .cargo-crap.toml + - .github/workflows/crap-crates.yml + # Keep these excludes last so docs-only changes are skipped. + - "!**.md" + - "!**.example" + workflow_dispatch: {} + +# Read-only: failures surface as job status and inline annotations. +permissions: + contents: read + +defaults: + run: + shell: bash + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + RUST_BACKTRACE: full + CARGO_TERM_COLOR: always + CARGO_NET_GIT_FETCH_WITH_CLI: true + NEXTEST_PROFILE: ci + +jobs: + crap-stack-auth: + name: CRAP (stack-auth) + runs-on: blacksmith-16vcpu-ubuntu-2204 + timeout-minutes: 45 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + # rust carries llvm-tools-preview, which cargo-llvm-cov needs. + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + working_directory: . + install_args: rust cargo:cargo-nextest cargo:cargo-llvm-cov cargo:cargo-crap + cache: true + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + - name: Run the CRAP gate + run: mise run crap:stack-auth -- --format github + + crap-stack-encrypt: + name: CRAP (stack-encrypt) + runs-on: blacksmith-16vcpu-ubuntu-2204 + timeout-minutes: 45 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + working_directory: . + install_args: rust cargo:cargo-nextest cargo:cargo-llvm-cov cargo:cargo-crap + cache: true + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + - name: Run the CRAP gate + run: mise run crap:stack-encrypt -- --format github diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 000000000..27b03f1d4 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,180 @@ +name: Fuzz (crates) + +# libFuzzer fuzzing for the stack crates' untrusted-input parsers (the +# detached `packages/*/fuzz/` crates and the `fuzz:*` mise tasks). Ported from +# cipherstash-suite's `fuzz.yml`, for the six targets in the imported set; the +# suite keeps its three cts-common targets. Two jobs with different roles: +# +# * fuzz-regression (pull_request and manual, blocking on PRs): builds every harness, which +# catches harness and API drift, and replays the committed seed corpus with +# `-runs=0`. Deterministic, so it is safe to gate PRs. +# +# * fuzz-campaign (schedule and manual, never on PRs): the time-boxed +# bug-finding run. Each target's corpus persists across runs in the Actions +# cache, so coverage compounds; it is minimised with `cargo fuzz cmin`, and +# any crash reproducer is uploaded as an artifact. +# +# cargo-fuzz needs nightly; the tasks run `cargo +nightly fuzz`. Pull requests +# are the only filtered event, so `push` has no filter to mirror; recorded in +# scripts/__tests__/workflow-paths-filter-parity.test.mjs. + +on: + pull_request: + paths: + - packages/stack-auth/** + - packages/stack-kms/** + - packages/stack-encrypt/** + - Cargo.toml + - Cargo.lock + - mise.toml + - .github/workflows/fuzz.yml + # Keep these excludes last so docs-only changes are skipped. + - "!**.md" + - "!**.example" + schedule: + # Nightly at 04:47 UTC. Scheduled runs fire from the default branch only. + - cron: "47 4 * * *" + workflow_dispatch: + inputs: + max_total_time: + description: "Seconds to fuzz each target (campaign job)" + default: "120" + +defaults: + run: + shell: bash + +permissions: + contents: read + +env: + RUST_BACKTRACE: full + CARGO_TERM_COLOR: always + CARGO_NET_GIT_FETCH_WITH_CLI: true + +jobs: + fuzz-regression: + name: fuzz regression (${{ matrix.slug }}) + # Not on the nightly schedule, which is the campaign's. A manual dispatch + # runs both jobs. + if: github.event_name != 'schedule' + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - { task: "fuzz:access-key", slug: access-key } + - { task: "fuzz:jwt-decode", slug: jwt-decode } + - { task: "fuzz:client-key", slug: client-key } + - { task: "fuzz:sealed-value", slug: sealed-value } + - { task: "fuzz:term-decode", slug: term-decode } + - { task: "fuzz:check-record", slug: check-record } + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + working_directory: . + install_args: rust cargo:cargo-fuzz + cache: true + + - name: Install the nightly toolchain (cargo-fuzz requires it) + run: rustup toolchain install nightly --profile minimal + + # Each fuzz crate is its own workspace with its own target/. + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: | + packages/stack-auth/fuzz + packages/stack-kms/fuzz + packages/stack-encrypt/fuzz + key: ${{ matrix.slug }} + + # `-runs=0` replays the committed seed corpus once and exits without + # fuzzing. The trailing args override the task's `-max_total_time`. + - name: Build the harness and replay the seed corpus (${{ matrix.slug }}) + run: mise run ${{ matrix.task }} -- -runs=0 + + fuzz-campaign: + name: fuzz campaign (${{ matrix.slug }}) + if: github.event_name != 'pull_request' + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + # `dir` and `target` drive the corpus path and `cargo fuzz cmin`. + include: + - { task: "fuzz:access-key", slug: access-key, dir: packages/stack-auth, target: access_key_parse } + - { task: "fuzz:jwt-decode", slug: jwt-decode, dir: packages/stack-auth, target: jwt_decode } + - { task: "fuzz:client-key", slug: client-key, dir: packages/stack-kms, target: client_key_encoded } + - { task: "fuzz:sealed-value", slug: sealed-value, dir: packages/stack-encrypt, target: sealed_value_decode } + - { task: "fuzz:term-decode", slug: term-decode, dir: packages/stack-encrypt, target: term_decode } + - { task: "fuzz:check-record", slug: check-record, dir: packages/stack-encrypt, target: check_record } + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + working_directory: . + install_args: rust cargo:cargo-fuzz + cache: true + + - name: Install the nightly toolchain (cargo-fuzz requires it) + run: rustup toolchain install nightly --profile minimal + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: ${{ matrix.dir }}/fuzz + key: ${{ matrix.slug }} + + # A cache key is write-once, so save under a per-run key and restore the + # most recent prior corpus by prefix. The committed seeds come from the + # checkout and merge with the restored corpus at run time. + - name: Restore the corpus + uses: actions/cache/restore@v4 + with: + path: ${{ matrix.dir }}/fuzz/corpus/${{ matrix.target }} + key: fuzz-corpus-${{ matrix.slug }}-${{ github.run_id }} + restore-keys: fuzz-corpus-${{ matrix.slug }}- + + # The trailing `-max_total_time` (last value wins) overrides the task's. + - name: Fuzz ${{ matrix.slug }} + env: + MAX_TOTAL_TIME: ${{ github.event.inputs.max_total_time || '120' }} + run: mise run ${{ matrix.task }} -- "-max_total_time=$MAX_TOTAL_TIME" + + # Drops inputs that add no coverage, so the saved corpus stays small. + # Skipped when the fuzz step found a crash. + - name: Minimise the corpus (${{ matrix.slug }}) + working-directory: ${{ matrix.dir }} + env: + TARGET: ${{ matrix.target }} + run: | + cargo +nightly fuzz cmin "$TARGET" --sanitizer none --target "$(rustc -vV | sed -n 's/^host: //p')" + + # Saved even on a crash: the grown corpus is still worth keeping, and the + # crash input lives in artifacts/, not corpus/. + - name: Save the corpus + if: always() + uses: actions/cache/save@v4 + with: + path: ${{ matrix.dir }}/fuzz/corpus/${{ matrix.target }} + key: fuzz-corpus-${{ matrix.slug }}-${{ github.run_id }} + + - name: Upload the crash reproducer + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: fuzz-artifacts-${{ matrix.slug }} + path: ${{ matrix.dir }}/fuzz/artifacts/** + if-no-files-found: ignore diff --git a/.github/workflows/miri.yml b/.github/workflows/miri.yml new file mode 100644 index 000000000..5f98898c7 --- /dev/null +++ b/.github/workflows/miri.yml @@ -0,0 +1,66 @@ +name: Miri (guest ABI) + +# Runs the stack-guest-abi unit tests under Miri with strict provenance (the +# `miri:stack-guest-abi` mise task). Ported from cipherstash-suite's `miri.yml`. +# The crate is the shared ABI of the WASI guests under languages/golang: its +# buffer registry hands raw pointers to the Go host and rebuilds owned buffers +# from them, and Miri is the only check that sees a use-after-free, a +# double-free, or a pointer rebuilt without provenance on that path. Miri is +# deterministic, so a red run is a real defect or a harness that stopped +# compiling, never flake. +# +# Pull requests only, so `push` has no filter to mirror; recorded in +# scripts/__tests__/workflow-paths-filter-parity.test.mjs. + +on: + pull_request: + paths: + - packages/stack-guest-abi/** + - Cargo.toml + - Cargo.lock + - mise.toml + - .github/workflows/miri.yml + # Keep these excludes last so docs-only changes are skipped. + - "!**.md" + - "!**.example" + workflow_dispatch: {} + +defaults: + run: + shell: bash + +permissions: + contents: read + +env: + RUST_BACKTRACE: full + CARGO_TERM_COLOR: always + CARGO_NET_GIT_FETCH_WITH_CLI: true + +jobs: + miri-stack-guest-abi: + name: Miri (stack-guest-abi) + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 30 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + working_directory: . + install_args: rust + cache: true + + - name: Install the nightly toolchain with Miri + run: | + rustup toolchain install nightly --profile minimal --component miri + cargo +nightly miri setup + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + - name: Miri + run: mise run miri:stack-guest-abi diff --git a/.github/workflows/mutants.yml b/.github/workflows/mutants.yml new file mode 100644 index 000000000..f5472195b --- /dev/null +++ b/.github/workflows/mutants.yml @@ -0,0 +1,201 @@ +name: Mutants + +# Gates pull requests on mutation testing of the lines they change in +# stack-auth and stack-encrypt. Ported from cipherstash-suite's `mutants.yml`. +# +# cargo-mutants rewrites small pieces of logic (flip `<` to `<=`, replace a +# body with `Default::default()`) and reruns the tests; a mutant that survives +# is a line the suite does not pin down. Scoped with `--in-diff` to the PR's +# own changes: a full sweep is far too slow for a per-PR gate (stack-encrypt: +# ~60 min). Run one locally with `mise run mutants:`. Features, the test +# filter (which drops the trybuild `ui` binary), excludes and timeouts live in +# .cargo/mutants.toml, so the gate and the local tasks agree. +# +# Scoped to the two crates with `-p`: the baseline runs the unmutated tests of +# those packages only. A diff touching only other crates yields no mutants and +# passes. +# +# The sticky comment needs `pull-requests: write`, which is registered in +# scripts/__tests__/workflow-publish-permissions.test.mjs `REPO_WRITE_JOBS`. +# Pull requests only, so `push` has no filter to mirror; recorded in +# scripts/__tests__/workflow-paths-filter-parity.test.mjs. + +on: + # No branch filter: a stacked PR (a feature branch as base) must run this + # gate too. + pull_request: + paths: + - packages/stack-auth/** + - packages/stack-encrypt/** + - Cargo.toml + - Cargo.lock + - mise.toml + - .cargo/mutants.toml + - .github/workflows/mutants.yml + # Keep these excludes last so docs-only changes are skipped. + - "!**.md" + - "!**.example" + workflow_dispatch: {} + +defaults: + run: + shell: bash + +permissions: + contents: read + +# Only the latest push of a PR matters. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + RUST_BACKTRACE: full + CARGO_TERM_COLOR: always + CARGO_NET_GIT_FETCH_WITH_CLI: true + +jobs: + mutants: + name: Mutants gate + runs-on: blacksmith-16vcpu-ubuntu-2204 + timeout-minutes: 120 + permissions: + contents: read + # Posts and updates the report comment on the PR. + pull-requests: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + # Full history, to diff the PR against its base for `--in-diff`. + fetch-depth: 0 + persist-credentials: false + + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + working_directory: . + install_args: rust cargo:cargo-nextest cargo:cargo-mutants + cache: true + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + # PR only: a manual dispatch has no base to diff against, so it sweeps + # both crates instead (see the next step). + - name: Compute the PR diff + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + run: | + git fetch --no-tags origin "$BASE_REF" + git diff "origin/$BASE_REF" > pr.diff + echo "Changed lines under mutation:" + cat pr.diff + + # Never fails the job: the enforce step below is the gate, and the + # comment must be posted first. + - name: Run cargo-mutants + id: mutants + continue-on-error: true + env: + EVENT_NAME: ${{ github.event_name }} + run: | + set +e + crates=(-p stack-auth -p stack-encrypt) + if [ "$EVENT_NAME" = "pull_request" ]; then + cargo mutants --no-shuffle -vV "${crates[@]}" --in-diff pr.diff + else + cargo mutants --no-shuffle -vV "${crates[@]}" + fi + echo "exit_code=$?" >> "$GITHUB_OUTPUT" + + - name: Build the mutants report + if: always() + env: + EXIT_CODE: ${{ steps.mutants.outputs.exit_code }} + run: | + out=mutants.out + count() { if [ -f "$out/$1" ]; then grep -c . "$out/$1" || true; else echo 0; fi; } + caught=$(count caught.txt) + missed=$(count missed.txt) + unviable=$(count unviable.txt) + timeout=$(count timeout.txt) + { + echo '' + echo "## Mutation testing (cargo-mutants, \`--in-diff\`, stack-auth + stack-encrypt)" + echo + if [ ! -d "$out" ]; then + # No output dir: either nothing to mutate (exit 0) or the run + # died before producing results (e.g. the baseline failed). + if [ "$EXIT_CODE" = "0" ]; then + echo "No mutants were generated for the changed lines." + else + echo "cargo-mutants did not complete (exit $EXIT_CODE) before producing results. Check the workflow run logs. The gate below will fail." + fi + exit 0 + fi + echo "| caught | missed | unviable | timeout |" + echo "| -----: | -----: | -------: | ------: |" + echo "| $caught | $missed | $unviable | $timeout |" + echo + if [ "$missed" -gt 0 ] || [ "$timeout" -gt 0 ]; then + echo "### Surviving mutants: add a test that fails on each before merging" + echo '```' + [ -s "$out/missed.txt" ] && cat "$out/missed.txt" + [ -s "$out/timeout.txt" ] && { echo '# timed out (treated as surviving):'; cat "$out/timeout.txt"; } + echo '```' + elif [ "$EXIT_CODE" = "0" ]; then + echo "Every mutant in the changed lines was caught by a test." + else + echo "cargo-mutants exited $EXIT_CODE with no surviving mutants recorded. The run may not have completed cleanly; check the workflow run logs." + fi + } > mutants-comment.md + cat mutants-comment.md + + # Posted before the gate, so the comment always shows what tripped it. + # Best effort: a comment failure must not turn the gate red. + - name: Post the report as a sticky PR comment + if: always() && github.event_name == 'pull_request' + continue-on-error: true + uses: actions/github-script@v9 + with: + script: | + const fs = require('fs'); + const body = fs.readFileSync('mutants-comment.md', 'utf8'); + const marker = ''; + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number, per_page: 100, + }); + const existing = comments.find(c => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number, body }); + } + + # The gate. A missed or timed-out mutant in the changed lines means a + # test does not pin that logic down. + - name: Enforce — fail on surviving mutants + env: + EXIT_CODE: ${{ steps.mutants.outputs.exit_code }} + run: | + out=mutants.out + if [ -s "$out/missed.txt" ] || [ -s "$out/timeout.txt" ]; then + echo "::error::Surviving mutants in the PR diff. Add tests that catch them (see the PR comment)." + [ -s "$out/missed.txt" ] && cat "$out/missed.txt" + [ -s "$out/timeout.txt" ] && cat "$out/timeout.txt" + exit 1 + fi + # Only 0 (all caught), 2 (missed) and 3 (timeouts) mean the sweep + # completed, and 2 and 3 are enforced above. Anything else, such as + # 4 (the unmutated baseline failed) or 70 (tool error), did not. + case "$EXIT_CODE" in + 0|2|3) ;; + *) + echo "::error::cargo-mutants did not complete (exit $EXIT_CODE). Check the run logs." + exit 1 + ;; + esac + echo "No surviving mutants in the PR diff." diff --git a/.github/workflows/tests-crates.yml b/.github/workflows/tests-crates.yml new file mode 100644 index 000000000..0b29bb2b6 --- /dev/null +++ b/.github/workflows/tests-crates.yml @@ -0,0 +1,205 @@ +name: Tests (crates) + +# The stack-* crates and their three node bindings, ported from +# cipherstash-suite's `test-unit.yml` (the stack crates' share of it), +# `test-stack-auth.yml` and `test-stack-profile.yml` when the set was imported +# to the repository root. +# +# This is the only workflow that runs the stack-encrypt `tests/ui` trybuild +# binary: `crap-crates.yml` and `mutants.yml` both filter out `binary(ui)`. +# `scripts/__tests__/crates-ci.test.mjs` pins that, and that every mise task in +# the five crate `tasks.toml` files is reached from a root workflow. +# +# The filter keeps Markdown in: `stack-auth`'s crate docs are its README +# (`#![doc = include_str!("../README.md")]`), so a README edit changes the +# doctests. The suite's `!**.md` exclude would have skipped that change. + +on: + pull_request: + paths: + - packages/stack-auth/** + - packages/stack-profile/** + - packages/stack-kms/** + - packages/stack-encrypt/** + - packages/stack-encrypt-derive/** + - packages/stack-guest-abi/** + - languages/typescript/packages/auth/** + - languages/typescript/packages/profile/** + - languages/typescript/packages/stack-auth-wasm/** + # Root manifest, lockfile, toolchain pin and cargo config: each can + # change what compiles without touching a crate. The toolchain pin in + # particular changes the trybuild UI snapshots. + - Cargo.toml + - Cargo.lock + - mise.toml + - mise.test.toml + - .cargo/config.toml + - .config/nextest.toml + - pnpm-lock.yaml + - .github/workflows/tests-crates.yml + push: + branches: [main] + paths: + - packages/stack-auth/** + - packages/stack-profile/** + - packages/stack-kms/** + - packages/stack-encrypt/** + - packages/stack-encrypt-derive/** + - packages/stack-guest-abi/** + - languages/typescript/packages/auth/** + - languages/typescript/packages/profile/** + - languages/typescript/packages/stack-auth-wasm/** + - Cargo.toml + - Cargo.lock + - mise.toml + - mise.test.toml + - .cargo/config.toml + - .config/nextest.toml + - pnpm-lock.yaml + - .github/workflows/tests-crates.yml + workflow_dispatch: {} + +permissions: + contents: read + +defaults: + run: + shell: bash + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + RUST_BACKTRACE: full + CARGO_TERM_COLOR: always + CARGO_NET_GIT_FETCH_WITH_CLI: true + NEXTEST_PROFILE: ci + +jobs: + rust: + name: fmt, clippy, nextest, doctests, rustdoc + runs-on: blacksmith-16vcpu-ubuntu-2204 + timeout-minutes: 60 + env: + # Job-level, not workflow-level: RUSTFLAGS replaces the + # `[target.wasm32-unknown-unknown]` rustflags in .cargo/config.toml, + # which the stack-auth-wasm build in `node-bindings` needs. + RUSTFLAGS: "-D warnings" + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + # At the repository root: the root mise.toml pins rust 1.94.1, which the + # trybuild snapshots record. Only the tools this job runs are installed; + # the cargo: tools build from source. + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + working_directory: . + install_args: rust cargo:cargo-nextest + cache: true + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + - name: rustfmt + run: cargo fmt --all --check + + - name: clippy + run: cargo clippy --locked --no-deps --workspace --all-targets --all-features -- -D warnings + + # Every test binary in the workspace, the trybuild `ui` binary included. + - name: nextest + run: mise x --env test -- cargo nextest run --locked --workspace --all-features + + - name: doctests + run: mise run test:doc + + # Rustdoc with warnings as errors, one `doc:` task per crate. + # `test:doc` runs the examples; only this sees a broken intra-doc link. + - name: rustdoc + run: mise run doc + + # The stack-encrypt examples need live credentials, so they are built + # here and run in tests-golang.yml `live`. + - name: Build the stack-encrypt examples + run: cargo build --locked -p stack-encrypt --examples + + node-bindings: + name: node bindings, napi typings, stack-auth-wasm + runs-on: blacksmith-16vcpu-ubuntu-2204 + timeout-minutes: 45 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + working_directory: . + install_args: rust aqua:wasm-bindgen/wasm-pack + cache: true + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + - uses: pnpm/action-setup@v6.1.0 + with: + run_install: false + + - uses: actions/setup-node@v6.5.0 + with: + node-version: 22 + cache: 'pnpm' + + # node-pty falls back to `node-gyp rebuild` on Linux during any + # workspace install. See scripts/__tests__/workflow-node-gyp.test.mjs. + - name: Install node-gyp + run: npm install -g node-gyp + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Each builds the binding's cdylib, copies it next to the package, and + # runs the package's vitest suite against it. + - name: stack-auth node binding + run: mise run test:integration:stack-auth + + - name: stack-profile node binding + run: mise run test:integration:stack-profile + + # Each binding's `build:debug` is `napi build --dts native.d.ts`: napi + # writes its generated typings to `native.d.ts`, which is committed, and + # never to `index.d.ts`, which is hand-written. Nothing else regenerates + # `native.d.ts`, so a `#[napi]` signature change that is not rebuilt and + # committed would leave typings that disagree with the `.node` ABI. The + # build must leave the tracked tree as it found it: `git status` also + # catches an untracked file the build drops into the package. + - name: Guard committed napi typings against drift + run: | + pnpm --filter @cipherstash/auth run build:debug + pnpm --filter @cipherstash/profile run build:debug + dirty=$(git status --porcelain -- languages/typescript/packages/auth languages/typescript/packages/profile) + if [ -n "$dirty" ]; then + echo "$dirty" + git diff -- languages/typescript/packages/auth languages/typescript/packages/profile + echo "::error::napi build changed the auth or profile binding — run 'pnpm --filter @cipherstash/auth --filter @cipherstash/profile run build:debug' and commit native.d.ts. index.d.ts is hand-written and napi must not write it." + exit 1 + fi + + # `test:integration:stack-auth` builds only the napi module, so the + # wasm-inline Result tests self-skip there. Build the inline shim and + # run them here. wasm-pack comes from the root mise.toml. + - name: Build the wasm shim and run the wasm-inline Result tests + run: | + mise x -- pnpm --filter @cipherstash/auth run build:wasm + pnpm --filter @cipherstash/auth exec vitest run __tests__/wasm-inline-result.test.ts + + - name: cargo check stack-auth-wasm (wasm32-unknown-unknown) + run: cargo check --locked --target wasm32-unknown-unknown -p stack-auth-wasm + + - name: wasm-pack test stack-auth-wasm + run: mise x -- wasm-pack test --node languages/typescript/packages/stack-auth-wasm diff --git a/.github/workflows/tests-golang.yml b/.github/workflows/tests-golang.yml new file mode 100644 index 000000000..05ff37ca3 --- /dev/null +++ b/.github/workflows/tests-golang.yml @@ -0,0 +1,329 @@ +name: Tests (Go) + +# The Go module (languages/golang) and the WASI guests it embeds, ported from +# cipherstash-suite's `test-wasi.yml`. The guests are detached Cargo +# workspaces and the Go module is its own module, so nothing else in CI +# compiles, tests or links them: this workflow is their only gate. +# +# wasi-check the stack crates build for wasm32-wasip1 with no JS-host or +# native-HTTP dependencies, the no-http shape passes its tests +# and docs, both guests pass lint and tests and are built with +# their import surfaces checked, their sha256 is recorded, and +# `go:test` runs against them. +# go-lint golangci-lint, Linux only. +# go-binding-cross +# the same Go tests on macOS and Windows, against the guests +# Linux built, after checking their sha256. +# live the Go live tests and the stack-encrypt examples, against real +# ZeroKMS with the repository's CS_* credentials. +# +# Every job except go-binding-cross runs mise at the repository root. The +# macOS and Windows jobs install only Go, reading the version from the root +# mise.toml, so they need no Rust. + +on: + pull_request: + paths: + - packages/stack-auth/** + - packages/stack-profile/** + - packages/stack-kms/** + - packages/stack-encrypt/** + - packages/stack-encrypt-derive/** + - packages/stack-guest-abi/** + - languages/golang/** + - scripts/check-wasm-imports.py + - scripts/go-binding-test.sh + - Cargo.toml + - Cargo.lock + - .cargo/** + - mise.toml + - .github/actions/require-cs-secrets/** + - .github/workflows/tests-golang.yml + # Keep these excludes last so docs-only changes are skipped. + - "!**.md" + - "!**.example" + push: + branches: [main] + paths: + - packages/stack-auth/** + - packages/stack-profile/** + - packages/stack-kms/** + - packages/stack-encrypt/** + - packages/stack-encrypt-derive/** + - packages/stack-guest-abi/** + - languages/golang/** + - scripts/check-wasm-imports.py + - scripts/go-binding-test.sh + - Cargo.toml + - Cargo.lock + - .cargo/** + - mise.toml + - .github/actions/require-cs-secrets/** + - .github/workflows/tests-golang.yml + - "!**.md" + - "!**.example" + workflow_dispatch: {} + +defaults: + run: + shell: bash + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + RUSTFLAGS: "-D warnings" + RUST_BACKTRACE: full + CARGO_TERM_COLOR: always + CARGO_NET_GIT_FETCH_WITH_CLI: true + +jobs: + wasi-check: + name: WASI check, guests and Go (Linux) + runs-on: blacksmith-16vcpu-ubuntu-2204 + timeout-minutes: 60 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + working_directory: . + install_args: rust cargo:cargo-nextest go + cache: true + + # The root workspace and both guest workspaces, each with its own + # target/. + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: | + . + languages/golang/stackencrypt/guest + languages/golang/stackauth/guest + + # The HTTP-free core compiles for wasm32-wasip1 with no JS-host backend + # and no native HTTP/TLS stack in its graph: the invariant the wazero + # binding is built on. See docs/wasm-analysis.md Layer 6. + - name: WASI core check + run: mise run wasm:wasi-check + + # The same no-http shape on the host: unit tests, doctests and rustdoc + # with default features off. The compile gate above cannot see doc + # examples or intra-doc links. + - name: No-http tests and docs + run: mise run wasm:no-http-test + + - name: stack-encrypt guest lint and tests + run: mise run wasm:guest:test + + # Builds the release module and checks its host-import surface is + # exactly WASI (minus ambient filesystem and sockets) plus the two + # `cipherstash_transport` functions. + - name: stack-encrypt guest release build and import-surface gate + run: mise run wasm:guest:build + + - name: Credential guest lint and tests + run: mise run wasm:auth-guest:test + + - name: Credential guest release build and import-surface gate + run: mise run wasm:auth-guest:build + + # The modules checked above are what every platform tests. Their + # checksums travel with them, so the other jobs can prove they got the + # same bytes rather than a stale or rebuilt guest. + - name: Record the guests' checksums + run: | + for guest in stackencrypt/wasm/stack_encrypt_guest.wasm stackauth/wasm/stack_auth_guest.wasm; do + (cd languages/golang && openssl dgst -sha256 "$guest" | awk '{print $NF}' > "$guest.sha256" && echo "$guest sha256 $(cat "$guest.sha256")") + done + + - name: Hand the guests to the other jobs + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: wasm-guests + path: | + languages/golang/stackencrypt/wasm/stack_encrypt_guest.wasm + languages/golang/stackencrypt/wasm/stack_encrypt_guest.wasm.sha256 + languages/golang/stackauth/wasm/stack_auth_guest.wasm + languages/golang/stackauth/wasm/stack_auth_guest.wasm.sha256 + if-no-files-found: error + retention-days: 1 + + # Format, vet and hermetic tests on amd64 and 386. The guest's memory + # lock is best effort, so its test skips where RLIMIT_MEMLOCK refuses + # it; CI raises the limit and sets STACKENCRYPT_TESTS_REQUIRE_LOCK so + # the skip is an error here. + - name: Go binding + env: + STACKENCRYPT_TESTS_REQUIRE_LOCK: "1" + run: | + ulimit -l "$(ulimit -H -l)" + echo "RLIMIT_MEMLOCK: $(ulimit -l) KiB" + mise run go:test + + # Linux only: macOS and Windows would report the same findings. Needs no + # guests: the packages embed a directory and compile without them. + go-lint: + name: Go lint + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + working_directory: . + install_args: go golangci-lint + cache: true + + - name: golangci-lint + run: mise run go:lint + + # The Go binding on macOS and Windows, against the guests Linux built. The + # credential guest's refresh lock has Unix and Windows implementations, and + # its device refresh tests have no skip. Go plus the artifact is the whole + # toolchain here. + go-binding-cross: + name: Go binding (${{ matrix.os }}) + needs: wasi-check + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + # Git for Windows defaults to CRLF on checkout, and gofmt would then + # report every file. Set this before checkout. + - name: Keep LF line endings + run: git config --global core.autocrlf false + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + # One source of truth for the Go version: the root mise pin the Linux + # job runs under. If the pin changes shape, fail here by name rather + # than hand setup-go an empty version. + - name: Go version from mise.toml + id: go + run: | + version=$(sed -n 's/^go = "\(.*\)"/\1/p' mise.toml) + if [ -z "$version" ]; then + echo 'no go = "..." line in mise.toml; the Go version pin has changed shape' >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + - uses: actions/setup-go@v5 + with: + go-version: ${{ steps.go.outputs.version }} + cache-dependency-path: languages/golang/go.sum + + # The artifact keeps its paths relative to their common root + # (languages/golang), so both guests land where the packages embed them. + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: wasm-guests + path: languages/golang + + - name: The guests are the ones Linux built and checked + run: | + cd languages/golang + for guest in stackencrypt/wasm/stack_encrypt_guest.wasm stackauth/wasm/stack_auth_guest.wasm; do + want=$(cat "$guest.sha256") + got=$(openssl dgst -sha256 "$guest" | awk '{print $NF}') + if [ "$want" != "$got" ]; then + echo "$guest checksum mismatch: artifact says $want, file is $got" >&2 + exit 1 + fi + echo "$guest sha256 $got" + done + + - name: Go binding + run: scripts/go-binding-test.sh languages/golang + + # Round trips through real ZeroKMS. The Go live tests skip without their + # STACK_ENCRYPT_TEST_* variables, so the secrets pre-flight runs first and + # turns a missing credential into a failure rather than a silent skip. The + # stack-encrypt examples read CS_* through `StackCipher::new()`; tests-crates + # builds them, and this job runs them. + live: + name: Go live tests and stack-encrypt examples + needs: wasi-check + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 30 + env: + CS_WORKSPACE_CRN: ${{ vars.CS_WORKSPACE_CRN }} + CS_CLIENT_ID: ${{ vars.CS_CLIENT_ID }} + CS_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }} + CS_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }} + STACK_ENCRYPT_TEST_WORKSPACE_CRN: ${{ vars.CS_WORKSPACE_CRN }} + STACK_ENCRYPT_TEST_CLIENT_ID: ${{ vars.CS_CLIENT_ID }} + STACK_ENCRYPT_TEST_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }} + STACK_ENCRYPT_TEST_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + # Fast pre-flight: fail in seconds if a secret was rotated or cleared. + # Ordering is asserted by scripts/__tests__/ffi-binding-step-order.test.mjs. + - uses: ./.github/actions/require-cs-secrets + with: + workspace-crn: ${{ vars.CS_WORKSPACE_CRN }} + client-id: ${{ vars.CS_CLIENT_ID }} + client-key: ${{ secrets.CS_CLIENT_KEY }} + client-access-key: ${{ secrets.CS_CLIENT_ACCESS_KEY }} + + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + working_directory: . + install_args: rust go + cache: true + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: wasm-guests + path: languages/golang + + - name: The guests are the ones the wasi-check job built and checked + run: | + cd languages/golang + for guest in stackencrypt/wasm/stack_encrypt_guest.wasm stackauth/wasm/stack_auth_guest.wasm; do + want=$(cat "$guest.sha256") + got=$(openssl dgst -sha256 "$guest" | awk '{print $NF}') + if [ "$want" != "$got" ]; then + echo "$guest checksum mismatch: artifact says $want, file is $got" >&2 + exit 1 + fi + echo "$guest sha256 $got" + done + + # The whole package, not a `-run` filter: live tests are the ones that + # call `liveClient`, and not all of them are named `TestLive*`. + - name: Go live tests + working-directory: languages/golang + run: CGO_ENABLED=0 go test -v ./stackencrypt/... + + - name: stack-encrypt examples + run: | + for example in encrypted_record mixed_user search_terms zerokms_auth; do + echo "==> $example" + cargo run --locked -p stack-encrypt --example "$example" + done diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 90bbffe63..29707432a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -141,7 +141,8 @@ jobs: # `test` scripts do not build it, so cargo stays off the default `test` # path; this step builds it, as the protect-ffi step above does for # `index.node`. Each `build:debug` writes its typings to the committed - # `native.d.ts`, so the build leaves the tree clean. + # `native.d.ts`, so the build leaves the tree clean; tests-crates.yml + # holds it to that, and runs the same suites against the pinned Rust. - name: Build the auth and profile node bindings run: pnpm --filter @cipherstash/auth --filter @cipherstash/profile run build:debug diff --git a/.github/workflows/udeps.yml b/.github/workflows/udeps.yml new file mode 100644 index 000000000..7daf41de0 --- /dev/null +++ b/.github/workflows/udeps.yml @@ -0,0 +1,85 @@ +name: Unused dependencies (crates) + +# Fails when a crate in the root Cargo workspace declares a dependency it does +# not use. Ported from cipherstash-suite's +# `test-no-unused-cargo-dependencies.yml`. cargo-udeps needs nightly; the +# toolchain is pinned so an upstream nightly regression cannot break unrelated +# PRs. Bump it deliberately, after checking a newer nightly builds the +# workspace. (The suite pinned 2026-07-10 because the 2026-07-14 nightly ICEs +# on the test-harness entrypoint attribute.) + +on: + pull_request: + paths: + - packages/stack-auth/** + - packages/stack-profile/** + - packages/stack-kms/** + - packages/stack-encrypt/** + - packages/stack-encrypt-derive/** + - packages/stack-guest-abi/** + - languages/typescript/packages/auth/** + - languages/typescript/packages/profile/** + - languages/typescript/packages/stack-auth-wasm/** + - Cargo.toml + - Cargo.lock + - .github/workflows/udeps.yml + # Keep these excludes last so docs-only changes are skipped. + - "!**.md" + - "!**.example" + push: + branches: [main] + paths: + - packages/stack-auth/** + - packages/stack-profile/** + - packages/stack-kms/** + - packages/stack-encrypt/** + - packages/stack-encrypt-derive/** + - packages/stack-guest-abi/** + - languages/typescript/packages/auth/** + - languages/typescript/packages/profile/** + - languages/typescript/packages/stack-auth-wasm/** + - Cargo.toml + - Cargo.lock + - .github/workflows/udeps.yml + - "!**.md" + - "!**.example" + workflow_dispatch: {} + +defaults: + run: + shell: bash + +permissions: + contents: read + +env: + RUST_BACKTRACE: full + CARGO_TERM_COLOR: always + CARGO_NET_GIT_FETCH_WITH_CLI: true + NIGHTLY_TOOLCHAIN: nightly-2026-07-10 + +jobs: + udeps: + name: Check unused Rust dependencies + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 45 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + working_directory: . + install_args: rust cargo:cargo-udeps + cache: true + + - name: Install the pinned nightly toolchain + run: rustup toolchain install "$NIGHTLY_TOOLCHAIN" --profile minimal --no-self-update + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + - name: Check for unused dependencies + run: cargo "+$NIGHTLY_TOOLCHAIN" udeps --workspace --all-features --all-targets diff --git a/AGENTS.md b/AGENTS.md index 0999284fc..cd70b3079 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -642,10 +642,24 @@ The stack-* crates came from `cipherstash/cipherstash-suite`, which still owns that use them set `MISE_ENV: test`. - **Run the tests with nextest, under the test env:** `mise x --env test -- cargo nextest run --workspace --all-features`. Doc examples are `mise run - test:doc`; rustdoc with warnings as errors is `mise run doc`. + test:doc`; rustdoc with warnings as errors is `mise run doc`. Plain `cargo + test` over the workspace is not equivalent: it runs a binary's tests as + threads of one process, and the `stack-kms` builder tests that set + environment variables race there. - **The node bindings keep cargo off `pnpm test`.** Their `test` runs vitest and Biome against a binding already built with `pnpm --filter - @cipherstash/auth run build:debug`; `test:cargo` runs the crate's tests. + @cipherstash/auth --filter @cipherstash/profile run build:debug`; + `test:cargo` runs the crate's tests. Both builds write napi's generated + typings to the committed `native.d.ts`; `index.d.ts` is hand-written. After + a `#[napi]` change, rebuild and commit `native.d.ts`, or CI's drift guard + fails. +- **CI:** `tests-crates.yml` (fmt, clippy, nextest including the trybuild + `tests/ui` binary, doctests, rustdoc, the node bindings and + `stack-auth-wasm`), `tests-golang.yml` (WASI checks, the guests, the Go + module on Linux, macOS and Windows, and the live tests), `crap-crates.yml`, + `mutants.yml`, `fuzz.yml`, `miri.yml` and `udeps.yml`. + `scripts/__tests__/crates-ci.test.mjs` fails when a mise task in the root + `mise.toml` or the five crate `tasks.toml` files loses its last CI caller. ### Fuzzing @@ -660,7 +674,7 @@ mise run fuzz:access-key -- -runs=0 # replay seed corpus only (CI regr ``` CI is split into a blocking per-PR regression replay and a nightly, -non-blocking bug-finding campaign; the workflow arrives with the CI port. +non-blocking bug-finding campaign (`.github/workflows/fuzz.yml`). Full walkthrough — layout, adding a target, the CI split — in [`docs/fuzzing.md`](docs/fuzzing.md). For cargo-fuzz mechanics (sanitizers, corpus, crash triage) use the Trail of Bits `cargo-fuzz` skill rather than a @@ -684,8 +698,8 @@ Go side (`mise run go:test`). ### Mutation testing The stack crates that opt in (stack-auth, stack-encrypt) are mutation-tested -with cargo-mutants; config in `.cargo/mutants.toml`. Once the CI port lands, -CI gates every PR touching them with `--in-diff`: only the lines the PR changes are mutated, and +with cargo-mutants; config in `.cargo/mutants.toml`. CI (`mutants.yml`) +gates every PR touching them with `--in-diff`: only the lines the PR changes are mutated, and a surviving mutant fails the job. A full sweep is slow and is run locally: ```bash diff --git a/docs/fuzzing.md b/docs/fuzzing.md index 448550122..c206f6431 100644 --- a/docs/fuzzing.md +++ b/docs/fuzzing.md @@ -110,7 +110,7 @@ single input with `cargo +nightly fuzz run `. ## CI (`.github/workflows/fuzz.yml`) -The workflow arrives with the CI port of the stack-* crates. Two jobs with deliberately different roles: +Two jobs with deliberately different roles: - **fuzz-regression** (`pull_request`, **blocking**): builds every harness — which catches harness/API drift, e.g. a changed `FromStr` @@ -126,8 +126,8 @@ The workflow arrives with the CI port of the stack-* crates. Two jobs with delib to stay small, and any crash reproducer is uploaded as an artifact. The `pull_request` trigger is path-filtered to `packages/stack-auth/**`, -`packages/stack-kms/**`, `packages/stack-encrypt/**`, and the workflow file, -with `!**.md` / `!**.example` excludes last so docs-only changes are +`packages/stack-kms/**`, `packages/stack-encrypt/**`, the root `Cargo.toml`, +`Cargo.lock` and `mise.toml`, and the workflow file, with `!**.md` / `!**.example` excludes last so docs-only changes are skipped. ## Adding a new target diff --git a/packages/stack-auth/tasks.toml b/packages/stack-auth/tasks.toml index 44774dc25..4c96551e5 100644 --- a/packages/stack-auth/tasks.toml +++ b/packages/stack-auth/tasks.toml @@ -34,7 +34,7 @@ run = [ # binding crates so the report reflects the core library. `--fail-above` exits # non-zero when any function's CRAP score exceeds the threshold from the # workspace-root .cargo-crap.toml (30), so this gates both local runs and CI. - # NOTE: this must stay the LAST command — the crap-stack-auth.yml CI workflow runs + # NOTE: this must stay the LAST command — the crap-crates.yml CI workflow runs # this task and relies on mise appending its trailing args (e.g. --format github) # to this `cargo crap` invocation. "mise x --env test -- cargo crap --path packages/stack-auth --lcov {{config_root}}/target/stack-auth-lcov.info --exclude 'node/**' --exclude 'wasm/**' --exclude 'examples/**' --exclude '**/tests.rs' --exclude '**/tests/**' --fail-above", diff --git a/packages/stack-encrypt/tasks.toml b/packages/stack-encrypt/tasks.toml index 872417224..aa9b3b34f 100644 --- a/packages/stack-encrypt/tasks.toml +++ b/packages/stack-encrypt/tasks.toml @@ -13,7 +13,7 @@ run = [ # otherwise be walked as production source). `--fail-above` exits non-zero # when any function's CRAP score exceeds the threshold from the workspace-root # .cargo-crap.toml (30), so this gates both local runs and CI. - # NOTE: this must stay the LAST command — the crap-stack-encrypt.yml CI + # NOTE: this must stay the LAST command — the crap-crates.yml CI # workflow runs this task and relies on mise appending its trailing args # (e.g. --format github) to this `cargo crap` invocation. "mise x --env test -- cargo crap --path packages/stack-encrypt --lcov {{config_root}}/target/stack-encrypt-lcov.info --exclude 'examples/**' --exclude 'fuzz/**' --exclude '**/tests/**' --fail-above", diff --git a/scripts/__tests__/eql-suite-ci.test.mjs b/scripts/__tests__/eql-suite-ci.test.mjs index 719e3f8f9..7f2091b22 100644 --- a/scripts/__tests__/eql-suite-ci.test.mjs +++ b/scripts/__tests__/eql-suite-ci.test.mjs @@ -290,16 +290,32 @@ const SHARED_KEY = 'sqlx-tests' */ const SHARED_KEY_SAVER = { relPath: EQL_WORKFLOW, jobName: 'build-archive' } +/** + * Whether a job works in the EQL tree: it runs mise from `packages/eql`, which + * every EQL job must (see test-eql.yml's header). The stack-* crate jobs run + * mise at the repository root and cache the ROOT workspace, so the checks + * below are about EQL's jobs only. + */ +const isEqlJob = (job) => + (Array.isArray(job?.steps) ? job.steps : []).some( + (step) => + (step?.uses ?? '').startsWith('jdx/mise-action@') && + step?.with?.working_directory === CARGO_WORKSPACE, + ) + /** Every `Swatinem/rust-cache` step GitHub can actually execute. */ const RUST_CACHE_STEPS = workflowFiles().flatMap((relPath) => { const wf = readWorkflow(relPath) return Object.entries(wf?.jobs ?? {}).flatMap(([jobName, job]) => (Array.isArray(job?.steps) ? job.steps : []) .filter((step) => (step?.uses ?? '').startsWith('Swatinem/rust-cache@')) - .map((step) => ({ relPath, jobName, step })), + .map((step) => ({ relPath, jobName, step, eql: isEqlJob(job) })), ) }) +/** The rust-cache steps in EQL's jobs. */ +const EQL_RUST_CACHE_STEPS = RUST_CACHE_STEPS.filter(({ eql }) => eql) + const stepLabel = ({ relPath, jobName }, detail) => `${relPath} (${jobName}): ${detail}` @@ -326,7 +342,7 @@ describe('the shared Rust cache is pointed and saved correctly', () => { ).toEqual([]) }) - it('points every rust-cache step at the nested Cargo workspace', () => { + it("points every EQL job's rust-cache step at the nested Cargo workspace", () => { // Silent when wrong, which is why it is asserted rather than left to // review. `workspaces:` names a workspace ROOT: rust-cache hashes the // `Cargo.lock` it finds there and caches the `target/` it writes there. @@ -335,7 +351,12 @@ describe('the shared Rust cache is pointed and saved correctly', () => { // `packages/eql/target` — so a step naming it hashes no lockfile and // archives an empty directory, while reporting success. Same for the // monorepo root, where there is no Cargo.lock for this workspace at all. - const wrong = RUST_CACHE_STEPS.filter( + // + // EQL's jobs only: a job running mise at the repository root builds the + // root workspace, and rust-cache's default (`. -> target`) is right there. + // Held non-empty, so a change to `isEqlJob` cannot empty the check. + expect(EQL_RUST_CACHE_STEPS.length).toBeGreaterThan(0) + const wrong = EQL_RUST_CACHE_STEPS.filter( ({ step }) => step?.with?.workspaces !== CARGO_WORKSPACE, ).map((entry) => stepLabel(entry, `workspaces: ${entry.step?.with?.workspaces}`), diff --git a/scripts/__tests__/ffi-binding-step-order.test.mjs b/scripts/__tests__/ffi-binding-step-order.test.mjs index dcca3fd63..517ccf9a1 100644 --- a/scripts/__tests__/ffi-binding-step-order.test.mjs +++ b/scripts/__tests__/ffi-binding-step-order.test.mjs @@ -192,6 +192,7 @@ const EXPECTED_CREDENTIALED_JOBS = [ '.github/workflows/tests.yml / run-tests', '.github/workflows/tests.yml / run-tests-bun', '.github/workflows/tests.yml / wasm-e2e-tests', + '.github/workflows/tests-golang.yml / live', ] /** @@ -228,6 +229,10 @@ const BINDING_EXEMPT_JOBS = new Map([ '.github/workflows/test-eql.yml / e2e', 'Rust: the proptest-e2e oracle encrypts through cipherstash-client, and never loads index.node or dist/wasm.', ], + [ + '.github/workflows/tests-golang.yml / live', + 'Go and Rust: the Go live tests encrypt through the stack-encrypt WASI guest, and the stack-encrypt examples link the crate directly. Neither loads index.node or dist/wasm.', + ], ]) /** diff --git a/scripts/__tests__/workflow-paths-filter-parity.test.mjs b/scripts/__tests__/workflow-paths-filter-parity.test.mjs index e4dff0592..da0fe49f9 100644 --- a/scripts/__tests__/workflow-paths-filter-parity.test.mjs +++ b/scripts/__tests__/workflow-paths-filter-parity.test.mjs @@ -159,6 +159,26 @@ const EXPECTED_ASYMMETRIES = new Map([ // PR filter) does not exist because PRs never run it. 'push to main and a nightly schedule are the only triggers; a 60-minute bench deliberately stays off the PR path', ], + // The four PR gates ported with the stack-* crates. Each has no `push:` + // trigger, as in cipherstash-suite: they gate a change before it merges, + // and the post-merge coverage of the same crates is tests-crates.yml and + // tests-golang.yml, which filter both events and are parity-checked. + [ + '.github/workflows/crap-crates.yml', + 'pull_request is the only filtered trigger; a CRAP gate on a merged change reports too late to block it', + ], + [ + '.github/workflows/mutants.yml', + 'pull_request is the only filtered trigger; `--in-diff` needs a PR base to diff against, and a dispatch sweeps unfiltered', + ], + [ + '.github/workflows/fuzz.yml', + 'pull_request (corpus regression) is the only filtered trigger; the campaign runs on a nightly schedule, which takes no paths', + ], + [ + '.github/workflows/miri.yml', + 'pull_request is the only filtered trigger; Miri is deterministic, so a post-merge run would repeat the PR result', + ], ]) describe('paths filters are written twice, identically', () => { diff --git a/scripts/__tests__/workflow-publish-permissions.test.mjs b/scripts/__tests__/workflow-publish-permissions.test.mjs index 90bdba822..d09353c81 100644 --- a/scripts/__tests__/workflow-publish-permissions.test.mjs +++ b/scripts/__tests__/workflow-publish-permissions.test.mjs @@ -105,6 +105,9 @@ const REPO_WRITE_JOBS = [ // release/eql- branch it must be dispatched against (release-plz // refuses a detached HEAD). '.github/workflows/release.yml / prerelease-eql-crate', + // pull-requests: write — posts and updates the sticky cargo-mutants report + // comment on the PR. + '.github/workflows/mutants.yml / mutants', ] /** diff --git a/scripts/go-binding-test.sh b/scripts/go-binding-test.sh index 0b9bd0db2..f77da30d0 100755 --- a/scripts/go-binding-test.sh +++ b/scripts/go-binding-test.sh @@ -3,7 +3,7 @@ # # One definition of "the Go binding passes", run on three platforms: the mise # task `go:test` (Linux CI, and locally) and the macOS/Windows -# jobs in .github/workflows/test-wasi.yml both call this, so they cannot +# jobs in .github/workflows/tests-golang.yml both call this, so they cannot # drift apart. The guest module itself is built once, on Linux, and handed to # the other platforms as an artifact — the wasm is platform-independent and # the Rust build is the slow part. From a8ce61a12807c44a2ed6ae8caf0c6aeffcdc65b3 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Fri, 2 Oct 2026 09:50:00 +1000 Subject: [PATCH 2/4] test: guard that CI runs the stack-* crates' checks crates-ci.test.mjs holds that a root workflow reaches every mise task the set defines (or names why not, and fails on a stale exemption), that the imported workflow directory stays gone, that the trybuild ui binary runs somewhere, that NEXTEST_PROFILE names a defined profile, that the Go jobs test the guests whose sha256 the Linux job recorded, and that @cipherstash/profile stays private. Two more, for this repository: every napi build in the auth and profile bindings writes `--dts native.d.ts`, which is committed and diffed by the tests-crates drift guard; and no root step runs plain `cargo test` over the workspace, where the stack-kms env-var tests race. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- scripts/__tests__/crates-ci.test.mjs | 673 +++++++++++++++++++++++++++ 1 file changed, 673 insertions(+) create mode 100644 scripts/__tests__/crates-ci.test.mjs diff --git a/scripts/__tests__/crates-ci.test.mjs b/scripts/__tests__/crates-ci.test.mjs new file mode 100644 index 000000000..19d41dcd1 --- /dev/null +++ b/scripts/__tests__/crates-ci.test.mjs @@ -0,0 +1,673 @@ +import { execFileSync } from 'node:child_process' +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { REPO_ROOT } from './lib/repo-root.mjs' +import { readWorkflow, WORKFLOW_DIR, workflowFiles } from './lib/workflows.mjs' + +/** + * Guards that the stack-* crates, their node bindings and the Go module are + * RUN by CI, not merely present. + * + * The set arrived from cipherstash-suite with its workflows exported to + * `.github/imported-workflows/`, where GitHub never looks, and deleted in the + * import's cleanup commit. Until the CI port landed, every check the set + * carried ran nowhere — the failure `eql-suite-ci.test.mjs` records for the + * EQL import, and `integrationSuiteCi.test.ts` for protect-ffi's before it. + * This file is the same guard for this set, in two halves: + * + * 1. Every mise task the set defines — the five crate `tasks.toml` files the + * root `mise.toml` includes, and the root tasks — is reached from a root + * workflow, or named as exempt with a reason. The scan is a smaller copy + * of `eql-suite-ci.test.mjs`'s: the same `invokes` rule for "a command, + * not a mention", plus `depends` globs (`doc:*`) and `${{ matrix.* }}` + * expansion, which the root tasks and `fuzz.yml` need and EQL does not. + * + * 2. The failures specific to this set that would be silent: the trybuild + * `ui` binary stops running, `NEXTEST_PROFILE` names a profile nobody + * defined, the Go jobs test a guest nobody checked, and + * `@cipherstash/profile` loses its private flag while it is unpublished. + */ + +const readRepo = (relPath) => readFileSync(join(REPO_ROOT, relPath), 'utf8') + +const ROOT_MISE = 'mise.toml' +const IMPORTED_WORKFLOW_DIR = '.github/imported-workflows' + +// --------------------------------------------------------------------------- +// The task graph +// --------------------------------------------------------------------------- + +/** The `["a", 'b']` inside a TOML array, without a TOML parser. */ +const parseStringArray = (inner) => + [...inner.matchAll(/"([^"]+)"|'([^']+)'/g)].map((m) => m[1] ?? m[2]) + +/** `[task_config].includes` from the root mise.toml. */ +function parseIncludes(miseText) { + const section = /^\[task_config\][^\n]*\n([\s\S]*?)(?=^\[|$(?![\s\S]))/m.exec( + miseText, + ) + const array = /^includes\s*=\s*\[([^\]]*)\]/m.exec(section?.[1] ?? '') + return array ? parseStringArray(array[1]) : [] +} + +/** + * mise task blocks. In mise.toml a task is `[tasks."name"]`; in an included + * file every top-level table is a task, `["name"]` (see + * `eql-suite-ci.test.mjs` for why the dialects must not be merged). + */ +function parseTomlTasks(text, { bareTables }) { + const header = bareTables + ? /^\[(?:"([^"]+)"|([\w:.-]+))\]/ + : /^\[tasks\.(?:"([^"]+)"|([\w:.-]+))\]/ + return text + .split(/^(?=\[)/m) + .map((block) => ({ match: header.exec(block), block })) + .filter(({ match }) => match) + .map(({ match, block }) => ({ name: match[1] ?? match[2], block })) +} + +const parseDepends = (block) => { + const m = /^[ \t]*depends\s*=\s*\[([^\]]*)\]/m.exec(block) + return m ? parseStringArray(m[1]) : [] +} + +const stripCommentLines = (text) => text.replace(/^[ \t]*#.*$/gm, '') + +function taskGraph() { + const tasks = new Map() + const bySource = new Map() + const add = (source, name, block) => { + tasks.set(name, { source, body: block, depends: parseDepends(block) }) + bySource.get(source).push(name) + } + const miseText = readRepo(ROOT_MISE) + bySource.set(ROOT_MISE, []) + for (const { name, block } of parseTomlTasks(miseText, { + bareTables: false, + })) { + add(ROOT_MISE, name, block) + } + for (const include of parseIncludes(miseText)) { + bySource.set(include, []) + if (!existsSync(join(REPO_ROOT, include))) continue + for (const { name, block } of parseTomlTasks(readRepo(include), { + bareTables: true, + })) { + add(include, name, block) + } + } + return { tasks, bySource } +} + +const { tasks: TASKS, bySource: TASK_SOURCES } = taskGraph() +const INCLUDES = parseIncludes(readRepo(ROOT_MISE)) + +/** mise's `depends` glob: `doc:*` is every task whose name starts `doc:`. */ +function expandDepends(pattern) { + if (!pattern.includes('*')) return [pattern] + const re = new RegExp( + `^${pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`, + ) + return [...TASKS.keys()].filter((name) => re.test(name)) +} + +/** Where a command can start; see `eql-suite-ci.test.mjs` `invokes`. */ +const COMMAND_START = String.raw`(?:^|&&|\|\||[;|(){}])[ \t]*` + +function invokes(body, taskName) { + const escaped = taskName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return new RegExp( + `${COMMAND_START}mise\\s+run\\s+[^\\n'"]*?(? other !== name && invokes(task.body, other), + ), + ]) +} + +// --------------------------------------------------------------------------- +// The workflows +// --------------------------------------------------------------------------- + +/** + * A step's `run:` once per value of each `${{ matrix. }}` it names. + * + * `fuzz.yml` runs `mise run ${{ matrix.task }}` over six targets. Read as + * text, that line invokes no task at all, and all six `fuzz:*` tasks would + * need an exemption that claims CI does not run them — the false report the + * EQL scan's comments warn about. Values come from `matrix.include[*]` and + * from plain list axes. + */ +function expandMatrix(run, job) { + const matrix = job?.strategy?.matrix ?? {} + const values = (key) => [ + ...(Array.isArray(matrix.include) + ? matrix.include.map((row) => row?.[key]) + : []), + ...(Array.isArray(matrix[key]) ? matrix[key] : []), + ] + let bodies = [run] + for (const [, key] of run.matchAll(/\$\{\{\s*matrix\.([\w-]+)\s*\}\}/g)) { + const pattern = new RegExp(`\\$\\{\\{\\s*matrix\\.${key}\\s*\\}\\}`, 'g') + const vals = values(key).filter((v) => v !== undefined) + bodies = bodies.flatMap((body) => + vals.map((v) => body.replace(pattern, String(v))), + ) + } + return bodies +} + +const jobSteps = (job) => (Array.isArray(job?.steps) ? job.steps : []) + +/** Every root workflow, with the `run:` bodies of all its jobs' steps. */ +const ROOT_WORKFLOWS = workflowFiles().map((relPath) => { + const wf = readWorkflow(relPath) + const body = Object.values(wf?.jobs ?? {}) + .flatMap((job) => + jobSteps(job) + .filter((step) => typeof step?.run === 'string') + .flatMap((step) => expandMatrix(step.run, job)), + ) + .join('\n') + return { relPath, wf, body } +}) + +function reachableFrom(workflows) { + const seen = new Set() + const queue = [...TASKS.keys()].filter((name) => + workflows.some(({ body }) => invokes(body, name)), + ) + while (queue.length > 0) { + const name = queue.pop() + if (seen.has(name)) continue + seen.add(name) + queue.push(...(TASKS.get(name)?.calls ?? [])) + } + return seen +} + +/** + * Tasks no workflow runs by name, each with the reason. Never "we decided not + * to check this": each entry says where CI does the same work, or why there + * is nothing for CI to do. + */ +const CI_EXEMPT_TASKS = new Map([ + ...[ + 'stack-auth', + 'stack-encrypt', + 'stack-guest-abi', + 'stack-kms', + 'stack-profile', + ].map((crate) => [ + `test:doc:${crate}`, + 'A second name for work CI does: tests-crates.yml runs the root `test:doc`, which runs every doctest in the workspace (`cargo test --doc --workspace --all-features`), this crate included.', + ]), + [ + 'mutants:stack-auth', + 'The full-crate sweep, for local use (~15 min). CI runs `cargo mutants -p stack-auth -p stack-encrypt --in-diff` in mutants.yml, reading the same .cargo/mutants.toml, and sweeps both crates on workflow_dispatch.', + ], + [ + 'mutants:stack-encrypt', + 'The full-crate sweep, for local use (~60 min). CI runs it `--in-diff` in mutants.yml; see mutants:stack-auth.', + ], + [ + 'mutants', + 'The fan-out over the two sweeps above, for local use. Both would write mutants.out in the same directory, so CI runs cargo-mutants once over both crates instead.', + ], + [ + 'go:stackencrypt:example', + 'A walkthrough against real ZeroKMS for a developer who has run `stash auth login`. CI exercises the same client through the Go live tests in tests-golang.yml `live`.', + ], + [ + 'go:stackencrypt:example:explicit', + 'The same walkthrough, with credentials passed as flags after `--`. Nothing for CI to pass; the live tests cover explicit credentials (`liveClient`).', + ], +]) + +describe('the scan sees every task mise sees', () => { + it('reads the five crate task files from the root mise.toml', () => { + expect(INCLUDES.sort()).toEqual( + [ + 'packages/stack-auth/tasks.toml', + 'packages/stack-encrypt/tasks.toml', + 'packages/stack-guest-abi/tasks.toml', + 'packages/stack-kms/tasks.toml', + 'packages/stack-profile/tasks.toml', + ].sort(), + ) + }) + + it('draws at least one task from every config', () => { + const empty = [...TASK_SOURCES] + .filter(([, names]) => names.length === 0) + .map(([source]) => source) + expect( + empty, + 'These configs yielded no task: either the file is missing, or the parser no longer reads its dialect. Either way every task in it is unguarded.', + ).toEqual([]) + }) + + it('agrees with mise about the task names', () => { + // The parser is a regex over TOML, so it is checked against mise itself + // where mise is installed. On a machine without mise this is skipped, not + // failed: the per-config floor above still holds. + let listed + try { + listed = JSON.parse( + execFileSync('mise', ['tasks', 'ls', '--json', '--hidden'], { + cwd: REPO_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }), + ) + } catch { + return + } + const fromMise = listed + .filter(({ source }) => + [ROOT_MISE, ...INCLUDES].some((rel) => + String(source).endsWith(`/${rel}`), + ), + ) + .map(({ name }) => name) + .sort() + expect([...TASKS.keys()].sort()).toEqual(fromMise) + }) + + it('expands a `depends` glob to the tasks it names', () => { + expect([...TASKS.get('doc').calls].sort()).toEqual( + [ + 'doc:stack-auth', + 'doc:stack-encrypt', + 'doc:stack-guest-abi', + 'doc:stack-kms', + 'doc:stack-profile', + ].sort(), + ) + }) + + it('expands a matrix axis in a run line', () => { + const job = { + strategy: { matrix: { include: [{ task: 'a:b' }, { task: 'c:d' }] } }, + } + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression, which is what is being expanded. + expect(expandMatrix('mise run ${{ matrix.task }} -- -runs=0', job)).toEqual( + ['mise run a:b -- -runs=0', 'mise run c:d -- -runs=0'], + ) + }) + + it('counts a command and not a mention', () => { + expect(invokes('mise run doc', 'doc')).toBe(true) + expect(invokes('mise run doc:stack-auth', 'doc')).toBe(false) + expect(invokes('# mise run doc', 'doc')).toBe(false) + expect(invokes('echo "run \'mise run doc\' first"', 'doc')).toBe(false) + }) +}) + +describe('every task the set defines is reached by a root workflow', () => { + const reachable = reachableFrom(ROOT_WORKFLOWS) + const orphans = (reach) => + [...TASKS.keys()].filter( + (name) => !reach.has(name) && !CI_EXEMPT_TASKS.has(name), + ) + + it('runs every task, or names it as exempt with a reason', () => { + expect( + orphans(reachable), + 'These mise tasks are run by no root workflow and are not in CI_EXEMPT_TASKS. A check nothing invokes reads exactly like a check that passes. Run it from a workflow, or exempt it with the reason CI does not need to.', + ).toEqual([]) + }) + + it('keeps no exemption for a task that is run, or has gone', () => { + const stale = [...CI_EXEMPT_TASKS.keys()].filter( + (name) => !TASKS.has(name) || reachable.has(name), + ) + expect( + stale, + 'These CI_EXEMPT_TASKS entries name a task that CI now runs, or one that no longer exists. Delete them.', + ).toEqual([]) + }) + + /** + * Each workflow whose deletion would leave some task unreached. Injected, + * not performed: the scan runs against the workflow list with one removed. + * If a workflow leaves this list, its tasks either moved (update it) or + * stopped running (the orphan check above fails first). + */ + const SOLE_CALLER_WORKFLOWS = [ + `${WORKFLOW_DIR}/crap-crates.yml`, // crap:* + `${WORKFLOW_DIR}/fuzz.yml`, // fuzz:* + `${WORKFLOW_DIR}/miri.yml`, // miri:stack-guest-abi + `${WORKFLOW_DIR}/tests-crates.yml`, // doc, test:doc, test:integration:* + `${WORKFLOW_DIR}/tests-golang.yml`, // wasm:*, go:test, go:lint + ] + + it('names every workflow whose deletion would orphan a task', () => { + const soleCallers = ROOT_WORKFLOWS.map(({ relPath }) => relPath).filter( + (relPath) => + orphans( + reachableFrom(ROOT_WORKFLOWS.filter((wf) => wf.relPath !== relPath)), + ).length > 0, + ) + expect(soleCallers).toEqual(SOLE_CALLER_WORKFLOWS) + }) +}) + +describe('the imported workflow directory is gone', () => { + it('does not exist', () => { + expect( + existsSync(join(REPO_ROOT, IMPORTED_WORKFLOW_DIR)), + `${IMPORTED_WORKFLOW_DIR} is back. GitHub reads workflows from .github/workflows alone, so anything in there runs on no event.`, + ).toBe(false) + }) + + it('has no tracked files', () => { + const tracked = execFileSync( + 'git', + ['ls-files', '-z', '--', IMPORTED_WORKFLOW_DIR], + { cwd: REPO_ROOT, encoding: 'utf8' }, + ) + .split('\0') + .filter(Boolean) + expect(tracked).toEqual([]) + }) +}) + +// --------------------------------------------------------------------------- +// The silent failures specific to this set +// --------------------------------------------------------------------------- + +const triggers = (wf) => wf?.on ?? wf?.[true] ?? {} + +/** Every step of every root workflow, with its job and effective env. */ +const STEPS = ROOT_WORKFLOWS.flatMap(({ relPath, wf }) => + Object.entries(wf?.jobs ?? {}).flatMap(([jobName, job]) => + jobSteps(job).map((step, index) => ({ + relPath, + wf, + jobName, + job, + step, + index, + env: { ...(wf?.env ?? {}), ...(job?.env ?? {}), ...(step?.env ?? {}) }, + })), + ), +) + +describe('the trybuild `ui` binary runs somewhere', () => { + // stack-encrypt's `tests/ui` compile-fail suite is one nextest binary, + // `ui`, gated on the `dynamic` feature. crap:stack-encrypt and + // .cargo/mutants.toml both filter out `binary(ui)`, so a single nextest run + // is its only runner. If that run drops the feature, adds the same filter, + // or narrows to other packages, the suite stops running and nothing fails. + const runsUi = ({ step }) => { + const run = String(step?.run ?? '') + return run + .split('\n') + .some( + (line) => + /\bcargo\s+nextest\s+run\b/.test(line) && + !/\bllvm-cov\b/.test(line) && + (/--workspace\b/.test(line) || /-p\s+stack-encrypt\b/.test(line)) && + (/--all-features\b/.test(line) || + /(?:--features|-F)[\s=]\S*\bdynamic\b/.test(line)) && + !/binary\(ui\)/.test(line), + ) + } + + it('is declared as a test binary named `ui` behind `dynamic`', () => { + const manifest = readRepo('packages/stack-encrypt/Cargo.toml') + expect(manifest).toMatch( + /\[\[test\]\]\s*\nname = "ui"\s*\nrequired-features = \["dynamic"\]/, + ) + expect(existsSync(join(REPO_ROOT, 'packages/stack-encrypt/tests/ui'))).toBe( + true, + ) + }) + + it('is run by a nextest step on pull requests', () => { + const runners = STEPS.filter(runsUi).filter( + ({ wf }) => triggers(wf).pull_request !== undefined, + ) + expect( + runners.map(({ relPath, jobName }) => `${relPath} / ${jobName}`), + 'No pull-request workflow runs `cargo nextest run` over stack-encrypt with the `dynamic` feature and without a `binary(ui)` filter, so the trybuild UI snapshots are checked nowhere.', + ).not.toEqual([]) + }) + + it('is filtered out where the plan says, and so needs the run above', () => { + expect(readRepo('packages/stack-encrypt/tasks.toml')).toMatch( + /not binary\(ui\)/, + ) + expect(readRepo('.cargo/mutants.toml')).toMatch(/not binary\(ui\)/) + }) +}) + +describe('NEXTEST_PROFILE names a profile that exists', () => { + const PROFILES = [ + ...readRepo('.config/nextest.toml').matchAll(/^\[profile\.([\w-]+)\]/gm), + ].map((m) => m[1]) + + it('defines the ci profile', () => { + expect(PROFILES).toContain('ci') + }) + + it('sets NEXTEST_PROFILE=ci on the step that runs the ui binary', () => { + const uiSteps = STEPS.filter(({ step }) => + /\bcargo\s+nextest\s+run\b.*--workspace/.test(String(step?.run ?? '')), + ) + expect(uiSteps.length).toBeGreaterThan(0) + for (const { relPath, jobName, env } of uiSteps) { + expect(env.NEXTEST_PROFILE, `${relPath} / ${jobName}`).toBe('ci') + } + }) + + it('names only defined profiles, wherever it is set', () => { + const unknown = STEPS.filter( + ({ env }) => + env.NEXTEST_PROFILE !== undefined && + !PROFILES.includes(env.NEXTEST_PROFILE), + ).map( + ({ relPath, jobName, env }) => + `${relPath} / ${jobName}: ${env.NEXTEST_PROFILE}`, + ) + expect(unknown).toEqual([]) + }) +}) + +describe('the Go jobs test the guest the Linux job built and checked', () => { + const GO_WORKFLOW = `${WORKFLOW_DIR}/tests-golang.yml` + const wf = readWorkflow(GO_WORKFLOW) + const jobs = Object.entries(wf?.jobs ?? {}) + const runOf = (step) => String(step?.run ?? '') + const isRecord = (step) => + /openssl dgst -sha256/.test(runOf(step)) && + />\s*"\$guest\.sha256"/.test(runOf(step)) + const isVerify = (step) => + /openssl dgst -sha256/.test(runOf(step)) && + /cat\s+"\$guest\.sha256"/.test(runOf(step)) && + /exit 1/.test(runOf(step)) + const isUpload = (step) => + String(step?.uses ?? '').startsWith('actions/upload-artifact@') && + step?.with?.name === 'wasm-guests' + const isDownload = (step) => + String(step?.uses ?? '').startsWith('actions/download-artifact@') && + step?.with?.name === 'wasm-guests' + const isGoTest = (step) => + /\bgo\s+test\b|go-binding-test\.sh|mise run go:test\b/.test(runOf(step)) + + const producers = jobs.filter(([, job]) => jobSteps(job).some(isRecord)) + + it('has exactly one job that records the checksums', () => { + expect(producers.map(([name]) => name)).toEqual(['wasi-check']) + }) + + it('builds both guests, then records, then uploads, then tests', () => { + const [, job] = producers[0] + const steps = jobSteps(job) + const at = (pred) => steps.findIndex(pred) + const build = (task) => at((step) => invokes(runOf(step), task)) + const record = at(isRecord) + expect(build('wasm:guest:build')).toBeGreaterThan(-1) + expect(build('wasm:auth-guest:build')).toBeGreaterThan(-1) + expect(build('wasm:guest:build')).toBeLessThan(record) + expect(build('wasm:auth-guest:build')).toBeLessThan(record) + expect(record).toBeLessThan(at(isUpload)) + expect(record).toBeLessThan(at(isGoTest)) + }) + + it('makes every job that downloads the guests wait for, and verify, them', () => { + const consumers = jobs.filter(([, job]) => jobSteps(job).some(isDownload)) + expect(consumers.length).toBeGreaterThan(0) + for (const [name, job] of consumers) { + const needs = [job?.needs ?? []].flat() + expect(needs, `${name} needs`).toContain('wasi-check') + const steps = jobSteps(job) + const verify = steps.findIndex(isVerify) + const firstGo = steps.findIndex(isGoTest) + expect(verify, `${name}: no sha256 verification step`).toBeGreaterThan( + steps.findIndex(isDownload), + ) + expect(firstGo, `${name}: no Go test step`).toBeGreaterThan(-1) + expect(verify, `${name}: Go runs before the sha256 check`).toBeLessThan( + firstGo, + ) + } + }) +}) + +describe('napi builds leave the hand-written typings alone', () => { + // `napi build` writes its generated typings to `index.d.ts` unless `--dts` + // names another file. Both bindings' `index.d.ts` are hand-written (auth's + // wraps every call in a `Result`; profile's adds the error codes), so a + // build without `--dts` overwrites them and dirties the tree of every job + // that builds the binding. Each writes `native.d.ts` instead, which is + // committed and diffed by the drift guard in tests-crates.yml. + const BINDINGS = [ + 'languages/typescript/packages/auth', + 'languages/typescript/packages/profile', + ] + const napiBuilds = BINDINGS.flatMap((dir) => + Object.entries(JSON.parse(readRepo(`${dir}/package.json`)).scripts ?? {}) + .filter(([, script]) => /\bnapi\s+build\b/.test(script)) + .map(([name, script]) => ({ id: `${dir} ${name}`, dir, script })), + ) + + it('finds a napi build in each binding', () => { + for (const dir of BINDINGS) { + expect( + napiBuilds.some((b) => b.dir === dir), + dir, + ).toBe(true) + } + }) + + it('sends every napi build to native.d.ts', () => { + const offenders = napiBuilds + .filter(({ script }) => !/--dts\s+native\.d\.ts\b/.test(script)) + .map(({ id, script }) => `${id}: ${script}`) + expect( + offenders, + 'These napi builds write their typings to the hand-written index.d.ts. Pass `--dts native.d.ts`.', + ).toEqual([]) + }) + + it('commits the native.d.ts each build writes, so the drift guard can diff it', () => { + const missing = BINDINGS.filter( + (dir) => !existsSync(join(REPO_ROOT, dir, 'native.d.ts')), + ) + expect(missing).toEqual([]) + }) + + it('has the drift guard rebuild each binding and fail on any change to it', () => { + const guard = STEPS.filter( + ({ relPath, step }) => + relPath === `${WORKFLOW_DIR}/tests-crates.yml` && + /git (?:diff --exit-code|status --porcelain)/.test( + String(step?.run ?? ''), + ), + ) + expect(guard.length).toBeGreaterThan(0) + // Commands, not mentions: the step's error message names both too. + const lines = guard + .flatMap(({ step }) => step.run.split('\n')) + .map((line) => line.trim()) + const check = lines.find((line) => /git status --porcelain/.test(line)) + for (const dir of BINDINGS) { + const name = JSON.parse(readRepo(`${dir}/package.json`)).name + expect( + lines.includes(`pnpm --filter ${name} run build:debug`), + `${name} is not rebuilt`, + ).toBe(true) + expect(check, `${dir} is not checked`).toContain(dir) + } + }) +}) + +describe('the root workspace is tested with nextest, not cargo test', () => { + // `cargo test` runs a binary's tests as threads of one process, so tests + // that set environment variables race (stack-kms `builder::tests:: + // invalid_config::*` fail that way). nextest runs each test in its own + // process. Doctests are the exception: nextest cannot run them. + const runsAtRoot = ({ job, step }) => + step?.['working-directory'] === undefined && + job?.defaults?.run?.['working-directory'] === undefined + const testsWholeWorkspace = (line) => + /\bcargo\s+test\b/.test(line) && + !/--doc\b/.test(line) && + (/--workspace\b/.test(line) || + !/(?:\s-p\s|--package\b|--manifest-path\b)/.test(line)) + + it('has no root step that runs cargo test over the workspace', () => { + const offenders = STEPS.filter(runsAtRoot).flatMap( + ({ relPath, jobName, step }) => + String(step?.run ?? '') + .split('\n') + .filter(testsWholeWorkspace) + .map((line) => `${relPath} / ${jobName}: ${line.trim()}`), + ) + expect( + offenders, + 'Use `cargo nextest run` (and `cargo test --doc` for doctests).', + ).toEqual([]) + }) + + it('flags the shapes it is meant to', () => { + expect(testsWholeWorkspace('cargo test --workspace')).toBe(true) + expect(testsWholeWorkspace('cargo test --locked')).toBe(true) + expect(testsWholeWorkspace('cargo test --doc --workspace')).toBe(false) + expect(testsWholeWorkspace('cargo test -p stack-kms')).toBe(false) + expect(testsWholeWorkspace('cargo nextest run --workspace')).toBe(false) + }) +}) + +describe('@cipherstash/profile stays private while it is unpublished', () => { + // Without the flag the release gate classifies it as `js`, and changesets + // would publish 0.35.0 with no binaries. Its six platform packages are the + // same, one level down. + const PROFILE = 'languages/typescript/packages/profile' + const manifests = [ + `${PROFILE}/package.json`, + ...readdirSync(join(REPO_ROOT, PROFILE, 'platforms')) + .map((dir) => `${PROFILE}/platforms/${dir}/package.json`) + .filter((rel) => existsSync(join(REPO_ROOT, rel))), + ] + + it('finds the wrapper and its six platform packages', () => { + expect(manifests).toHaveLength(7) + }) + + it('marks each one private', () => { + const published = manifests.filter( + (rel) => JSON.parse(readRepo(rel)).private !== true, + ) + expect(published).toEqual([]) + }) +}) From 897da0a2d162e355c3fd768e2f19963dfc79367d Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Fri, 2 Oct 2026 10:38:25 +1000 Subject: [PATCH 3/4] ci: install the stack-* cargo tools from the test environment The import PR moved the cargo tools (nextest, llvm-cov, crap, mutants, fuzz, udeps) from the root mise.toml to mise.test.toml, pinned, so that EQL and protect-ffi stop inheriting them. mise loads that file only in the test environment, so every job here that uses one now sets MISE_ENV: test. This also fixes udeps.yml, which built the floating `latest` cargo-udeps (0.1.61, which needs rustc 1.93) with the runner's rustc 1.92; the test environment pins 0.1.60. `mise run` and `mise x` install whatever tool of the toolset is missing, and mise-action caches only what its own install step installed. So tests-crates (rust), tests-golang (wasi-check), crap-crates and fuzz, whose steps go through `mise run` or `mise x`, drop `install_args` and install the whole test toolset, which then shares one cache. mutants and udeps call their tool directly, so they keep a narrow install. The workflows that read mise.test.toml now list it in their path filters. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- .github/workflows/crap-crates.yml | 9 +++++++-- .github/workflows/fuzz.yml | 9 +++++++-- .github/workflows/mutants.yml | 7 +++++++ .github/workflows/tests-crates.yml | 10 +++++++--- .github/workflows/tests-golang.yml | 9 ++++++++- .github/workflows/udeps.yml | 7 +++++++ 6 files changed, 43 insertions(+), 8 deletions(-) diff --git a/.github/workflows/crap-crates.yml b/.github/workflows/crap-crates.yml index 58e1ad292..9e16c966c 100644 --- a/.github/workflows/crap-crates.yml +++ b/.github/workflows/crap-crates.yml @@ -24,6 +24,7 @@ on: - Cargo.toml - Cargo.lock - mise.toml + - mise.test.toml - .cargo-crap.toml - .github/workflows/crap-crates.yml # Keep these excludes last so docs-only changes are skipped. @@ -44,6 +45,12 @@ concurrency: cancel-in-progress: true env: + # The cargo tools are pinned in mise.test.toml, which mise loads only in + # the test environment. With no install_args, mise-action installs (and + # caches) the whole test toolset once: `mise run` and `mise x` install any + # tool of the toolset that is missing, uncached, so narrowing the install + # would only move the rest out of the cache. + MISE_ENV: test RUST_BACKTRACE: full CARGO_TERM_COLOR: always CARGO_NET_GIT_FETCH_WITH_CLI: true @@ -65,7 +72,6 @@ jobs: version: 2026.4.0 install: true working_directory: . - install_args: rust cargo:cargo-nextest cargo:cargo-llvm-cov cargo:cargo-crap cache: true - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 @@ -87,7 +93,6 @@ jobs: version: 2026.4.0 install: true working_directory: . - install_args: rust cargo:cargo-nextest cargo:cargo-llvm-cov cargo:cargo-crap cache: true - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 27b03f1d4..6a8cb4320 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -27,6 +27,7 @@ on: - Cargo.toml - Cargo.lock - mise.toml + - mise.test.toml - .github/workflows/fuzz.yml # Keep these excludes last so docs-only changes are skipped. - "!**.md" @@ -48,6 +49,12 @@ permissions: contents: read env: + # The cargo tools are pinned in mise.test.toml, which mise loads only in + # the test environment. With no install_args, mise-action installs (and + # caches) the whole test toolset once: `mise run` and `mise x` install any + # tool of the toolset that is missing, uncached, so narrowing the install + # would only move the rest out of the cache. + MISE_ENV: test RUST_BACKTRACE: full CARGO_TERM_COLOR: always CARGO_NET_GIT_FETCH_WITH_CLI: true @@ -80,7 +87,6 @@ jobs: version: 2026.4.0 install: true working_directory: . - install_args: rust cargo:cargo-fuzz cache: true - name: Install the nightly toolchain (cargo-fuzz requires it) @@ -126,7 +132,6 @@ jobs: version: 2026.4.0 install: true working_directory: . - install_args: rust cargo:cargo-fuzz cache: true - name: Install the nightly toolchain (cargo-fuzz requires it) diff --git a/.github/workflows/mutants.yml b/.github/workflows/mutants.yml index f5472195b..59ca59434 100644 --- a/.github/workflows/mutants.yml +++ b/.github/workflows/mutants.yml @@ -30,6 +30,7 @@ on: - Cargo.toml - Cargo.lock - mise.toml + - mise.test.toml - .cargo/mutants.toml - .github/workflows/mutants.yml # Keep these excludes last so docs-only changes are skipped. @@ -50,6 +51,12 @@ concurrency: cancel-in-progress: true env: + # The cargo tools are pinned in mise.test.toml, which mise loads only in + # the test environment. This job calls cargo-mutants (which runs nextest) + # directly, never through `mise run` or `mise x`, which would install the + # rest of the toolset. So mise-action installs only rust, nextest and + # cargo-mutants. + MISE_ENV: test RUST_BACKTRACE: full CARGO_TERM_COLOR: always CARGO_NET_GIT_FETCH_WITH_CLI: true diff --git a/.github/workflows/tests-crates.yml b/.github/workflows/tests-crates.yml index 0b29bb2b6..4753727c1 100644 --- a/.github/workflows/tests-crates.yml +++ b/.github/workflows/tests-crates.yml @@ -86,20 +86,24 @@ jobs: # `[target.wasm32-unknown-unknown]` rustflags in .cargo/config.toml, # which the stack-auth-wasm build in `node-bindings` needs. RUSTFLAGS: "-D warnings" + # nextest is pinned in mise.test.toml, which mise loads only in the + # test environment. See the mise-action step. + MISE_ENV: test steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false # At the repository root: the root mise.toml pins rust 1.94.1, which the - # trybuild snapshots record. Only the tools this job runs are installed; - # the cargo: tools build from source. + # trybuild snapshots record. No install_args: `mise x` and `mise run` + # install any tool of the toolset that is missing, uncached, so this + # installs (and caches) the whole test toolset once. The cargo: tools + # build from source. - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 with: version: 2026.4.0 install: true working_directory: . - install_args: rust cargo:cargo-nextest cache: true - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 diff --git a/.github/workflows/tests-golang.yml b/.github/workflows/tests-golang.yml index 05ff37ca3..cb6cf01eb 100644 --- a/.github/workflows/tests-golang.yml +++ b/.github/workflows/tests-golang.yml @@ -37,6 +37,7 @@ on: - Cargo.lock - .cargo/** - mise.toml + - mise.test.toml - .github/actions/require-cs-secrets/** - .github/workflows/tests-golang.yml # Keep these excludes last so docs-only changes are skipped. @@ -58,6 +59,7 @@ on: - Cargo.lock - .cargo/** - mise.toml + - mise.test.toml - .github/actions/require-cs-secrets/** - .github/workflows/tests-golang.yml - "!**.md" @@ -86,6 +88,12 @@ jobs: name: WASI check, guests and Go (Linux) runs-on: blacksmith-16vcpu-ubuntu-2204 timeout-minutes: 60 + env: + # wasm:guest:test and wasm:auth-guest:test run nextest, which is pinned + # in mise.test.toml. In the test environment mise-action installs (and + # caches) the whole test toolset, which `mise run` would otherwise + # install uncached. + MISE_ENV: test steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: @@ -96,7 +104,6 @@ jobs: version: 2026.4.0 install: true working_directory: . - install_args: rust cargo:cargo-nextest go cache: true # The root workspace and both guest workspaces, each with its own diff --git a/.github/workflows/udeps.yml b/.github/workflows/udeps.yml index 7daf41de0..1a7fc68d9 100644 --- a/.github/workflows/udeps.yml +++ b/.github/workflows/udeps.yml @@ -22,6 +22,7 @@ on: - languages/typescript/packages/stack-auth-wasm/** - Cargo.toml - Cargo.lock + - mise.test.toml - .github/workflows/udeps.yml # Keep these excludes last so docs-only changes are skipped. - "!**.md" @@ -40,6 +41,7 @@ on: - languages/typescript/packages/stack-auth-wasm/** - Cargo.toml - Cargo.lock + - mise.test.toml - .github/workflows/udeps.yml - "!**.md" - "!**.example" @@ -53,6 +55,11 @@ permissions: contents: read env: + # The cargo tools are pinned in mise.test.toml, which mise loads only in + # the test environment. This job calls cargo-udeps directly, never through + # `mise run` or `mise x`, which would install the rest of the toolset. So + # mise-action installs only rust and cargo-udeps. + MISE_ENV: test RUST_BACKTRACE: full CARGO_TERM_COLOR: always CARGO_NET_GIT_FETCH_WITH_CLI: true From c93a39b034238d6479beef9b967a5347ca2d9549 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Fri, 2 Oct 2026 18:16:11 +1000 Subject: [PATCH 4/4] ci: add rustfmt and clippy after a mise cache restore The second run of tests-crates `rust` and tests-golang `wasi-check` on a mise cache key failed with "'cargo-fmt' is not installed for the toolchain '1.94.1-x86_64-unknown-linux-gnu'" (and the same for clippy). mise installs the root's rust as a symlink into ~/.rustup, and mise-action caches ~/.local/share/mise but not ~/.rustup. The ubuntu-2204 runners already carry a 1.94.1 toolchain, so on a cache hit the symlink resolves, mise reports rust as installed, and the components mise.toml declares are never added. The first run on a key misses the cache and installs them, which is why the drafts passed. Both jobs now run `rustup component add rustfmt clippy` after mise-action, as test-eql.yml and bench-eql.yml already do. A guard in crates-ci.test.mjs derives which jobs need it: any job whose root mise-action can restore a cache and that runs `cargo fmt` or `cargo clippy`, directly or through the mise tasks it calls, must add those components before the step. It failed on exactly the two jobs. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- .github/workflows/tests-crates.yml | 8 ++ .github/workflows/tests-golang.yml | 8 ++ scripts/__tests__/crates-ci.test.mjs | 112 +++++++++++++++++++++++++++ 3 files changed, 128 insertions(+) diff --git a/.github/workflows/tests-crates.yml b/.github/workflows/tests-crates.yml index 4753727c1..8ef297132 100644 --- a/.github/workflows/tests-crates.yml +++ b/.github/workflows/tests-crates.yml @@ -106,6 +106,14 @@ jobs: working_directory: . cache: true + # mise caches its rust install as a symlink into ~/.rustup, which the + # cache does not hold. On a runner image that already carries a 1.94.1 + # toolchain, a cache hit makes mise skip the components mise.toml + # declares, and the lint steps below fail. A no-op when they are there. + # Held by scripts/__tests__/crates-ci.test.mjs. + - name: Add rustfmt and clippy to the pinned toolchain + run: rustup component add rustfmt clippy + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - name: rustfmt diff --git a/.github/workflows/tests-golang.yml b/.github/workflows/tests-golang.yml index cb6cf01eb..7e3d7ddc0 100644 --- a/.github/workflows/tests-golang.yml +++ b/.github/workflows/tests-golang.yml @@ -106,6 +106,14 @@ jobs: working_directory: . cache: true + # mise caches its rust install as a symlink into ~/.rustup, which the + # cache does not hold. On a runner image that already carries a 1.94.1 + # toolchain, a cache hit makes mise skip the components mise.toml + # declares, and the lint steps below fail. A no-op when they are there. + # Held by scripts/__tests__/crates-ci.test.mjs. + - name: Add rustfmt and clippy to the pinned toolchain + run: rustup component add rustfmt clippy + # The root workspace and both guest workspaces, each with its own # target/. - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 diff --git a/scripts/__tests__/crates-ci.test.mjs b/scripts/__tests__/crates-ci.test.mjs index 19d41dcd1..822f78f7d 100644 --- a/scripts/__tests__/crates-ci.test.mjs +++ b/scripts/__tests__/crates-ci.test.mjs @@ -648,6 +648,118 @@ describe('the root workspace is tested with nextest, not cargo test', () => { }) }) +describe('a job that restores the mise cache adds the lint components first', () => { + // mise installs the root's rust as a symlink into ~/.rustup, and + // mise-action caches ~/.local/share/mise but not ~/.rustup. On a runner + // image that already carries a 1.94.1 toolchain, a cache hit leaves the + // symlink resolving, so mise reports rust as installed and never adds the + // components mise.toml declares: `cargo fmt` and `cargo clippy` then fail + // with "not installed for the toolchain". The first run on a cache key + // misses and passes, so only the next one shows it. test-eql.yml and + // bench-eql.yml add the components the same way before `mise run`. + const COMPONENT_OF = { fmt: 'rustfmt', clippy: 'clippy' } + const LINT = /\bcargo\s+(fmt|clippy)\b/g + + const isMiseAction = (step) => + String(step?.uses ?? '').startsWith('jdx/mise-action@') + const restoresRootCache = (step) => + isMiseAction(step) && + step?.with?.cache !== false && + String(step?.with?.working_directory ?? '.') === '.' + + /** The lint commands a task runs, following the tasks it calls. */ + const taskLints = (name, seen = new Set()) => { + if (seen.has(name) || !TASKS.has(name)) return new Set() + seen.add(name) + const task = TASKS.get(name) + return new Set([ + ...[...stripCommentLines(task.body).matchAll(LINT)].map((m) => m[1]), + ...[...task.calls].flatMap((other) => [...taskLints(other, seen)]), + ]) + } + + /** The components a step needs: its own lint commands and its tasks'. */ + const stepNeeds = (step, job) => { + const bodies = expandMatrix(String(step?.run ?? ''), job).map( + stripCommentLines, + ) + const commands = new Set( + bodies.flatMap((body) => [ + ...[...body.matchAll(LINT)].map((m) => m[1]), + ...[...TASKS.keys()] + .filter((name) => invokes(body, name)) + .flatMap((name) => [...taskLints(name)]), + ]), + ) + return [...commands].map((command) => COMPONENT_OF[command]) + } + + const adds = (step) => { + const run = String(step?.run ?? '') + if (!/\brustup\s+component\s+add\b/.test(run)) return [] + return Object.values(COMPONENT_OF).filter((component) => + new RegExp(`\\b${component}\\b`).test(run), + ) + } + + /** Each job whose root mise-action can restore a cache. */ + const JOBS = ROOT_WORKFLOWS.flatMap(({ relPath, wf }) => + Object.entries(wf?.jobs ?? {}).flatMap(([jobName, job]) => { + const steps = jobSteps(job) + const mise = steps.findIndex(restoresRootCache) + if (mise === -1) return [] + return [{ relPath, jobName, job, steps, mise }] + }), + ) + + it('finds the jobs that lint the crates and the guests', () => { + const linting = JOBS.filter(({ job, steps }) => + steps.some((step) => stepNeeds(step, job).length > 0), + ).map(({ relPath, jobName }) => `${relPath} / ${jobName}`) + expect(linting).toEqual( + expect.arrayContaining([ + '.github/workflows/tests-crates.yml / rust', + '.github/workflows/tests-golang.yml / wasi-check', + ]), + ) + }) + + it('adds every component a lint step needs before that step', () => { + const missing = JOBS.flatMap(({ relPath, jobName, job, steps, mise }) => + steps.flatMap((step, index) => { + const added = new Set( + steps.slice(mise + 1, index).flatMap((earlier) => adds(earlier)), + ) + return stepNeeds(step, job) + .filter((component) => !added.has(component)) + .map( + (component) => + `${relPath} / ${jobName} / ${step?.name ?? index}: ${component}`, + ) + }), + ) + expect( + missing, + 'Add `rustup component add ` after the mise-action step and before these steps. A cache hit can leave the pinned toolchain without them.', + ).toEqual([]) + }) + + it('reads a lint command through the tasks a step runs', () => { + expect(stepNeeds({ run: 'cargo fmt --all --check' }, {})).toEqual([ + 'rustfmt', + ]) + expect(stepNeeds({ run: 'mise run wasm:guest:test' }, {}).sort()).toEqual([ + 'clippy', + 'rustfmt', + ]) + expect(stepNeeds({ run: '# cargo clippy' }, {})).toEqual([]) + expect(adds({ run: 'rustup component add rustfmt clippy' })).toEqual([ + 'rustfmt', + 'clippy', + ]) + }) +}) + describe('@cipherstash/profile stays private while it is unpublished', () => { // Without the flag the release gate classifies it as `js`, and changesets // would publish 0.35.0 with no binaries. Its six platform packages are the