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
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
name: CI
on:
push:
branches:
tags:
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
# SECURITY: validation jobs in this workflow use disposable GitHub-hosted
# runners. Its only private-runner consumer is the tag-gated release job below;
# runner-group policy must enforce that boundary independently of this
# PR-editable workflow file.
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# 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@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0
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@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- uses: kunobi-ninja/kache-action@49398d37113c616fdb61be434cb497e3c2c8f3e6 # 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
# 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
# Plan a bounded changed-line mutation matrix. Large Rust PRs can expose
# hundreds of mutants; one in-place cargo-mutants process is deliberately
# serial and cannot finish that scope inside a useful CI timeout.
mutation-plan:
name: Mutation plan
runs-on: ubuntu-latest
needs: changes
if: always()
timeout-minutes: 10
outputs:
count: ${{ steps.plan.outputs.count }}
matrix: ${{ steps.plan.outputs.matrix }}
steps:
- if: github.event_name == 'pull_request' && needs.changes.outputs.code == 'true'
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- if: github.event_name == 'pull_request' && needs.changes.outputs.code == 'true'
uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0
with:
version: 2026.6.9
install_args: rust
cache: false
- if: github.event_name == 'pull_request' && needs.changes.outputs.code == 'true'
uses: taiki-e/install-action@41049aa56687c35e0afa74eed4f09cec4f9afabf # v2.85.2
with:
tool: cargo-mutants@27.1.0
fallback: none
- id: plan
env:
MUTATE_DIFF: ${{ github.event_name == 'pull_request' && needs.changes.outputs.code == 'true' }}
run: |
if [ "$MUTATE_DIFF" != "true" ]; then
echo 'count=0' >> "$GITHUB_OUTPUT"
echo 'matrix={"include":[{"index":0,"display":1,"total":1}]}' >> "$GITHUB_OUTPUT"
exit 0
fi
mkdir -p tmp/mutants
git rev-parse --verify 'HEAD^1^{commit}'
git diff --no-ext-diff --unified=1 HEAD^1 HEAD -- '*.rs' \
> tmp/mutants/pr.diff
count="$(
cargo mutants \
--workspace \
--all-features \
--in-diff tmp/mutants/pr.diff \
--exclude 'crates/kache-core/**' \
--exclude 'crates/kache-service/**' \
--list \
--json |
python3 -c '
import json, sys
payload = sys.stdin.read()
print(len(json.loads(payload)) if payload.strip() else 0)
'
)"
matrix="$(python3 -c '
import json, sys
count = int(sys.argv[1])
total = max(1, (count + 9) // 10)
if total > 256:
raise SystemExit(
f"changed-line scope needs {total} shards; GitHub permits at most 256"
)
print(json.dumps({"include": [
{"index": index, "display": index + 1, "total": total}
for index in range(total)
]}, separators=(",", ":")))
' "$count")"
echo "changed-line mutants: $count"
echo "count=$count" >> "$GITHUB_OUTPUT"
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
- if: steps.plan.outputs.count != '0'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: mutation-diff-plan
path: tmp/mutants/pr.diff
retention-days: 14
# Complete mutation coverage for the small, hermetic planner crate on every
# code PR/main push.
mutation-core:
name: Mutation testing (core)
runs-on: ubuntu-latest
needs:
if: github.ref_type != 'tag' && (github.event_name != 'pull_request' || needs.changes.outputs.code == 'true')
timeout-minutes: 20
env:
RUSTC_WRAPPER: ""
CARGO_INCREMENTAL: "1"
# Mutants fail properties intentionally; do not persist their shrunk cases.
PROPTEST_DISABLE_FAILURE_PERSISTENCE: "1"
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# PR checkout is a synthetic merge commit. Its first parent is the
# exact base used by this run, avoiding stale event SHAs.
fetch-depth: 0
- uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0
with:
version: 2026.6.9
install_args: rust
cache: false
- uses: taiki-e/install-action@41049aa56687c35e0afa74eed4f09cec4f9afabf # v2.85.2
with:
tool: cargo-mutants@27.1.0
fallback: none
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
shared-key: mutants
cache-bin: false
cache-on-failure: false
- name: Verify complete mutation scope is non-empty
run: |
mkdir -p tmp/mutants
count="$(
cargo mutants --package kache-core --all-features --list --json |
python3 -c 'import json, sys; print(len(json.load(sys.stdin)))'
)"
echo "kache-core mutants: $count"
test "$count" -gt 0
- name: Mutate all kache-core behavior
timeout-minutes: 15
# rust-cache exports CARGO_INCREMENTAL=0 after the job-level env is
# evaluated. Mutation builds deliberately need incremental reuse.
env:
CARGO_INCREMENTAL: "1"
run: |
cargo mutants \
--package kache-core \
--all-features \
--in-place \
--baseline run \
--caught \
--timeout 300 \
--build-timeout 600 \
--annotations github \
--output tmp/mutants/core
- name: Upload core mutation report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: mutation-core-report
path: tmp/mutants/core
retention-days: 14
# Complete mutation coverage for the remote service crate on every code
# PR/main push. This is separate from the changed-line lane so unchanged
# authentication, readiness, startup, and shutdown behavior cannot regress.
mutation-service:
name: Mutation testing (service)
runs-on: ubuntu-latest
needs:
if: github.ref_type != 'tag' && (github.event_name != 'pull_request' || needs.changes.outputs.code == 'true')
timeout-minutes: 20
env:
RUSTC_WRAPPER: ""
CARGO_INCREMENTAL: "1"
PROPTEST_DISABLE_FAILURE_PERSISTENCE: "1"
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0
with:
version: 2026.6.9
install_args: rust
cache: false
- uses: taiki-e/install-action@41049aa56687c35e0afa74eed4f09cec4f9afabf # v2.85.2
with:
tool: cargo-mutants@27.1.0
fallback: none
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
shared-key: mutants
cache-bin: false
cache-on-failure: false
- name: Verify complete service mutation scope is non-empty
run: |
mkdir -p tmp/mutants
count="$(
cargo mutants --package kache-service --all-features --list --json |
python3 -c 'import json, sys; print(len(json.load(sys.stdin)))'
)"
echo "kache-service mutants: $count"
test "$count" -gt 0
- name: Mutate all kache-service behavior
timeout-minutes: 15
env:
CARGO_INCREMENTAL: "1"
run: |
cargo mutants \
--package kache-service \
--all-features \
--in-place \
--baseline run \
--caught \
--timeout 300 \
--build-timeout 600 \
--annotations github \
--output tmp/mutants/service
- name: Upload service mutation report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: mutation-service-report
path: tmp/mutants/service
retention-days: 14
# PR-only workspace mutation is split into round-robin shards. Ten
# serial in-place mutants per runner bounds exposure to slow or pathological
# mutants, while round-robin avoids concentrating them in one shard.
mutation-diff:
name: Mutation testing (diff ${{ matrix.display }}/${{ matrix.total }})
runs-on: ubuntu-latest
needs:
if: github.event_name == 'pull_request' && needs.mutation-plan.outputs.count != '0'
timeout-minutes: 55
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.mutation-plan.outputs.matrix) }}
env:
RUSTC_WRAPPER: ""
CARGO_INCREMENTAL: "1"
PROPTEST_DISABLE_FAILURE_PERSISTENCE: "1"
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: mutation-diff-plan
path: tmp/mutants
- uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0
with:
version: 2026.6.9
install_args: rust
cache: false
- uses: taiki-e/install-action@41049aa56687c35e0afa74eed4f09cec4f9afabf # v2.85.2
with:
tool: cargo-mutants@27.1.0
fallback: none
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
shared-key: mutants
cache-bin: false
cache-on-failure: false
- name: Mutate changed Rust behavior
timeout-minutes: 45
# Keep this step-local: rust-cache otherwise overrides the job-level
# setting and every changed-line mutant rebuilds the crate from scratch.
env:
CARGO_INCREMENTAL: "1"
run: |
test -s tmp/mutants/pr.diff
cargo mutants \
--workspace \
--all-features \
--in-diff tmp/mutants/pr.diff \
--exclude 'crates/kache-core/**' \
--exclude 'crates/kache-service/**' \
--shard '${{ matrix.index }}/${{ matrix.total }}' \
--sharding round-robin \
--in-place \
--baseline run \
--caught \
--timeout 180 \
--build-timeout 600 \
--annotations github \
--output 'tmp/mutants/diff-${{ matrix.display }}'
- name: Upload changed-line mutation report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: mutation-diff-report-${{ matrix.display }}
path: tmp/mutants/diff-${{ matrix.display }}
retention-days: 14
# Keep the required status context stable while worker topology scales with
# the PR. Any failed or cancelled expected worker fails this aggregate gate.
mutation:
name: Mutation testing
runs-on: ubuntu-latest
needs:
if: always() && github.ref_type != 'tag'
timeout-minutes: 5
steps:
- name: Verify mutation workers
env:
EVENT_NAME: ${{ github.event_name }}
CHANGES_RESULT: ${{ needs.changes.result }}
CODE_CHANGED: ${{ needs.changes.outputs.code }}
PLAN_RESULT: ${{ needs.mutation-plan.result }}
CORE_RESULT: ${{ needs.mutation-core.result }}
SERVICE_RESULT: ${{ needs.mutation-service.result }}
DIFF_RESULT: ${{ needs.mutation-diff.result }}
DIFF_COUNT: ${{ needs.mutation-plan.outputs.count }}
run: |
require_result() {
if [ "$2" != "$3" ]; then
echo "::error::$1 result was '$2', expected '$3'"
exit 1
fi
}
require_result "change detection" "$CHANGES_RESULT" "success"
require_result "mutation plan" "$PLAN_RESULT" "success"
if [ "$CODE_CHANGED" != "true" ] && [ "$CODE_CHANGED" != "false" ]; then
echo "::error::change detection produced invalid code output '$CODE_CHANGED'"
exit 1
fi
if [ "$EVENT_NAME" = "pull_request" ] && [ "$CODE_CHANGED" = "false" ]; then
require_result "core mutation" "$CORE_RESULT" "skipped"
require_result "service mutation" "$SERVICE_RESULT" "skipped"
require_result "changed-line mutation" "$DIFF_RESULT" "skipped"
exit 0
fi
require_result "core mutation" "$CORE_RESULT" "success"
require_result "service mutation" "$SERVICE_RESULT" "success"
if [ "$EVENT_NAME" = "pull_request" ] && [ "$DIFF_COUNT" -gt 0 ]; then
require_result "changed-line mutation" "$DIFF_RESULT" "success"
else
require_result "changed-line mutation" "$DIFF_RESULT" "skipped"
fi
# Bounded model checking for the small, hermetic planner crate. The proof
# command deliberately runs through the locally-built kache wrapper: Kani
# replaces rustc with `kani-compiler`, so this is also the end-to-end
# regression for #656. Unsupported compiler drivers must pass through
# uncached; Kache does not model Kani's verification artifacts.
kani:
name: Kani proofs
runs-on: ubuntu-24.04
needs:
if: github.ref_type != 'tag' && (github.event_name != 'pull_request' || needs.changes.outputs.code == 'true')
timeout-minutes: 30
env:
RUSTC_WRAPPER: ""
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0
with:
version: 2026.6.9
install_args: rust
cache: false
- name: Verify kache-core's stable 1.93 MSRV
run: |
rustup toolchain install 1.93.0 --profile minimal
cargo +1.93.0 test --package kache-core --all-features --locked
cargo +1.93.0 check --package kache-core --no-default-features --locked
- name: Install Kani 0.67.0
run: |
cargo install --locked kani-verifier --version 0.67.0
cargo kani setup
- name: Build the local Kache wrapper
run: cargo build --bin kache
- name: Discover and verify Kani harnesses through Kache
env:
RUSTC_WRAPPER: ${{ github.workspace }}/target/debug/kache
KACHE_CACHE_DIR: ${{ runner.temp }}/kache-kani
KACHE_CONFIG: ${{ runner.temp }}/kache-kani-config.toml
run: |
cd crates/kache-core
cargo kani list --format json
count="$(python3 -c 'import json; print(json.load(open("kani-list.json"))["totals"]["standard-harnesses"])')"
echo "Kani harnesses: $count"
test "$count" -ge 6
output="$RUNNER_TEMP/kani-output.txt"
cargo kani --all-features --output-format terse 2>&1 | tee "$output"
covers="$(grep -c '1 of 1 cover properties satisfied' "$output" || true)"
echo "Satisfied reachability covers: $covers"
test "$covers" -eq "$count"
# 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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
# The -git AUR package's pkgver is only ever visible as AUR metadata, so
# a wrong value publishes cleanly and no build fails on it. Same class of
# release-only logic as the check above, so it is gated in the same job.
- name: AUR -git pkgver tests
run: ./scripts/aur/test-vcs-pkgver.sh
# --- Dependency policy audit ---
# Runs `cargo deny check` (via `just audit`) on every push and non-docs PR:
# RustSec advisories PLUS license / bans / sources policy, matching the
# kunobi-* repos' tooling. Config and documented exceptions live in
# `deny.toml`. `just audit` runs cargo-deny once PER WORKSPACE MEMBER — a
# root-only run graphs only the `kache` bin (+ kache-core) and silently skips
# anything reachable solely through `kache-service` (e.g. the rsa
# RUSTSEC-2023-0071 advisory and surrealdb's BUSL-1.1 license). Kept as its
# own job — not folded into `check` — so a newly-published advisory surfaces
# as a dedicated red check instead of being buried in `just ci`, and so it can
# go red on advisory-DB updates alone without touching any code. Deliberately
# NOT a `release` need: advisories can be published at any time, independent of
# the code being released, so they gate merges (via branch protection) rather
# than blocking a tag build of already-reviewed code.
audit:
name: Dependency audit
runs-on: ubuntu-latest
needs: changes
if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true'
timeout-minutes: 15
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# Install only what `just audit` needs — rust (for cargo), just, and
# cargo-deny — via scoped install_args, so the rest of mise.toml (helm,
# cmake, sccache, llvm-cov) doesn't build on this job. Tool names must
# match the mise.toml keys. cargo-deny ships prebuilt via the aqua backend
# (no from-source compile).
- uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0
with:
# Pin mise CLI — see the `check` job's note (v2026.6.10 regression).
version: 2026.6.9
install_args: rust github:casey/just aqua:EmbarkStudios/cargo-deny
cache: false
- name: cargo deny check
run: just audit
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
# `--all-systems` is the point: without it the check only evaluates the
# runner's own system, which is how the x86_64-darwin breakage in #597
# stayed invisible here. Evaluation is cross-system, so this costs
# seconds and needs no emulation.
- name: Check flake evaluation
run: nix flake check --all-systems --no-build
# Builds every check for THIS system, including `checks.package`, which
# is `pkgs.kache` — so this covers the package build too. Deliberately
# not an explicit list of check attrnames: that silently stops covering
# any check added later.
- name: Build flake checks
run: nix flake check --keep-going --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. A hosted image or dependency download can still stall;
# 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: ""
# Validation routes to disposable GitHub-hosted runners. This is not
# the enforcement boundary: PRs can change this workflow, so private
# runner-group policy must independently deny validation workflows.
- os: macOS
runner: macos-latest
exe_suffix: ""
- os: Windows
runner: windows-latest
exe_suffix: ".exe"
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# Checkout never legitimately runs this long, so fail fast rather than
# waiting out the whole job timeout.
timeout-minutes: 10
- name: Put hosted Windows tools on PATH
if: runner.os == 'Windows'
run: |
if ! command -v nasm >/dev/null 2>&1; then
nasm_dir="/c/Program Files/NASM"
if [ ! -x "$nasm_dir/nasm.exe" ]; then
echo "MISSING on hosted image: $nasm_dir/nasm.exe"
exit 1
fi
cygpath -w "$nasm_dir" >> "$GITHUB_PATH"
fi
# Fail fast with an explicit hosted-image checklist instead of an opaque
# failure deep in the cargo build or a fixture's `make`.
# cc/c++/make -> C/C++ fixtures (harness sets CC="$KACHE cc")
# clang/clang-cl -> fixtures that exercise clang-specific classification
# 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 clang clang-cl; do
if ! command -v "$t" >/dev/null 2>&1; then
echo "MISSING on hosted runner PATH: $t"
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 so every OS
# uses the pinned toolchain from a clean state. 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 can prompt
# on overwrite and hang forever in CI (no stdin). Use a unique path;
# Windows ignores $TMPDIR, so the install step also sets %TEMP%/%TMP%.
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` keeps this a reproducible clean install. The state dirs
# above are fresh per job, so there are no old shims to repair.
# 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@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0
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.
# Prepend the pinned toolchain's own bin dir 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: |
rm -rf tmp/e2e
mkdir -p tmp/e2e
# Bounded retry to absorb transient 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.
attempt=1
while true; do
attempt_out="tmp/e2e/results-attempt-${attempt}.json"
rm -f "$attempt_out"
if ./target/release/kache-scenario${{ matrix.exe_suffix }} \
--kache ./target/release/kache${{ matrix.exe_suffix }} \
--scenarios ./scenarios \
--select suite:e2e \
--select tier:gate \
--deny-missing-tools \
--out "$attempt_out"
then
mv "$attempt_out" tmp/e2e/results.json
break
else
rc=$?
fi
if [ -f "$attempt_out" ]; then
mv "$attempt_out" "tmp/e2e/results-attempt-${attempt}-failed.json"
fi
if [ "$rc" -eq 2 ]; then
echo "::error::e2e harness found missing tools on supported fixtures; not retrying"
exit 2
fi
if [ "$rc" -ne 1 ]; then
echo "::error::e2e harness exited unexpectedly with status $rc; not retrying"
exit "$rc"
fi
if [ "$attempt" -ge 3 ]; then
echo "::error::e2e harness failed after $attempt attempts"
exit "$rc"
fi
echo "::warning::e2e harness attempt $attempt failed; retrying (transient-flake mitigation)"
attempt=$((attempt + 1))
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 \
--deny-missing-tools \
--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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
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 hosted macOS 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)
runs-on: macos-latest
needs: changes
if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true'
# Cargo test is additionally capped at 30m; the job cap catches setup hangs.
timeout-minutes: 40
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# Cap checkout well under the job timeout.
timeout-minutes: 10
# Keep Rust and mise install/cache/state under the per-run temp dir so the
# job always uses the pinned toolchain from a clean state.
- 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-run scratch for mise-action's download/extract, exported
# as MISE_DL_TMPDIR for the install step below.
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@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0
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). Use the same hosted Windows image as the e2e job's Windows arm.
# Blocking: Windows is a first-class supported platform.
test-windows:
name: Test (Windows)
runs-on: windows-latest
needs: changes
if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true'
# A from-scratch `cargo test --workspace` compile has 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
timeout-minutes: 10
- name: Put hosted Windows tools on PATH
run: |
if ! command -v nasm >/dev/null 2>&1; then
nasm_dir="/c/Program Files/NASM"
if [ ! -x "$nasm_dir/nasm.exe" ]; then
echo "MISSING on hosted image: $nasm_dir/nasm.exe"
exit 1
fi
cygpath -w "$nasm_dir" >> "$GITHUB_PATH"
fi
# 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 hosted runner PATH: $t"
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. 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@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0
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:
# IN-ORG MIRROR of zondax/_workflows@7f61511 (v11) — a byte-identical import.
# The macOS legs run on the self-hosted signing runners, whose runner group
# restricts which workflows may use it. GitHub matches that allowlist against
# the workflow a job is DEFINED in (not the caller), and an org-owned group
# cannot name a workflow from another org — so while this pointed at
# `zondax/`, no allowlist entry could authorize the Darwin jobs. They queued
# forever against online, idle runners with no error anywhere; v0.14.0 lost
# ~2h to exactly that. See kunobi-ninja/_workflows for the full rationale.
#
# The signing-runners allowlist pins THIS SHA. Bumping the pin without
# updating that group's selected_workflows breaks releases SILENTLY — move
# both in one change; the mirror's README has the one-liner.
uses: kunobi-ninja/_workflows/.github/workflows/_release-rust.yml@ee8dc66895f75bf79c8fa4c87e06aaeb752e7db5 # v1
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).
#
# `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", "mac-mini"]'
extra_build_env: |
KACHE_VERSION=${{ github.ref_name }}
# Statically link the MSVC CRT into the Windows binaries. Without this the
# .exe dynamically imports VCRUNTIME140.dll + the UCRT and fails with
# STATUS_DLL_NOT_FOUND on any machine lacking the VC++ Redistributable
# (winget's validation VMs, many end-user boxes). Set per-target via env
# rather than .cargo/config.toml on purpose: a repo-root config.toml is
# picked up by every in-repo cargo build, including the e2e fixtures —
# whose relocate phases rebuild the same source at a temp path OUTSIDE the
# repo where the config isn't seen, so crt-static would leak into one half
# of the pair and break cache-determinism. cargo applies each
# CARGO_TARGET_<triple>_RUSTFLAGS only when building that triple, so the
# musl/darwin matrix legs ignore these.
CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS=-C target-feature=+crt-static
CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_RUSTFLAGS=-C target-feature=+crt-static
# --- 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
# Publish the Helm chart to the OCI registry, tag-only.
#
# Until now the chart was never published at all: Flux read
# ./charts/kache-service straight from the git revision, so there was no
# versioned artifact and no way to say which chart a cluster ran. That is the
# same shape as tracking a mutable `:latest` image — which is how a build the
# planner could not start from reached production and sat there for 13 days.
#
# Tag-only for a concrete reason: chart versions are IMMUTABLE in the
# registry, so a push from main would eventually try to overwrite a released
# version and fail. `needs: version-consistency` proves Chart.yaml agrees with
# the tag before anything immutable is pushed.
#
# --- Stable branch (GA tags only) ---
# Advance the moving `stable` branch to the released tag once the gated
# release has fully succeeded, so Nix flake users can follow real releases
# instead of tracking main (#756). Two properties do all the safety work:
# the `needs: [release]` edge means only a tag that survived the full
# validation-and-release pipeline can advance the branch, and the plain
# (non-force) push is fast-forward-only, so an out-of-order or divergent
# tag fails this job closed instead of rewinding the branch. Prereleases
# (any tag containing `-`) never advance it. GITHUB_TOKEN pushes do not
# trigger workflows, so the branch update spawns no runs of its own.
# Bootstrap: the branch is first created manually at the latest GA tag.
stable-branch:
name: Advance stable branch
if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
needs:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
steps:
# The default depth-1 tag checkout marks the release commit as shallow,
# so Git cannot prove that updating an existing stable branch is a
# fast-forward and rejects the push with "fetch first". Keep the plain
# non-force push below as the safety gate, but give it full ancestry.
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Fast-forward stable to the released tag
run: git push origin HEAD:refs/heads/stable
# The chart is named `kache-service`, NOT `kache`, and that is load-bearing:
# `helm push` derives the OCI repository from the chart name, so a chart named
# `kache` lands on zondax/kache — the same repository and tag as the service
# IMAGE. v0.14.1 published the chart, then the image push overwrote the tag
# with a manifest list, and `helm show` started failing with "could not load
# config with mediatype ...helm.config.v1+json". kobe never hit this because
# its chart (kobe) and images (kobe-operator, kobe-sync) already differ.
publish-chart:
name: Publish Helm chart (OCI)
if: startsWith(github.ref, 'refs/tags/v')
needs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: azure/setup-helm@v4
- name: Package & push Helm chart
env:
DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
run: |
helm registry login registry-1.docker.io -u "$DOCKERHUB_USER" -p "$DOCKERHUB_TOKEN"
helm package charts/kache-service
# Explicit name, not kache-*.tgz: that glob would also match a stray
# kache-<version>.tgz and silently push to the image's repository.
helm push kache-service-*.tgz oci://registry-1.docker.io/zondax