name: CI

on:
  push:
    branches: [main]
    tags: ["v*"]
  pull_request:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

env:
  CARGO_TERM_COLOR: always
  KACHE_LOG: kache=debug

jobs:
  # Skip the build matrix on docs-only PRs (*.md, *.mdx, docs/**), which compile
  # nothing. Heavy jobs gate on `code`. We use a job-level `if`, not trigger
  # `paths-ignore`: the required checks must still report, and a *skipped* job
  # counts as success while a never-created one wedges branch protection at
  # "pending". Fails open (runs everything) on non-PR events or any API error.
  changes:
    name: Detect changes
    runs-on: ubuntu-latest
    timeout-minutes: 5
    outputs:
      code: ${{ steps.filter.outputs.code }}
    steps:
      - id: filter
        env:
          GH_TOKEN: ${{ github.token }}
          PR: ${{ github.event.pull_request.number }}
        run: |
          set -o pipefail
          if [ "${{ github.event_name }}" != "pull_request" ]; then
            echo "code=true" >> "$GITHUB_OUTPUT"; exit 0
          fi
          files=$(gh api --paginate "repos/${{ github.repository }}/pulls/$PR/files" \
            --jq '.[].filename') || { echo "code=true" >> "$GITHUB_OUTPUT"; exit 0; }
          printf '%s\n' "$files"
          if printf '%s\n' "$files" | grep -qvE '(\.mdx?$|^docs/)'; then
            echo "code=true"  >> "$GITHUB_OUTPUT"   # a non-docs file changed
          else
            echo "code=false" >> "$GITHUB_OUTPUT"   # docs only -> skip the matrix
          fi

  check:
    name: Check (${{ matrix.os }})
    runs-on: ${{ matrix.runner }}
    needs: changes
    if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true'
    # Job-level cap so a hang in checkout/mise/build is killed in minutes, not
    # the 6h GitHub default — the step-level timeouts below only guard their own
    # steps. (`just ci` itself is additionally capped at 30m.)
    timeout-minutes: 40
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: Linux
            runner: ubuntu-latest
          # `check` runs the full `just ci` (docker buildx + helm + tarpaulin)
          # and stays Linux-only. macOS gets a dedicated, lighter `test-macos`
          # job below — see that job for the rationale.
    steps:
      - uses: actions/checkout@v6
      # mise installs everything in mise.toml: rust (with rustfmt +
      # clippy via the rust tool-options block), just, helm, sccache,
      # cargo-llvm-cov.
      - uses: jdx/mise-action@v4
        with:
          # Pin the mise CLI. v2026.6.10 (2026-06-14) regressed binary
          # resolution ("mise ERROR cannot find binary path"), reddening every
          # run after its release — including unchanged code that passed hours
          # earlier. mise.toml already pins every *tool* for exactly this
          # reason; the CLI was the one unpinned surface. Bump deliberately once
          # upstream fixes it.
          version: 2026.6.9
          cache: false
      - uses: docker/setup-buildx-action@v4
      - uses: kunobi-ninja/kache-action@v1
        with:
          github-cache: "true"
          cache-executables: "false"
      - name: Clean bootstrap cache artifacts
        run: cargo clean
      - name: Run repo verification
        # kache-action exports the installed release version; self-checks must
        # compile with Cargo.toml's version from this PR.
        run: env -u KACHE_VERSION just ci
        timeout-minutes: 30
      - name: Check crates.io package metadata
        run: |
          version="$(cargo pkgid -p kache-core | sed 's/.*#//')"
          cargo package -p kache-core --locked
          # Poll the sparse index (index.crates.io), not `cargo search`: the
          # search index is eventually-consistent and lagged on the 0.4.0 cut.
          if curl -sf -A kache-ci-metadata-check "https://index.crates.io/ka/ch/kache-core" | grep -q "\"vers\":\"$version\""; then
            cargo package -p kache --locked
          else
            echo "kache-core $version is not on crates.io yet; skipping kache package verification"
          fi
        env:
          RUSTC_WRAPPER: ""
      - name: Check coverage threshold (88%)
        # Enforced on PRs too, not just push: in a trunk-based model a PR that
        # drops coverage must fail before merge, not after on the main push.
        run: |
          python3 -c "
          import json, sys
          data = json.load(open('tmp/llvm-cov/coverage.json'))
          coverage = data['data'][0]['totals']['lines']['percent']
          threshold = 88.0
          print(f'Coverage: {coverage:.1f}%')
          if coverage < threshold:
              print(f'FAIL: below threshold {threshold}%')
              sys.exit(1)
          print(f'OK: >= {threshold}%')
          "
      - name: Upload HTML coverage report
        uses: actions/upload-artifact@v7
        # Skip on tag/release runs: the shared release workflow downloads *all*
        # run artifacts and `gh release upload artifacts/*` chokes on directory
        # artifacts like this one. Coverage HTML is only useful on PR/branch runs.
        if: always() && github.ref_type != 'tag'
        with:
          name: coverage-html
          path: tmp/llvm-cov/html
          retention-days: 14

  # Fast, hermetic version gate (no nix / no cargo / no network). Runs on every
  # push & PR in internal mode (the shipping manifests must agree), and on a v*
  # tag additionally asserts the tag matches the manifest BEFORE the release job
  # builds anything. It is a `release` need (below), so a drifted tag — e.g. the
  # v0.5.0-rc.* tags that were cut against a 0.4.1 manifest — fails here in
  # seconds and never reaches the irreversible binary build / GitHub Release.
  # The old "Check Nix package version matches Cargo" step lived in nix-package
  # but compared two values both derived from Cargo.toml (tautological); this
  # gate replaces it. The publish floor re-runs the script directly (not by
  # matching a job name), so no job-name coupling is required.
  version-consistency:
    name: Version consistency
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v6
      - name: Manifests agree (and match the tag on a v* tag)
        # Pass the ref via a quoted env var (not `${{ }}` interpolated into the
        # script) — a tag name can legally contain shell metacharacters.
        env:
          REF_TYPE: ${{ github.ref_type }}
          REF_NAME: ${{ github.ref_name }}
        run: |
          if [ "$REF_TYPE" = "tag" ]; then
            ./scripts/check-version-consistency.sh "$REF_NAME"
          else
            ./scripts/check-version-consistency.sh
          fi

  nix-package:
    name: Nix package
    runs-on: ubuntu-latest
    needs: changes
    if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true'
    timeout-minutes: 25
    steps:
      - uses: actions/checkout@v6
      - uses: DeterminateSystems/nix-installer-action@v22
      - name: Check flake evaluation
        run: nix flake check --no-build
      - name: Build flake package
        run: nix build .#kache --no-link --print-build-logs

  # --- E2E smoke test ---
  # Builds kache, configures as RUSTC_WRAPPER, cold-builds a fixture project,
  # cleans, warm-builds, and verifies cache hits work end-to-end.
  e2e:
    name: E2E smoke (${{ matrix.os }})
    runs-on: ${{ matrix.runner }}
    needs: changes
    if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true'
    # Per-arm cap. The self-hosted macOS/Windows runners are persistent and have
    # hung in `checkout` on stale git refs (#208 run hung ~51m before manual
    # cancel); without a job timeout the default is 6h. Normal runs are <10m.
    timeout-minutes: 30
    # All three arms are blocking. Windows was non-blocking while its port
    # landed (the cache store #196, the `.sh` fallback wrappers #197, the
    # rust-c-ffi cc-rs path #198, the harness `.exe`/probe/OUT_DIR fixes
    # #201/#202); now that every fixture passes on Windows it gates like
    # Linux/macOS. The publish gates (`require-ci-green.sh`) also await
    # "E2E smoke (Windows)".
    # Run every step through bash on all three OSes. On Windows the bash comes
    # from Git for Windows (already present — checkout needs git), so the shared
    # step bodies below run identically everywhere. `exe_suffix` (matrix) is the
    # only build-output difference: Windows binaries are `.exe`.
    defaults:
      run:
        shell: bash
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: Linux
            runner: ubuntu-latest
            exe_suffix: ""
          # Use the self-hosted Apple Silicon runners only inside
          # kunobi-ninja and not for fork PRs; otherwise fall back to a
          # GitHub-hosted runner (a fork cannot reach our runners, so a
          # hardcoded label would queue forever). NOTE: this is a
          # convenience fallback, not a security control — for a `pull_request`
          # the fork's own copy of this file runs, so a fork can override it.
          # Fork PRs are gated by the repo's "require approval" Actions
          # setting, which is the actual boundary.
          - os: macOS
            runner: ${{ (github.repository_owner == 'kunobi-ninja' && github.event.pull_request.head.repo.fork != true) && fromJSON('["self-hosted", "macOS", "ARM64"]') || 'macos-latest' }}
            exe_suffix: ""
          # Self-hosted Windows runner shared with this repo (kunobi org,
          # group `kunobi`, label `kunobi-windows`). No fork-fallback expression
          # like macOS: it's an internal runner and the Windows e2e is internal
          # (#77); a fork PR simply can't reach it. Blocking like the other arms.
          - os: Windows
            runner: ${{ fromJSON('["self-hosted", "Windows", "X64", "kunobi-windows"]') }}
            exe_suffix: ".exe"
    steps:
      - uses: actions/checkout@v6
        # Checkout has hung on the persistent self-hosted runners' stale git
        # state (#208); a checkout never legitimately runs this long, so fail
        # fast rather than waiting out the whole job timeout.
        timeout-minutes: 10
      # Fail fast with an explicit checklist if the self-hosted Windows runner
      # is missing a build/fixture tool, instead of an opaque failure deep in
      # the cargo build or a fixture's `make`. Mirrors the verification step in
      # ans-runners/tasks/setup_windows.yml — keep the two tool lists in sync.
      #   cc/c++/make  -> C/C++ fixtures (harness sets CC="$KACHE cc")
      #   nasm         -> ring's x86_64 `.asm` (the rustls crypto provider).
      #                   perl/cmake are no longer needed: they were aws-lc-sys
      #                   build deps, and aws-lc-sys was dropped in favour of ring.
      #   MSVC link.exe presence is proven by a successful `cargo build`.
      - name: Verify Windows build toolchain
        if: runner.os == 'Windows'
        run: |
          missing=
          for t in cc c++ make nasm; do
            if ! command -v "$t" >/dev/null 2>&1; then
              echo "MISSING on runner PATH: $t — provision it (see ans-runners/tasks/setup_windows.yml)"
              missing=1
            fi
          done
          [ -z "$missing" ] || exit 1
          echo "all build/fixture tools present"
      # Point tool state at the per-run temp dir before installing. All the
      # self-hosted runners (macOS AND Windows) are persistent: shared rustup /
      # mise state goes stale and makes `mise --force rust` try to remove a
      # locked toolchain dir (`Access is denied (os error 5)` on Windows), and
      # `rustup target add` against a shared toolchain poisons every later job.
      # Keep Rust + mise installs disposable per job so one run can't break the
      # next. Runs everywhere via `shell: bash` (Git Bash on Windows).
      - name: Isolate tool state
        run: |
          mkdir -p \
            "$RUNNER_TEMP/rustup" \
            "$RUNNER_TEMP/cargo" \
            "$RUNNER_TEMP/mise-data/bin" \
            "$RUNNER_TEMP/mise-data/shims" \
            "$RUNNER_TEMP/mise-cache" \
            "$RUNNER_TEMP/mise-state"
          rm -rf "$RUNNER_TEMP/mise"
          # Fresh, per-(instance,run) scratch for mise-action's download/extract,
          # exported as MISE_DL_TMPDIR for the install step below. On Windows
          # mise-action extracts with chocolatey `unzip.exe`, which prompts
          # interactively on overwrite and hangs forever in CI (no stdin); and
          # RUNNER_TEMP on the self-hosted runners is persistent (the service
          # account's %TEMP% on Windows, possibly shared between instances), so
          # leftovers from earlier runs collide. A unique dir can't collide;
          # scope it AND the sweep to a sanitized $RUNNER_NAME so the sweep only
          # ever removes THIS instance's dirs, never a sibling's in-use dir on a
          # shared Temp. (Windows ignores $TMPDIR, so the install step also sets
          # %TEMP%/%TMP% to this path.)
          safe_runner=$(printf '%s' "$RUNNER_NAME" | tr -c 'A-Za-z0-9_.-' '_')
          rm -rf "$RUNNER_TEMP"/mise-dl-"$safe_runner"-* 2>/dev/null || true
          mise_dl="$RUNNER_TEMP/mise-dl-$safe_runner-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
          mkdir -p "$mise_dl"
          echo "MISE_DL_TMPDIR=$mise_dl" >> "$GITHUB_ENV"
          echo "RUSTUP_HOME=$RUNNER_TEMP/rustup" >> "$GITHUB_ENV"
          echo "CARGO_HOME=$RUNNER_TEMP/cargo" >> "$GITHUB_ENV"
          echo "MISE_DATA_DIR=$RUNNER_TEMP/mise-data" >> "$GITHUB_ENV"
          echo "MISE_CACHE_DIR=$RUNNER_TEMP/mise-cache" >> "$GITHUB_ENV"
          echo "MISE_STATE_DIR=$RUNNER_TEMP/mise-state" >> "$GITHUB_ENV"
      # `cache: false` so the self-hosted runner's mise state never
      # round-trips through GHA cache (it accumulates broken shims).
      # The state dirs above are fresh per job, so there are no old
      # shims to repair. Keep `reshim` off: the self-hosted macOS
      # runner has failed inside `mise reshim -f` before any build ran.
      # mise installs rust + sccache (the latter backs the
      # `rust-sccache` fixture / KACHE_FALLBACK check). Tool names
      # must match the mise.toml key — sccache is registered under
      # the github backend, so the full `github:mozilla/sccache`
      # identifier is required.
      - name: Install tools via mise
        uses: jdx/mise-action@v4
        env:
          # jdx/mise-action extracts into `$TMPDIR/mise` before moving
          # the binary. On the shared macOS host, parallel runner
          # instances share the default /var/folders/.../T path and can
          # race each other. Scope TMPDIR to the action only so Cargo
          # builds keep their normal environment.
          # Unix uses $TMPDIR; Windows uses %TEMP%/%TMP%. Set all three so the
          # download/extract lands in our per-run dir on every platform.
          TMPDIR: ${{ env.MISE_DL_TMPDIR }}
          TEMP: ${{ env.MISE_DL_TMPDIR }}
          TMP: ${{ env.MISE_DL_TMPDIR }}
        with:
          # Pin mise CLI — see the bootstrap job's note (v2026.6.10 regression).
          version: 2026.6.9
          install_args: --force rust github:mozilla/sccache
          cache: false
          reshim: false
      # mise installs the toolchain via rustup under RUSTUP_HOME, but on a
      # runner with a pre-existing rustup it leaves no cargo proxy on PATH —
      # so the stale system Rust (Homebrew 1.94) would win. Prepend the
      # toolchain's own bin dir (the real 1.95 binaries) to PATH instead.
      # macOS-runner workaround (see "Isolate tool state"): skipped on Windows,
      # where mise-action puts cargo on PATH directly and there is no competing
      # system Rust to shadow it.
      - name: Put the Rust toolchain first on PATH
        if: runner.os != 'Windows'
        run: |
          bin_dir=$(echo "$RUSTUP_HOME"/toolchains/*/bin)
          if [ ! -x "$bin_dir/cargo" ]; then
            echo "cargo not found under $RUSTUP_HOME/toolchains/*/bin"
            ls -la "$RUSTUP_HOME/toolchains" 2>/dev/null || true
            exit 1
          fi
          "$bin_dir/rustc" --version
          echo "$bin_dir" >> "$GITHUB_PATH"
      # The `rust-c-ffi` fixture builds with the windows-gnu target on Windows
      # (its C half is MinGW/GNU — see that fixture's `[windows]` override); the
      # MinGW linker is already provisioned. Add BOTH targets: on the
      # mise-managed toolchain the host (msvc) std is bundled, but once
      # `rustup target add` runs, rustup resolves std from its per-target dirs —
      # so the host build breaks ("can't find crate for std") unless msvc is
      # installed there too.
      - name: Add Rust targets (msvc host + windows-gnu for rust-c-ffi)
        if: runner.os == 'Windows'
        run: rustup target add x86_64-pc-windows-msvc x86_64-pc-windows-gnu
      - name: Build kache + e2e harness (release)
        run: |
          rustc --version && cargo --version
          cargo build --release -p kache && cargo build --release -p kache-e2e
        env:
          RUSTC_WRAPPER: ""
      - name: Run e2e harness (gate scenarios)
        # Single Rust harness drives selected e2e scenarios from scenarios/
        # through cold → warm → noop, applies per-fixture assertions
        # against `kache report --format json`, and writes a single
        # results.json. Replaces the previous per-language bash scripts.
        run: |
          mkdir -p tmp/e2e
          # Bounded retry to absorb transient self-hosted-runner / Windows
          # file-system flakes (e.g. a relocate-phase build hiccup). Each
          # attempt is a full clean re-run, so cache assertions stay sound.
          # A real failure fails all attempts; retries are logged as warnings
          # so a genuinely-intermittent issue stays visible, not hidden.
          n=0
          until ./target/release/kache-scenario${{ matrix.exe_suffix }} \
            --kache ./target/release/kache${{ matrix.exe_suffix }} \
            --scenarios ./scenarios \
            --select suite:e2e \
            --select tier:gate \
            --out tmp/e2e/results.json
          do
            n=$((n + 1))
            if [ "$n" -ge 3 ]; then
              echo "::error::e2e harness failed after $n attempts"
              exit 1
            fi
            echo "::warning::e2e harness attempt $n failed; retrying (transient-flake mitigation)"
            sleep 15
          done
      - name: Run e2e negative-control (falsifiability check)
        # Reruns every fixture with kache disabled (KACHE_DISABLED=1)
        # and asserts each non-exempt result FLIPS to a failure — a
        # cache-dependent fixture that still passes with kache off has
        # a vacuous test that does not actually exercise caching.
        # Independent signal; `always()` so it runs even when the
        # harness run above failed, as long as the build produced the
        # binaries.
        if: always() && hashFiles(format('target/release/kache-scenario{0}', matrix.exe_suffix)) != ''
        run: |
          ./target/release/kache-scenario${{ matrix.exe_suffix }} \
            --kache ./target/release/kache${{ matrix.exe_suffix }} \
            --scenarios ./scenarios \
            --select suite:e2e \
            --select tier:gate \
            --negative-control \
            --out tmp/e2e/negative-control.json
      - name: Check the sccache fallback caches an rlib
        # The `rust-sccache` fixture (run by the harness above) proves
        # the executable passthrough composes with sccache; sccache
        # does not cache `bin` crates, so this step covers the path
        # that does — a library compile kache passes through to
        # sccache, asserting the rebuild is an sccache cache hit.
        # Independent signal; `always()` so it runs even if the
        # harness step above failed.
        if: always() && hashFiles(format('target/release/kache{0}', matrix.exe_suffix)) != ''
        run: |
          # Bounded retry, same rationale as the harness step above.
          n=0
          until ./scripts/sccache-fallback-check.sh ./target/release/kache${{ matrix.exe_suffix }}
          do
            n=$((n + 1))
            if [ "$n" -ge 3 ]; then
              echo "::error::sccache fallback check failed after $n attempts"
              exit 1
            fi
            echo "::warning::sccache fallback check attempt $n failed; retrying (transient-flake mitigation)"
            sleep 15
          done
      - name: Show aggregated e2e results
        if: always()
        run: |
          if [ -f tmp/e2e/results.json ]; then
            echo "--- e2e results.json ---"
            cat tmp/e2e/results.json
          else
            echo "no results.json produced"
          fi
      - name: Upload e2e results artifact
        # Per-arm artifact name (#203): with upload-artifact@v4+ an artifact
        # name is unique per run, so all three matrix arms uploading the same
        # `e2e-results` collide — `gh run download -n e2e-results` then returns
        # an arbitrary arm's results and the others are lost (this masked the
        # real Windows results.json while debugging #196). `${{ matrix.os }}`
        # keeps each arm's results.json + negative-control.json separate.
        # The path moved to `tmp/e2e/` per the repo-wide scratch-under-tmp
        # convention (see .gitignore comment).
        #
        # Skipped on tag/release runs for the same reason as the coverage
        # artifact: the shared release workflow's `gh release upload
        # artifacts/*` would try to upload this directory as a release asset.
        if: always() && github.ref_type != 'tag'
        uses: actions/upload-artifact@v7
        with:
          name: e2e-results-${{ matrix.os }}
          path: tmp/e2e/
          retention-days: 14

  # --- macOS test suite ---
  # The Linux `check` job runs the full `just ci` (coverage + docker + helm);
  # replicating that on macOS isn't worth it. Instead this job runs a focused
  # `cargo test` pass on a self-hosted Apple Silicon runner so OS-specific
  # regressions — e.g. the daemon's Unix-socket EOF behaviour, which once hung
  # the suite on macOS while passing on Linux — are caught before merge.
  # Blocking: macOS is a first-class supported platform.
  test-macos:
    name: Test (macOS)
    # Self-hosted Apple Silicon inside kunobi-ninja (non-fork); GitHub-hosted
    # otherwise so forks can still run this job. Convenience only — see the
    # matching note on the `e2e` job; fork PRs are gated by the repo's
    # "require approval" Actions setting, not by this expression.
    runs-on: ${{ (github.repository_owner == 'kunobi-ninja' && github.event.pull_request.head.repo.fork != true) && fromJSON('["self-hosted", "macOS", "ARM64"]') || 'macos-latest' }}
    needs: changes
    if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true'
    # Job-level cap on the persistent self-hosted macOS runner (cargo test is
    # additionally capped at 30m); catches checkout/mise hangs.
    timeout-minutes: 40
    steps:
      - uses: actions/checkout@v6
        # See the e2e job: checkout can hang on this persistent runner's stale
        # git state, so cap it well under the job timeout.
        timeout-minutes: 10
      # This persistent self-hosted runner has no usable shared toolchain:
      # its ~/.rustup is corrupted and its Homebrew Rust is stale (1.94,
      # below the pinned 1.95). Keep Rust and mise install/cache/state under
      # the per-run temp dir so setup is isolated from shared runner drift.
      - name: Isolate tool state
        run: |
          mkdir -p \
            "$RUNNER_TEMP/rustup" \
            "$RUNNER_TEMP/cargo" \
            "$RUNNER_TEMP/mise-data/bin" \
            "$RUNNER_TEMP/mise-data/shims" \
            "$RUNNER_TEMP/mise-cache" \
            "$RUNNER_TEMP/mise-state"
          rm -rf "$RUNNER_TEMP/mise"
          # Fresh, per-(instance,run) scratch for mise-action's download/extract,
          # exported as MISE_DL_TMPDIR for the install step below. On Windows
          # mise-action extracts with chocolatey `unzip.exe`, which prompts
          # interactively on overwrite and hangs forever in CI (no stdin); and
          # RUNNER_TEMP on the self-hosted runners is persistent (the service
          # account's %TEMP% on Windows, possibly shared between instances), so
          # leftovers from earlier runs collide. A unique dir can't collide;
          # scope it AND the sweep to a sanitized $RUNNER_NAME so the sweep only
          # ever removes THIS instance's dirs, never a sibling's in-use dir on a
          # shared Temp. (Windows ignores $TMPDIR, so the install step also sets
          # %TEMP%/%TMP% to this path.)
          safe_runner=$(printf '%s' "$RUNNER_NAME" | tr -c 'A-Za-z0-9_.-' '_')
          rm -rf "$RUNNER_TEMP"/mise-dl-"$safe_runner"-* 2>/dev/null || true
          mise_dl="$RUNNER_TEMP/mise-dl-$safe_runner-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
          mkdir -p "$mise_dl"
          echo "MISE_DL_TMPDIR=$mise_dl" >> "$GITHUB_ENV"
          echo "RUSTUP_HOME=$RUNNER_TEMP/rustup" >> "$GITHUB_ENV"
          echo "CARGO_HOME=$RUNNER_TEMP/cargo" >> "$GITHUB_ENV"
          echo "MISE_DATA_DIR=$RUNNER_TEMP/mise-data" >> "$GITHUB_ENV"
          echo "MISE_CACHE_DIR=$RUNNER_TEMP/mise-cache" >> "$GITHUB_ENV"
          echo "MISE_STATE_DIR=$RUNNER_TEMP/mise-state" >> "$GITHUB_ENV"
      # Same setup as the `e2e` job — see that block for the
      # `cache: false` + `reshim: false` rationale.
      - name: Install Rust via mise
        uses: jdx/mise-action@v4
        env:
          # See the e2e job: isolate mise's download/extract scratch
          # without changing TMPDIR for the later Cargo test process.
          # Unix uses $TMPDIR; Windows uses %TEMP%/%TMP%. Set all three so the
          # download/extract lands in our per-run dir on every platform.
          TMPDIR: ${{ env.MISE_DL_TMPDIR }}
          TEMP: ${{ env.MISE_DL_TMPDIR }}
          TMP: ${{ env.MISE_DL_TMPDIR }}
        with:
          # Pin mise CLI — see the bootstrap job's note (v2026.6.10 regression).
          version: 2026.6.9
          install_args: --force rust
          cache: false
          reshim: false
      # mise installs the toolchain via rustup under RUSTUP_HOME, but on a
      # runner with a pre-existing rustup it leaves no cargo proxy on PATH —
      # so the stale system Rust (Homebrew 1.94) would win. Prepend the
      # toolchain's own bin dir (the real 1.95 binaries) to PATH instead.
      - name: Put the Rust toolchain first on PATH
        run: |
          bin_dir=$(echo "$RUSTUP_HOME"/toolchains/*/bin)
          if [ ! -x "$bin_dir/cargo" ]; then
            echo "cargo not found under $RUSTUP_HOME/toolchains/*/bin"
            ls -la "$RUSTUP_HOME/toolchains" 2>/dev/null || true
            exit 1
          fi
          "$bin_dir/rustc" --version
          echo "$bin_dir" >> "$GITHUB_PATH"
      - name: cargo test (workspace)
        run: |
          rustc --version && cargo --version
          cargo test --workspace
        timeout-minutes: 30
        env:
          RUSTC_WRAPPER: ""
      # Clippy otherwise runs only on Linux (`just ci` → `lint`), so lints
      # inside `#[cfg(target_os = "macos")]` code went unchecked. Run it here too.
      - name: cargo clippy (workspace)
        run: cargo clippy --workspace --all-targets -- -D warnings
        timeout-minutes: 30
        env:
          RUSTC_WRAPPER: ""

  # --- Windows test suite ---
  # Mirrors `test-macos`: the Linux `check` job runs the full `just ci` and
  # macOS has its own focused pass, but until now nothing ran `cargo test` (or
  # clippy) on Windows — so Windows-only regressions slipped through every gate
  # (e.g. #348: the build-session marker write failing under a mandatory file
  # lock). Self-hosted Windows runner, same as the e2e job's Windows arm.
  # Blocking: Windows is a first-class supported platform.
  test-windows:
    name: Test (Windows)
    runs-on: ${{ fromJSON('["self-hosted", "Windows", "X64", "kunobi-windows"]') }}
    needs: changes
    if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true'
    # Windows on the self-hosted runner is the slowest leg: a from-scratch
    # `cargo test --workspace` compile alone exceeded the previous 30m step cap.
    # Job cap must cover the test step (45m) + clippy step (30m) + setup.
    timeout-minutes: 90
    defaults:
      run:
        shell: bash
    steps:
      - uses: actions/checkout@v6
        timeout-minutes: 10
      # Fail fast if the runner is missing a build tool the dep graph needs
      # (ring → nasm, *-sys → cc). Mirrors the e2e job's check.
      - name: Verify Windows build toolchain
        run: |
          missing=
          for t in cc c++ make nasm; do
            if ! command -v "$t" >/dev/null 2>&1; then
              echo "MISSING on runner PATH: $t — provision it (see ans-runners/tasks/setup_windows.yml)"
              missing=1
            fi
          done
          [ -z "$missing" ] || exit 1
          echo "all build/fixture tools present"
      # Keep Rust + mise install/cache/state under the per-run temp dir so setup
      # is isolated from this persistent runner's shared drift. See the e2e and
      # test-macos jobs for the full rationale.
      - name: Isolate tool state
        run: |
          mkdir -p \
            "$RUNNER_TEMP/rustup" \
            "$RUNNER_TEMP/cargo" \
            "$RUNNER_TEMP/mise-data/bin" \
            "$RUNNER_TEMP/mise-data/shims" \
            "$RUNNER_TEMP/mise-cache" \
            "$RUNNER_TEMP/mise-state"
          rm -rf "$RUNNER_TEMP/mise"
          safe_runner=$(printf '%s' "$RUNNER_NAME" | tr -c 'A-Za-z0-9_.-' '_')
          rm -rf "$RUNNER_TEMP"/mise-dl-"$safe_runner"-* 2>/dev/null || true
          mise_dl="$RUNNER_TEMP/mise-dl-$safe_runner-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
          mkdir -p "$mise_dl"
          echo "MISE_DL_TMPDIR=$mise_dl" >> "$GITHUB_ENV"
          echo "RUSTUP_HOME=$RUNNER_TEMP/rustup" >> "$GITHUB_ENV"
          echo "CARGO_HOME=$RUNNER_TEMP/cargo" >> "$GITHUB_ENV"
          echo "MISE_DATA_DIR=$RUNNER_TEMP/mise-data" >> "$GITHUB_ENV"
          echo "MISE_CACHE_DIR=$RUNNER_TEMP/mise-cache" >> "$GITHUB_ENV"
          echo "MISE_STATE_DIR=$RUNNER_TEMP/mise-state" >> "$GITHUB_ENV"
      - name: Install Rust via mise
        uses: jdx/mise-action@v4
        env:
          TMPDIR: ${{ env.MISE_DL_TMPDIR }}
          TEMP: ${{ env.MISE_DL_TMPDIR }}
          TMP: ${{ env.MISE_DL_TMPDIR }}
        with:
          # Pin mise CLI — see the bootstrap job's note (v2026.6.10 regression).
          version: 2026.6.9
          install_args: --force rust
          cache: false
          reshim: false
      # No "toolchain first on PATH" step: on Windows mise-action puts cargo on
      # PATH directly and there is no competing system Rust to shadow it.
      - name: cargo test (workspace)
        run: |
          rustc --version && cargo --version
          cargo test --workspace
        timeout-minutes: 45
        env:
          RUSTC_WRAPPER: ""
      # Clippy otherwise runs only on Linux, leaving `#[cfg(windows)]` code
      # unlinted. Run it here so platform-gated lints are caught on Windows too.
      - name: cargo clippy (workspace)
        run: cargo clippy --workspace --all-targets -- -D warnings
        timeout-minutes: 30
        env:
          RUSTC_WRAPPER: ""

  # --- Release (only on v* tags) ---
  release:
    # This `if:` and the `version-consistency` job's `if: github.ref_type ==
    # 'tag'` must stay paired: both skip together on non-tag pushes, so a
    # *skipped* gate never resolves as success-equivalent and silently un-gates
    # the release. If you relax this `if:`, relax the gate's too (or drop the
    # gate's `if:` so it always runs).
    if: startsWith(github.ref, 'refs/tags/v')
    # Gate the release (and, transitively, the crates publish it triggers) on
    # the full validation suite — `version-consistency` rejects a tag that
    # disagrees with the manifest BEFORE any binary builds; `e2e` includes the
    # (blocking) Windows arm, so a tag won't release unless Windows e2e is green.
    needs: [check, nix-package, e2e, test-macos, test-windows, version-consistency]
    uses: zondax/_workflows/.github/workflows/_release-rust.yml@v10
    with:
      binary_name: kache
      # Build .deb assets (kache_<version>_{amd64,arm64}.deb) for the apt repo,
      # consumed by .github/workflows/package-publish.yml after release publish.
      # NOTE: requires the _workflows `build_deb` change merged AND the `v10` tag
      # moved to include it first — so THIS PR must merge AFTER that _workflows PR.
      build_deb: true
      # Also publish the bare signed kache.exe per Windows arch as a first-class
      # release asset (kache-<triple>-pc-windows-msvc.exe), so winget can install
      # it as InstallerType: portable (see package-publish.yml). Same merge-order
      # caveat as build_deb: needs the _workflows `upload_windows_exe` change in
      # `v10` first. The .zip is still produced (cargo-binstall consumes it).
      upload_windows_exe: true
      # Both Windows targets (x64 + arm64) ship as `-pc-windows-msvc`, cross-built
      # on the Linux runner via cargo-xwin: clang-cl + lld-link against the MSVC
      # CRT and Windows SDK that `xwin` downloads. TLS uses the `ring` crypto
      # provider (no aws-lc-sys — its cmake build hung cross-compiling to arm64),
      # so the runner needs `nasm` to assemble ring's x86_64 `.asm` (provisioned
      # by _release-rust.yml). No native Windows runner is needed here
      # (`runner_windows` left unset); the kunobi-windows runner is still used by
      # the e2e job above.
      #
      # `aarch64-pc-windows-msvc` requires the cargo-xwin clang shim added in
      # _workflows@v10 (rewrites ring's `.S` asm `/imsvc` flags → `-isystem` so the
      # GNU clang driver accepts them). Without that shim the arm64 build fails at
      # ring's asm step. blake3 also uses its `pure` feature on Windows (see
      # Cargo.toml) since its arm64 NEON C doesn't cross-compile.
      targets: '["x86_64-unknown-linux-musl", "aarch64-unknown-linux-musl", "aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"]'
      runner_macos: '["self-hosted", "macOS", "ARM64"]'
      extra_build_env: |
        KACHE_VERSION=${{ github.ref_name }}
      # --- GCP-KMS code signing (reuses the CODESIGN_* identity for all platforms) ---
      enable_signing: true
      notarize_macos: true
      # NOTE: CODESIGN_*/PGP_SIGN_* config is passed via `secrets:` below — they're
      # repo secrets (public repo) and the `secrets` context isn't allowed in `with:`.
      # sha256 pins for the downloaded signers (set as repo vars once known;
      # empty → action warns but still signs).
      jsign_sha256: ${{ vars.JSIGN_SHA256 }}
      rcodesign_sha256: ${{ vars.RCODESIGN_SHA256 }}
    secrets:
      pgp_cert_base64: ${{ secrets.PGP_CERT_BASE64 }}
      pgp_signer_token: ${{ secrets.GH_PAT_KUNOBI_NINJA_RELEASES }}
      windows_cert_chain: ${{ secrets.CODESIGN_CERT_CHAIN }}
      # Config kept as secrets (public repo); macOS fetches Apple creds from GCP
      # Secret Manager via the CODESIGN_* identity — no Apple GitHub secrets.
      codesign_wif_provider: ${{ secrets.CODESIGN_WIF_PROVIDER }}
      codesign_gcp_project: ${{ secrets.CODESIGN_GCP_PROJECT }}
      codesign_service_account: ${{ secrets.CODESIGN_SERVICE_ACCOUNT }}
      codesign_kms_keyring: ${{ secrets.CODESIGN_KMS_KEYRING }}
      codesign_kms_key_alias: ${{ secrets.CODESIGN_KMS_KEY_ALIAS }}
      pgp_sign_wif_provider: ${{ secrets.PGP_SIGN_WIF_PROVIDER }}
      pgp_sign_gcp_project_id: ${{ secrets.PGP_SIGN_GCP_PROJECT_ID }}
      pgp_sign_service_account: ${{ secrets.PGP_SIGN_SERVICE_ACCOUNT }}
      pgp_sign_kms_key_version: ${{ secrets.PGP_SIGN_KMS_KEY_VERSION }}
    permissions:
      contents: write
      id-token: write
