1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
name: CI
on:
push:
branches:
tags:
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:
check:
name: Check (${{ matrix.os }})
runs-on: ${{ matrix.runner }}
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:
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 (35%)
# 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 = 35.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
nix-package:
name: Nix package
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: DeterminateSystems/nix-installer-action@v22
- name: Check flake evaluation
run: nix flake check --no-build
- name: Check Nix package version matches Cargo
run: |
cargo_version="$(python3 - <<'PY'
import tomllib
with open("Cargo.toml", "rb") as f:
print(tomllib.load(f)["package"]["version"])
PY
)"
nix_version="$(nix eval --raw .#kache.version)"
if [ "$nix_version" != "$cargo_version" ]; then
echo "Nix package version $nix_version does not match Cargo version $cargo_version" >&2
exit 1
fi
- 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 }}
# Windows is non-blocking for now: the runner + harness work, but kache's
# Windows port is incomplete, so most fixtures still fail — the cache store
# ("flushing blob to disk", #196), the `.sh` fallback wrappers (#197), and
# the rust-c-ffi cc-rs path (#198). Keep it running for signal; flip to
# blocking once those land. Linux/macOS stay blocking.
continue-on-error: ${{ matrix.os == '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. Non-blocking for now (see the
# job-level continue-on-error note).
- os: Windows
runner: ${{ fromJSON('["self-hosted", "Windows", "X64", "kunobi-windows"]') }}
exe_suffix: ".exe"
steps:
- uses: actions/checkout@v6
# 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/perl/cmake -> kache build (ring + aws-lc-sys); 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 perl cmake; 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:
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"
- 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 (all fixtures)
# Single Rust harness drives every fixture in test-projects/
# 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
./target/release/kache-e2e${{ matrix.exe_suffix }} \
--kache ./target/release/kache${{ matrix.exe_suffix }} \
--fixtures ./test-projects \
--out tmp/e2e/results.json
- name: Run e2e negative-control (falsifiability check)
# Reruns every fixture with kache disabled (KACHE_DISABLED=1)
# and asserts each result FLIPS to a failure — a 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-e2e{0}', matrix.exe_suffix)) != ''
run: |
./target/release/kache-e2e${{ matrix.exe_suffix }} \
--kache ./target/release/kache${{ matrix.exe_suffix }} \
--fixtures ./test-projects \
--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: ./scripts/sccache-fallback-check.sh ./target/release/kache${{ matrix.exe_suffix }}
- 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
# Artifact name kept as `e2e-results` (the GitHub-facing label) so
# historical-run links stay valid; the path it uploads from 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
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' }}
steps:
- uses: actions/checkout@v6
# 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:
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: ""
# --- Release (only on v* tags) ---
release:
if: startsWith(github.ref, 'refs/tags/v')
# Gate the release (and, transitively, the crates publish it triggers) on
# the full validation suite — not just `check`. windows-check is omitted on
# purpose: it is exploratory (continue-on-error) and must not block a release.
needs:
uses: zondax/_workflows/.github/workflows/_release-rust.yml@main
with:
binary_name: kache
targets: '["x86_64-unknown-linux-musl", "aarch64-unknown-linux-musl", "aarch64-apple-darwin", "x86_64-apple-darwin"]'
runner_macos: '["self-hosted", "macOS", "ARM64"]'
extra_build_env: |
KACHE_VERSION=${{ github.ref_name }}
permissions:
contents: write