kache 0.23.0

Zero-copy, content-addressed build cache for Rust, C/C++ and more, with S3 and shared-filesystem remotes.
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
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
name: CI

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

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

permissions:
  contents: read

# Public repositories use GitHub-hosted validation runners, including forks.
# Private repositories require CI_RUNNER_LINUX/MACOS/WINDOWS JSON selectors.
# Runner-group access must enforce isolation independently of PR-editable YAML.
# See .github/CI.md for repository configuration and publication boundaries.
env:
  CARGO_TERM_COLOR: always
  KACHE_LOG: kache=debug

jobs:
  # Skip compiler work on docs-only PRs (*.md, *.mdx, docs/**); the
  # `version-consistency` job still validates them. Required check names must
  # still be emitted. Matrix names need step-level conditions because a job
  # condition runs before matrix expansion. Fails open on non-PR events or
  # any API error.
  changes:
    name: Detect changes
    runs-on: ${{ fromJSON(!github.event.repository.private && '"ubuntu-latest"' || vars.CI_RUNNER_LINUX) }}
    timeout-minutes: 5
    # Which job groups a pull request needs, from the files it touches. The
    # mapping lives in scripts/ci-changes.py (tested by
    # scripts/test-ci-changes.py in Repository consistency): docs run
    # nothing, bench scripts only Check (Linux), scenarios add the E2E arms,
    # packaging adds the Nix builds, and any other file runs everything.
    # Pushes and tags always run everything, as does a failed file listing.
    # A skipped required job still satisfies branch protection.
    outputs:
      check: ${{ steps.filter.outputs.check }}
      tests: ${{ steps.filter.outputs.tests }}
      e2e: ${{ steps.filter.outputs.e2e }}
      nix: ${{ steps.filter.outputs.nix }}
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          sparse-checkout: scripts/ci-changes.py
          sparse-checkout-cone-mode: false
      - id: filter
        env:
          GH_TOKEN: ${{ github.token }}
          PR: ${{ github.event.pull_request.number }}
        run: |
          set -o pipefail
          if [ "${{ github.event_name }}" != "pull_request" ]; then
            python3 scripts/ci-changes.py --all >> "$GITHUB_OUTPUT"; exit 0
          fi
          files=$(gh api --paginate "repos/${{ github.repository }}/pulls/$PR/files" \
            --jq '.[].filename') || { python3 scripts/ci-changes.py --all >> "$GITHUB_OUTPUT"; exit 0; }
          printf '%s\n' "$files" | python3 scripts/ci-changes.py | tee -a "$GITHUB_OUTPUT"

  check:
    # A single Linux job keeps its required name even when it is skipped.
    name: Check (Linux)
    runs-on: ${{ fromJSON(!github.event.repository.private && '"ubuntu-latest"' || vars.CI_RUNNER_LINUX) }}
    needs: changes
    if: github.event_name != 'pull_request' || needs.changes.outputs.check == '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
    # `just ci` needs Linux (docker buildx, helm and tarpaulin). macOS cargo
    # tests live in `cargo test (macOS)`; the Nix package build is
    # `Nix package (macOS)`.
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
      # mise installs everything in mise.toml (just, helm, sccache,
      # cargo-llvm-cov) plus rust, which it reads from rust-toolchain.toml:
      # channel, rustfmt and clippy.
      - 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
      # Held at a pre-runtime-dir revision: newer kache-action exports
      # KACHE_RUNTIME_DIR into the job env. The integration tests now pin
      # KACHE_RUNTIME_DIR to their own cache dir and clear KACHE_SOCKET_PATH,
      # KACHE_EVENT_ROOT, and KACHE_ACTIVE for every child they spawn
      # (tests/common/mod.rs, hermetic_command), so events.jsonl and the
      # daemon socket land where the tests look. In-process unit tests that
      # call Config::load still see the job env, so the pin stays until they
      # are checked.
      # Use the latest stable release to accelerate ordinary verification.
      # E2E, mutation, Kani, and platform jobs below still build this commit
      # directly. Add `[no-kache]` to a PR title to diagnose cache-related CI.
      - id: stable_kache
        if: github.event_name != 'pull_request' || !contains(github.event.pull_request.title, '[no-kache]')
        uses: kunobi-ninja/kache-action@49398d37113c616fdb61be434cb497e3c2c8f3e6 # v1
        with:
          github-cache: "true"
          cache-executables: "false"
          # The old key contains no compiler outputs because Justfile disabled
          # the wrapper. Start a new immutable Actions cache with real entries.
          cache-key-prefix: kache-self-build
      - 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.
        env:
          KACHE_SELF_HOST: ${{ steps.stable_kache.outcome == 'success' && '1' || '0' }}
        run: env -u KACHE_VERSION ./scripts/with-test-resources.sh just ci
        timeout-minutes: 30
      - name: Check crates.io package metadata
        # Cargo stages workspace dependencies together, including crates whose
        # first version has not reached crates.io yet.
        run: cargo package --locked -p kache-core -p kache-format -p kache-fs -p kache-store -p kache
        env:
          RUSTC_WRAPPER: ""
      - name: Check coverage scope and 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: |
          just coverage-scope-check
          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: Set up Node for Kartero
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
        with:
          node-version: 24
      - name: Export coverage telemetry
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        run: |
          npx --yes @kunobi/kartero@0.3.0 coverage \
            --input tmp/llvm-cov/coverage.json \
            --format llvm-cov-json \
            --output tmp/coverage-telemetry
          npx --yes @kunobi/kartero@0.3.0 validate \
            --input tmp/coverage-telemetry
        env:
          NPM_CONFIG_CACHE: ${{ runner.temp }}/kartero-npm-cache
      - name: Upload coverage telemetry
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: telemetry-otlp-v1-coverage
          path: |
            tmp/coverage-telemetry/metrics.otlp.json
            tmp/coverage-telemetry/schema_version
          retention-days: 3
          if-no-files-found: error
      - 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: ${{ fromJSON(!github.event.repository.private && '"ubuntu-latest"' || vars.CI_RUNNER_LINUX) }}
    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.tests == 'true'
        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          fetch-depth: 0
      - if: github.event_name == 'pull_request' && needs.changes.outputs.tests == '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.tests == '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.tests == '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: ${{ fromJSON(!github.event.repository.private && '"ubuntu-latest"' || vars.CI_RUNNER_LINUX) }}
    needs: [changes, check]
    if: github.ref_type != 'tag' && (github.event_name != 'pull_request' || needs.changes.outputs.tests == '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: ${{ fromJSON(!github.event.repository.private && '"ubuntu-latest"' || vars.CI_RUNNER_LINUX) }}
    needs: [changes, check]
    if: github.ref_type != 'tag' && (github.event_name != 'pull_request' || needs.changes.outputs.tests == 'true')
    timeout-minutes: 35
    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
        # The mutation run itself takes about ten minutes; the old 15-minute
        # cap left too little for a slow shared runner. One that took half as
        # long again killed the step at 15m51s AFTER all 74 mutants had been
        # accounted for (50 caught, 24 unviable, none missed) — a red check
        # reporting nothing but the runner's speed.
        #
        # Hangs are already caught closer in: `--timeout 180` bounds each
        # mutant's test run, so this cap only has to bound the whole lane.
        timeout-minutes: 30
        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: ${{ fromJSON(!github.event.repository.private && '"ubuntu-latest"' || vars.CI_RUNNER_LINUX) }}
    needs: [check, mutation-plan]
    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
          # Shard 0 checks the unmutated baseline for the whole matrix.
          # The proof-only crate is compiled and exercised by the Kani job.
          ./scripts/with-test-resources.sh cargo mutants \
            --workspace \
            --all-features \
            --in-diff tmp/mutants/pr.diff \
            --exclude 'crates/kache-core/**' \
            --exclude 'crates/kache-service/**' \
            --exclude 'crates/kache-proofs/**' \
            --shard '${{ matrix.index }}/${{ matrix.total }}' \
            --sharding round-robin \
            --in-place \
            --baseline "${{ matrix.index == 0 && 'run' || 'skip' }}" \
            --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: ${{ fromJSON(!github.event.repository.private && '"ubuntu-latest"' || vars.CI_RUNNER_LINUX) }}
    needs: [changes, mutation-plan, mutation-core, mutation-service, mutation-diff]
    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.tests }}
          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 and repository-only
  # production-range harness 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 artifacts.
  kani:
    name: Kani proofs
    runs-on: ${{ fromJSON(!github.event.repository.private && '"ubuntu-24.04"' || vars.CI_RUNNER_LINUX) }}
    needs: [changes, check]
    if: github.ref_type != 'tag' && (github.event_name != 'pull_request' || needs.changes.outputs.tests == '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
          cargo +1.93.0 check --package kache-proofs --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: |
          cargo kani --package kache-core --package kache-proofs --all-features 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 8
          output="$RUNNER_TEMP/kani-output.txt"
          cargo kani --package kache-core --package kache-proofs --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 repository checks with one checkout (no nix / no cargo / no network).
  # The version gate runs on every push and PR, 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: Repository consistency
    runs-on: ${{ fromJSON(!github.event.repository.private && '"ubuntu-latest"' || vars.CI_RUNNER_LINUX) }}
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
      - name: Check routes and source parity
        run: bash scripts/check-docs.sh
      - name: Set up Node for workflow policy checks
        uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
        with:
          node-version: 24
      - name: Test workflow policy
        working-directory: .github/tests
        run: |
          npm ci --ignore-scripts --no-audit --no-fund
          npm test
        env:
          NPM_CONFIG_CACHE: ${{ runner.temp }}/workflow-tests-npm-cache
      - name: Test version consistency gate
        run: python3 scripts/test-version-consistency.py
      - name: Test crates.io publish discovery
        run: python3 scripts/test-crates-io.py
      - 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

      # Validate benchmark admission, aggregation, and event capture using
      # fixtures. Real contention timings run separately on a quiet host.
      - name: Perf gate comparison tests
        run: |
          python3 scripts/test-bench-short.py
          python3 scripts/test-bench-contention.py

      # The path-to-job-group mapping Detect changes applies to pull requests.
      - name: Change detection tests
        run: python3 scripts/test-ci-changes.py

  # --- 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). 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: ${{ fromJSON(!github.event.repository.private && '"ubuntu-latest"' || vars.CI_RUNNER_LINUX) }}
    needs: changes
    if: github.event_name != 'pull_request' || needs.changes.outputs.tests == '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

  # --- Real CUDA toolkit validation ---
  # Installs the real CUDA toolkit and runs the nvcc integration tests,
  # which skip gracefully without nvcc. No GPU needed: probing
  # (`--version`, `--dryrun` host discovery), dependency queries (`-M`),
  # and `-c` compiles are all host-side. This is the only lane that
  # exercises the nvcc adapter against genuine driver behavior (#1024).
  # The distro codename is detected at runtime (not pinned) so the job
  # survives `ubuntu-latest` image flips; the adapter itself is
  # version-agnostic, CUDA is pinned to the mature 12.8 branch.
  cuda-toolkit:
    name: CUDA toolkit (nvcc)
    runs-on: ${{ fromJSON(!github.event.repository.private && '"ubuntu-latest"' || vars.CI_RUNNER_LINUX) }}
    needs: changes
    if: github.event_name != 'pull_request' || needs.changes.outputs.tests == 'true'
    timeout-minutes: 40
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
      - 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
          cache: false
      - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
        with:
          shared-key: cuda
          cache-bin: false
          cache-on-failure: false
      # Pinned CUDA 12.8: mature, version-agnostic for the adapter, and new
      # enough to cover every flag shape it models. `cuda-toolkit` (not the
      # `cuda` metapackage) so no kernel driver is installed.
      - name: Install CUDA toolkit
        run: |
          set -euo pipefail
          . /etc/os-release
          distro="ubuntu${VERSION_ID//./}"
          echo "installing CUDA toolkit for $distro"
          wget "https://developer.download.nvidia.com/compute/cuda/repos/${distro}/x86_64/cuda-keyring_1.1-1_all.deb"
          sudo dpkg -i cuda-keyring_1.1-1_all.deb
          sudo apt-get update
          sudo DEBIAN_FRONTEND=noninteractive apt-get install -y cuda-toolkit-12-8
          # GITHUB_PATH only takes effect in later steps: export for the
          # verification below as well.
          export PATH="/usr/local/cuda-12.8/bin:$PATH"
          echo "/usr/local/cuda-12.8/bin" >> "$GITHUB_PATH"
          nvcc --version
          gcc --version
      # Ground truth for the adapter: dump the exact driver outputs the
      # prober and the dependency closure parse (host discovery reads
      # --dryrun, the closure reads -M stdout). Kept permanently —
      # driver output formats drift across toolkit releases, and this
      # log is where that drift first shows up.
      - name: Dump nvcc probe inputs
        run: |
          set -euo pipefail
          mkdir -p /tmp/nvcc-probe-sample
          cd /tmp/nvcc-probe-sample
          printf '__global__ void kernel(void) {}\n' > kernel.cu
          echo '--- nvcc --dryrun (stdout) ---'
          nvcc --dryrun -c kernel.cu -o kernel.o || echo "dryrun exit: $?"
          echo '--- nvcc --dryrun (stderr) ---'
          nvcc --dryrun -c kernel.cu -o kernel.o 2>&1 1>/dev/null || echo "dryrun exit: $?"
          echo '--- nvcc -M ---'
          nvcc -M kernel.cu || echo "-M exit: $?"
      - name: Run nvcc integration tests
        run: cargo test --test nvcc_cuda_toolchain_test

  # Linux evaluates every system (catches #597-style eval breakage) and builds
  # the Linux package. macOS actually *builds* the Darwin package: stdenv sets
  # SDKROOT to the Apple SDK store path and puts xcbuild's xcrun on PATH, which
  # `cargo test` on a hosted Mac never sees. That is how the SDK-identity
  # checkPhase failure in nixpkgs#561411 stayed invisible here.
  nix-package:
    name: Nix package (${{ matrix.os }})
    runs-on: ${{ matrix.runner }}
    needs: changes
    if: github.event_name != 'pull_request' || needs.changes.outputs.nix == 'true'
    timeout-minutes: 40
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: Linux
            runner: ${{ fromJSON(!github.event.repository.private && '"ubuntu-latest"' || vars.CI_RUNNER_LINUX) }}
          - os: macOS
            runner: ${{ fromJSON(!github.event.repository.private && '"macos-latest"' || vars.CI_RUNNER_MACOS) }}
    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. One OS is enough.
      - name: Check flake evaluation
        if: matrix.os == 'Linux'
        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

  # --- Copy-on-write filesystem coverage ---
  # Every hosted runner is ext4, where `try_reflink` always fails and the
  # `fs::copy` fallback preserves permissions. That makes an entire class of bug
  # unobservable in CI: the store's reflink ingest creates its staging temp with
  # `File::create` (umask mode, no +x), so a mode-losing ingest looks perfectly
  # healthy here and only breaks on btrfs / XFS-with-reflink / ZFS >= 2.2 /
  # bcachefs. macOS clonefile preserves mode, so APFS is not in that set.
  # #648 fixed the restore half of that and #822 silently reverted it through
  # the ingest half; both shipped green. This lane exists so a third round can't.
  cow-filesystem:
    name: CoW filesystem (btrfs)
    runs-on: ${{ fromJSON(!github.event.repository.private && '"ubuntu-latest"' || vars.CI_RUNNER_LINUX) }}
    needs: changes
    if: github.event_name != 'pull_request' || needs.changes.outputs.tests == 'true'
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
      - 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
          cache: false
      - name: Mount a loopback btrfs volume
        run: |
          set -euo pipefail
          sudo apt-get update
          sudo apt-get install -y btrfs-progs
          # Sparse: the image only occupies what the tests actually write.
          truncate -s 20G "$RUNNER_TEMP/cow.img"
          mkfs.btrfs -q "$RUNNER_TEMP/cow.img"
          sudo mkdir -p /mnt/cow
          sudo mount -o loop "$RUNNER_TEMP/cow.img" /mnt/cow
          sudo chown "$(id -u):$(id -g)" /mnt/cow
          mkdir -p /mnt/cow/scratch /mnt/cow/tmp
      # A mount that silently cannot clone makes this whole job a slower
      # duplicate of the ext4 lanes — the exact blind spot it is here to close.
      # `--reflink=always` fails rather than falling back to a copy, so this
      # proves FICLONE works before any test relies on it.
      - name: Prove reflink works on the mount
        run: |
          set -euo pipefail
          head -c 1M /dev/urandom > /mnt/cow/probe-src
          cp --reflink=always /mnt/cow/probe-src /mnt/cow/probe-dst
          rm -f /mnt/cow/probe-src /mnt/cow/probe-dst
      # `KACHE_TEST_SCRATCH_DIR` moves the integration fixtures' cache and target
      # dirs onto btrfs; `TMPDIR` does the same for `tempfile::tempdir()`, which
      # is where the store and link unit tests do their ingest. The build tree
      # itself stays on the runner disk — it is the artifacts under test that
      # need the CoW filesystem, not the compile.
      #
      # No kache-action here: this lane must exercise the wrapper built from
      # this PR, not a released one, and dogfooding would put a second store in
      # play.
      - name: Run the filesystem-sensitive suites on btrfs
        env:
          KACHE_TEST_SCRATCH_DIR: /mnt/cow/scratch
          TMPDIR: /mnt/cow/tmp
          RUSTC_WRAPPER: ""
        run: |
          set -euo pipefail
          cargo test -p kache --test custom_harness_test
          cargo test -p kache --test integration_test test_rust_restored_outputs_allow_build_without_wrapper
          cargo test -p kache-fs --all-features
          cargo test -p kache-store --lib
          cargo test -p kache --bins -- store:: wrapper::tests::materialize_cached_artifact
      - name: Report mount usage
        if: always()
        run: df -h /mnt/cow || true

  # --- 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
    # Expand every platform name even when docs-only changes skip the work.
    env:
      RUN_E2E: ${{ github.event_name != 'pull_request' || needs.changes.outputs.e2e == '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: ${{ fromJSON(!github.event.repository.private && '"ubuntu-latest"' || vars.CI_RUNNER_LINUX) }}
            exe_suffix: ""
          # Public validation stays hosted. Private runner groups must grant
          # access only to the intended repositories and workflow trust level.
          - os: macOS
            runner: ${{ fromJSON(!github.event.repository.private && '"macos-latest"' || vars.CI_RUNNER_MACOS) }}
            exe_suffix: ""
          - os: Windows
            runner: ${{ fromJSON(!github.event.repository.private && '"windows-latest"' || vars.CI_RUNNER_WINDOWS) }}
            exe_suffix: ".exe"
    steps:
      - name: Report skipped E2E work
        if: env.RUN_E2E != 'true'
        run: echo 'Documentation-only change; E2E scenarios skipped.' >> "$GITHUB_STEP_SUMMARY"
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        if: env.RUN_E2E == 'true'
        # Checkout never legitimately runs this long, so fail fast rather than
        # waiting out the whole job timeout.
        timeout-minutes: 10
      - name: Install cross gcc (riscv64 + armv7)
        if: env.RUN_E2E == 'true' && runner.os == 'Linux'
        run: |
          sudo apt-get update
          sudo apt-get install -y gcc-riscv64-linux-gnu gcc-arm-linux-gnueabihf
      - name: Put hosted Windows tools on PATH
        if: env.RUN_E2E == 'true' && (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: env.RUN_E2E == 'true' && (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
        if: env.RUN_E2E == 'true'
        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
        if: env.RUN_E2E == 'true'
        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: env.RUN_E2E == 'true' && (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: env.RUN_E2E == 'true' && (runner.os == 'Windows')
        run: rustup target add x86_64-pc-windows-msvc x86_64-pc-windows-gnu
      - name: Build kache + e2e harness (release)
        if: env.RUN_E2E == 'true'
        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)
        if: env.RUN_E2E == 'true'
        # 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: env.RUN_E2E == 'true' && (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: env.RUN_E2E == 'true' && (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: env.RUN_E2E == 'true' && (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: env.RUN_E2E == 'true' && (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 cargo test ---
  # The Linux `check` job runs the full `just ci` (coverage + docker + helm);
  # replicating that on macOS isn't worth it. This is `cargo test` + clippy on
  # a hosted Mac with Apple's toolchain, not the Nix package (that's
  # `Nix package (macOS)`). Catches OS-specific regressions — e.g. the
  # daemon's Unix-socket EOF behaviour, which once hung the suite on macOS
  # while passing on Linux — before merge.
  # Blocking: macOS is a first-class supported platform.
  cargo-macos:
    name: cargo test (macOS)
    runs-on: ${{ fromJSON(!github.event.repository.private && '"macos-latest"' || vars.CI_RUNNER_MACOS) }}
    needs: changes
    if: github.event_name != 'pull_request' || needs.changes.outputs.tests == '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) would win. Prepend the pinned
      # toolchain's own bin dir (the rust-toolchain.toml 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
          ./scripts/with-test-resources.sh cargo test --workspace
        timeout-minutes: 30
        env:
          RUSTC_WRAPPER: ""
          # Blank wrapper above, so the binary cargo built for the tests is
          # the bootstrap build; skip the second one (see tests/common).
          KACHE_TEST_USE_CARGO_BIN_EXE: "1"
      # 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 cargo test ---
  # Mirrors `cargo test (macOS)`: the Linux `check` job runs the full `just ci`
  # and macOS has its own cargo pass. Nothing used to run `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.
  cargo-windows:
    name: cargo test (Windows)
    runs-on: ${{ fromJSON(!github.event.repository.private && '"windows-latest"' || vars.CI_RUNNER_WINDOWS) }}
    needs: changes
    if: github.event_name != 'pull_request' || needs.changes.outputs.tests == 'true'
    # A from-scratch workspace test compile has exceeded the previous 30m step
    # cap. Job cap covers filesystem tests (5m), workspace tests (45m),
    # doctests (15m), clippy (30m), and 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 cargo-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.
      # Catch native filesystem failures before compiling the full workspace.
      - name: Test filesystem operations (all features)
        run: cargo test -p kache-fs --all-features
        timeout-minutes: 5
        env:
          RUSTC_WRAPPER: ""
      - name: Install cargo-nextest
        uses: taiki-e/install-action@41049aa56687c35e0afa74eed4f09cec4f9afabf # v2.85.2
        with:
          tool: nextest
      # nextest runs the test binaries concurrently (cargo test runs them one
      # after another) and one process per test; it does not run doctests, so
      # those follow separately. KACHE_TEST_USE_CARGO_BIN_EXE is safe here
      # because RUSTC_WRAPPER is blank: the binary cargo built for the tests is
      # exactly what the bootstrap build would produce (see tests/common).
      - name: cargo nextest (workspace)
        run: |
          rustc --version && cargo --version
          cargo nextest run --workspace --profile ci
        timeout-minutes: 45
        env:
          RUSTC_WRAPPER: ""
          KACHE_TEST_USE_CARGO_BIN_EXE: "1"
      - name: cargo test --doc (workspace)
        run: cargo test --workspace --doc
        timeout-minutes: 15
        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: ""

  # Branch protection still requires the pre-rename check names. These jobs
  # report those contexts from the renamed jobs. Drop once protection lists
  # Nix package (Linux), Nix package (macOS), cargo test (macOS), and
  # cargo test (Windows).
  nix-package-alias:
    name: Nix package
    needs: nix-package
    if: always() && needs.nix-package.result != 'skipped'
    runs-on: ${{ fromJSON(!github.event.repository.private && '"ubuntu-latest"' || vars.CI_RUNNER_LINUX) }}
    timeout-minutes: 5
    steps:
      - name: Mirror nix-package
        run: test "${{ needs.nix-package.result }}" = "success"

  cargo-macos-alias:
    name: Test (macOS)
    needs: cargo-macos
    if: always() && needs.cargo-macos.result != 'skipped'
    runs-on: ${{ fromJSON(!github.event.repository.private && '"ubuntu-latest"' || vars.CI_RUNNER_LINUX) }}
    timeout-minutes: 5
    steps:
      - name: Mirror cargo-macos
        run: test "${{ needs.cargo-macos.result }}" = "success"

  cargo-windows-alias:
    name: Test (Windows)
    needs: cargo-windows
    if: always() && needs.cargo-windows.result != 'skipped'
    runs-on: ${{ fromJSON(!github.event.repository.private && '"ubuntu-latest"' || vars.CI_RUNNER_LINUX) }}
    timeout-minutes: 5
    steps:
      - name: Mirror cargo-windows
        run: test "${{ needs.cargo-windows.result }}" = "success"

  # --- 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: github.repository == 'kunobi-ninja/kache' && 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,
        cow-filesystem,
        nix-package,
        e2e,
        cargo-macos,
        cargo-windows,
        version-consistency,
      ]
    # 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@b43559c8bc0fc6fee5f22e48989e350df823d4db # v2
    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
  # ./packaging/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: github.repository == 'kunobi-ninja/kache' && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
    needs: [release]
    runs-on: ${{ fromJSON(!github.event.repository.private && '"ubuntu-latest"' || vars.CI_RUNNER_LINUX) }}
    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: github.repository == 'kunobi-ninja/kache' && startsWith(github.ref, 'refs/tags/v')
    needs: [version-consistency]
    runs-on: ${{ fromJSON(!github.event.repository.private && '"ubuntu-latest"' || vars.CI_RUNNER_LINUX) }}
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

      - uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1

      - 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 packaging/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