-
Notifications
You must be signed in to change notification settings - Fork 0
1321 lines (1304 loc) · 80.5 KB
/
Copy pathci.yml
File metadata and controls
1321 lines (1304 loc) · 80.5 KB
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
name: CI
# CI minutes cost money; an agent's local execution does not. So CI runs as
# rarely as correctness allows:
# - Only on NON-DRAFT PRs. Draft = still being verified locally; no CI.
# - Not on push to main: main only advances by fast-forward to a commit that
# already passed these exact checks on its PR, so re-running would be pure
# waste.
# The jobs run the SAME `mise run` tasks an agent runs locally, so a green
# `mise run verify` locally means these will pass.
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
# Least privilege: these jobs only read the repo. Third-party actions are pinned
# to a full commit SHA (a moving tag is a supply-chain hole an attacker can move).
permissions:
contents: read
# Make the per-job `install_args` lists below actually binding.
#
# mise auto-installs missing tools when a task runs (task.run_auto_install) or
# when `mise exec` needs one (exec_auto_install), both default true. With those
# on, narrowing the install list moves the cost instead of removing it: measured
# on run 31153705833, the `cross` job installed rust in 13s and then rebuilt the
# whole toolchain — serena's 72 Python packages, node, zig, prettier — inside
# `mise run cross-check`, which is why its work step went 21s -> 32s while the
# install step shrank by the same amount. Net zero, plus the overhead.
#
# Off, a tool a job did not ask for is a hard "command not found" instead of a
# silent re-download. That is the intended failure: `mise run ci-tools-check`
# keeps the names honest, and this keeps the lists honest.
env:
MISE_TASK_RUN_AUTO_INSTALL: "false"
MISE_EXEC_AUTO_INSTALL: "false"
# `[settings] lockfile` is false so that no install can write the tracked
# lockfile (CLOUD-223) — but that setting governs the whole lockfile feature,
# not just the write, and mise-action installs with `--locked` whenever a
# lockfile exists. Left alone, every job here dies on "locked mode requires
# lockfile to be enabled". Turning it back on for CI is the right boundary and
# not a concession: a locked install is exactly what a runner should do, and a
# write here lands in a disposable checkout that is never committed from. The
# property being protected is that a DEVELOPER's clone is never dirtied.
# Every workflow using mise-action must set this; `mise run lock-complete`
# fails if one does not.
MISE_LOCKFILE: "true"
jobs:
ci:
name: ci
if: ${{ github.event.pull_request.draft == false }}
# ARM64, AND THE WRITER MOVES WITH THE READERS (CLOUD-1416). rust-cache puts
# `runnerOS-runnerArch` at `config.ts:93`, BEFORE the restore prefix is
# assigned at `:133` — so a reader on arm64 whose family's writer is still on
# x64 has no warm entry at all and is dramatically worse off than before.
# `ci`, `bats`, `commit-lint` and `cache-warm-linux` are the family's arm64
# set and move together.
#
# `batten-check` IS THE ONE READER LEFT BEHIND, and it is left behind by a
# measurement rather than by preference: it is the only job here that runs
# `batten enforce`, whose `no-secrets` rule needs the pinned `ripsecrets`
# scanner, and no ripsecrets release has ever published a linux-aarch64
# artifact. Its own header carries the failing run and the reasoning. It pays
# a cold build for that and it costs nothing, because it runs in parallel
# with this job rather than in series with it.
#
# WHAT DELIBERATELY DOES NOT MOVE. `perf` builds both arms and compares them,
# so an architecture change invalidates comparability and its base entry.
# `cross`, `semver` and `darwin-link` stay on x64 because `cargo-zigbuild` is
# the one tool pinned through the unchecksummed `ubi:` backend (CLOUD-281),
# making it the least verified on this architecture, and this change is
# already dense. Windows is unaffected.
#
# THE USUAL OBJECTION DOES NOT APPLY: `lock-complete` already requires every
# locked tool to install on linux-arm64 as one of its three mandatory
# platforms, so the tool surface is proven here by a standing gate and a
# failure would be a `lock-complete` finding rather than a surprise.
# `darwin-link` additionally proves the cross-compilation path works from a
# Linux host. The runners are free and unlimited on public repositories,
# which this became on 2026-09-03, and carry identical specifications to
# their x64 counterparts — 4 CPUs, 16 GB RAM, 14 GB storage.
#
# AND THE SPEED CLAIM IS UNMEASURED — CORRECTED RATHER THAN QUIETLY DROPPED.
# This comment used to justify the stale budget below with "the expected
# direction is faster." Nothing in this repository has ever measured that.
# CLOUD-1416's title carries "up to 40% faster", a figure imported from
# outside and never reproduced here, and every number PR #847 took afterwards
# measures the CACHE-KEY fix instead: `mise run ci` went 498s cold to 315s
# warm on the SAME architecture, which is build-versus-no-build and swamps
# any per-core difference. Two lines above say these runners "carry identical
# specifications to their x64 counterparts", which is not a reason to expect
# a speedup either. `arch-probe.yml` is the matched cold pair that decides
# it; until it has run, arm64's benefit here is UNKNOWN rather than expected.
#
# THE BUDGET BELOW IS STILL AN x64 READING and is owed a re-derivation from
# the first arm64 runs, recorded on CLOUD-1420. It is carried because an
# x64-derived ceiling over an unknown direction is a backstop of unknown
# tightness — which is a reason to re-derive it, never a reason to relax
# about it.
runs-on: ubuntu-24.04-arm
# RE-MEASURED IN THE SAME PASS AS THE POLE (CLOUD-386). The 2026-08-14 reading
# declared `p95=701s`; `mise run timeout-drift` over the last 25 successful runs
# now reports 1730s — stale by 2.4x, with only the x3 multiplier keeping the
# ceiling from firing on a healthy run. `bats-invocation` holds this date to the
# sweep's, so moving the pole without re-measuring here is a refusal rather than
# a number nobody revisits.
timeout-minutes: 87 # budget: p95=1730s x3 measured=2026-08-28
# `actions: write` is here for one call and one only: cancelling THIS run
# when the landing lease does not authorise this branch (CLOUD-420). A job
# that could not cancel itself would have to fail instead, and a failed job
# makes the run conclude `failure` rather than `cancelled` — which reds
# `final` and makes `land` re-draft a healthy PR.
permissions:
contents: read
actions: write
steps:
# CLOUD-420: THE LANDING LEASE, ENFORCED WHERE THE MONEY IS SPENT.
# First step, before any checkout or toolchain install, because a run this
# branch is not authorised to make should cost the rounding rather than a
# matrix. The body runs `batten lease guard`, installed from `main` and
# pinned to trunk's version rather than to this head's, so a clone
# carrying stale rules cannot dodge the predicate by carrying a stale copy
# of it. Every justification lives on `run_lease_guard`. THREE DIFFERENT
# FAILURES FAIL OPEN below, one `|| exit 0` each: a binary that will not
# download, a policy that will not fetch, and a guard that will not run.
# That is the whole posture — waving one matrix through costs one matrix,
# while a precondition that cannot reach its own inputs would stop the
# fleet. What keeps those three from reading as *ran and allowed* is
# trunk's installer refusing a binary that does not carry the verb
# (`BATTEN_REQUIRE`); until that installer IS trunk's, this step is
# vacuous by construction rather than by accident.
#
# AND IT WAS VACUOUS FOR A SECOND REASON THE PARAGRAPH ABOVE HID (review of
# #848). `$RUNNER_TEMP/batten-bin` is not on PATH, and `install.sh`'s
# off-PATH refusal is a `die 1` that fires BEFORE the `BATTEN_REQUIRE`
# check — so the `|| exit 0` ended the step at the install and the guard
# never ran, at any of the sites, whatever the installer's provenance. The
# bootstrap above is real and was not the reason; it just described the
# same silence convincingly enough that nobody looked further.
#
# `BATTEN_ALLOW_OFF_PATH=1` is the opt-out for exactly this shape and the
# binary is invoked by absolute path two lines down, so nothing here
# resolves `batten` by name and the refusal is protecting a caller that
# does not exist. Installing onto PATH instead would put a trunk-pinned
# binary ahead of the checkout's own for every later step in the job.
- name: Landing lease precondition
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
LEASE_HEAD_REF: ${{ github.head_ref }}
# The HEAD sha, never `github.sha`: on a pull_request event that is the
# merge commit, whose tree is trunk's wherever this head did not touch
# it — and the staleness read asks about the head's own landing paths.
LEASE_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
LEASE_RUN_ID: ${{ github.run_id }}
run: |
# `|| exit 0` ON EVERY LINE, and the reason outlives the shell it was
# written for (CLOUD-420). A step that reds makes the RUN's conclusion
# `failure` rather than `cancelled`; `final` then runs under
# `!cancelled()`, fails its `needs:` assertion, and `land` re-drafts a
# healthy PR — fleet-wide, from one bad response. `batten lease guard`
# promises it never exits non-zero; these are what make that true at the
# call site too, for the cases the promise cannot cover: a binary that
# will not download and one that will not run are different failures
# from one that ran and decided.
#
# THE VERSION IS TRUNK'S, NEVER THIS HEAD'S. The installer comes from
# trunk and resolves the version from trunk's own manifest, which is the
# property the fetched-script design protected by reading its logic from
# trunk — a head cannot pin an older guard for itself.
installer=$(gh api -H "Accept: application/vnd.github.raw" \
"repos/$GH_REPO/contents/install.sh?ref=main") || exit 0
printf '%s\n' "$installer" | \
BATTEN_VERSION_FROM_REF=main BATTEN_INSTALL_DIR="$RUNNER_TEMP/batten-bin" \
BATTEN_ALLOW_OFF_PATH=1 BATTEN_REQUIRE="lease guard" sh || exit 0
# THE POLICY IS TRUNK'S TOO, AND IT HAD TO BE FETCHED. This step runs
# BEFORE any checkout, so the directory the guard stands in is empty and
# `config::load` found nothing — `[lease] landing_paths` read as *no
# paths declared* and the staleness half failed open on every run, which
# is the exact silence the row was written to end. Fetched from `main`
# for the installer's own reason, one line up: a head must not pin the
# policy it is judged by, and the only tree a checkout would offer here
# is the pull request's own.
mkdir -p "$RUNNER_TEMP/batten-config" || exit 0
gh api -H "Accept: application/vnd.github.raw" \
"repos/$GH_REPO/contents/batten.toml?ref=main" \
>"$RUNNER_TEMP/batten-config/batten.toml" || exit 0
"$RUNNER_TEMP/batten-bin/batten" --config-in "$RUNNER_TEMP/batten-config" \
lease guard \
"$LEASE_HEAD_SHA" "$LEASE_HEAD_REF" "$LEASE_RUN_ID" || exit 0
# RECLAIM RUNNER DISK, BEFORE ANYTHING WRITES ITS OWN GIGABYTES.
#
# Measured on this tree: a full `mise run ci` leaves `target/debug` at
# 14 GB (`target/` at 21 GB), and every `crates/batten/tests/*.rs` is a
# separately linked binary, so that grows with the test surface. Twice, on
# the same head, this job died ~20 minutes into `mise run ci` with the step
# still `in_progress`, no annotations, and NO LOG ARCHIVE AT ALL — a 404 on
# the job's logs is what a runner that cannot write looks like. The same
# task with `BATTEN_STEP_RECEIPT_BYPASS=1` (so nothing answers from a
# receipt, as under CI) exits 0 locally in 988s, and this workflow passes
# on other branches, so the failure is this tree's size rather than the
# gate. Reclaiming the preinstalled toolchains this repository never uses
# is the cheapest thing that could make that true.
#
# SECOND, NEVER FIRST. `ci-local-parity` property 7 asserts the first step
# of a `needs:`-less job is the landing lease, matched by name — and the
# lease belongs first on its own merits: a run this branch is not
# authorised to make should not spend even this.
#
# A BARE STEP, NEVER A `mise` TASK. Property 3 requires every `mise run
# <task>` a workflow names to be one `mise run verify` also runs, so a
# `mise run free-disk` would oblige a contributor's laptop to delete
# `/usr/local/lib/android`. The honest shape names no task, exactly as the
# lease step and the slow-tier decision below do.
#
# `|| true` because this is a mitigation and never a gate: a path absent
# from some future runner image must not red a required check.
#
# The summary writes are the diagnostic that SURVIVES. A job log is lost
# when the runner dies; a step summary is uploaded when its step ends, so
# the reading below is durable evidence even if the job dies twenty minutes
# later — which is what makes the next run a measurement rather than
# another guess.
- name: Reclaim runner disk before the build
run: |
{
echo "### disk before"
df -h /
} >>"$GITHUB_STEP_SUMMARY"
sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc \
/usr/local/share/boost /usr/share/swift || true
{
echo "### disk after"
df -h /
} >>"$GITHUB_STEP_SUMMARY"
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
# tests/bats — the shell test runner. Without it `mise run test:bats`
# has no bats to run.
submodules: true
# CLOUD-789: THE TRAILER HALF NEEDS THIS BRANCH'S OWN COMMITS.
# `config-lint` admits a groomed weakening only when a `Weakens:`
# trailer on a commit in `origin/main..HEAD` names it, and the claim
# receipt carrying the other half never exists on a runner. At the
# default depth 1 that range holds exactly one commit — the PR merge
# ref — so every commit that could carry the trailer is outside the
# clone and the read comes back empty. Absence admits nothing, so the
# gate refuses: the refusal working correctly on evidence that was
# never fetched, and still a red run on a branch whose local `verify`
# already proved both halves agree.
#
# A full history rather than a deeper bound, because the exclusion has
# to be EXACT: with either side still grafted, `origin/main..HEAD`
# stops at the shallow boundary and reports trunk commits as this
# branch's own — which would let a trailer inherited from `main` admit
# the same weakening on every branch cut after it. Measured at 10.8
# MiB packed, the price `commit-lint` and `perf` already pay to read a
# history rather than a tree.
fetch-depth: 0
# The committed `ratchet` rows count against `origin/main` (CLOUD-55), and
# `actions/checkout` fetches only this PR's head — so that ref does not
# exist here. An unresolvable base is exit 1 by design, never a pass, which
# is the correct behaviour and precisely why the ref has to be supplied
# rather than the refusal softened.
#
# UNBOUNDED, and it has to be: a `--depth=1` fetch into a repository that
# already has the history GRAFTS it back to one commit, so the bound that
# once made this step cheap would now undo the checkout above and take the
# trailer read's evidence away with it. The ratchet itself still reads one
# tree, not a history — the depth is gone because the clone already paid,
# not because the comparison grew.
#
# CLOUD-236 adds the second base to the same step: `config lint
# --config-from` reads the committed authority out of the PR's OWN base
# ref, which is `main` for essentially every PR here and need not be.
# Fetched only when it differs, so the ordinary PR pays exactly the one
# round trip it always paid. An unresolvable base is exit 1 in that gate
# too — loud, and never read as "no weakening found".
- name: Fetch the comparison base refs
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
git fetch origin main:refs/remotes/origin/main
if [ "$BASE_REF" != main ]; then
git fetch origin "$BASE_REF:refs/remotes/origin/$BASE_REF"
fi
- uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release)
with:
# PINNED TO `batten.toml`'s `[[provision]]` ROW, NOT LEFT TO RESOLVE
# (CLOUD-1672). The action digest above pins the ACTION; this pins the
# MISE it installs, which is a separate resolution the digest does not
# reach. Unset, it takes whatever is newest when the job starts —
# measured 2026-09-08, that was v2026.9.3, whose linux-x64 asset 404s,
# and every job in every workflow died at this step inside 11 seconds.
# v0.0.152 published with zero binaries as a result.
#
# The provision row's own comment already names this harm one layer
# down: a runner that updates itself under a container makes two
# sessions on one commit run different toolchains. The pin existed and
# this layer could not see it, so the tree declared one version and CI
# ran another. `ci-tools-check` now refuses any drift between the two.
version: 2026.9.1
# Install only what this job's gates actually invoke, not the whole
# 18-tool dev set. Every job used to install everything, and `zig`
# (17.3s) finished last, so zig alone set the install wall clock in
# all four jobs — three of which never invoke it. Measured per-tool on
# run 31149330726: zig 17.3s, rust 13.9s, serena 6.7s, prettier 5.6s,
# node 4.7s, everything else under 3s.
#
# This list IS the `gate` mapping in hk.pkl plus `deny`: hk drives the
# steps; cargo-deny, shellcheck, shfmt, taplo, actionlint, pkl and
# prettier (which needs node) are the step commands; rust covers
# cargo-fmt/clippy/test and the `cargo metadata` in macos-link-check;
# jq is used at runtime by the gh-guard program that `test:bats`
# exercises; syft backs the sbom-check step, and jq reads its output.
# bats itself is the tests/bats submodule, not a tool — but rush is
# its parallel backend, and bats aborts rather than falling back when
# the named binary is absent, so an omission here is a failed gate
# rather than a quietly serial one.
#
# uv AND the checker it resolves are here for CLOUD-580: the
# `sbom-ntia-*` rows in batten.toml run `mise run ntia-check`, so
# `sbomcheck` is now a step command of the `batten-check` step rather
# than an agent-only tool. Omitting it would not soften the gate — a
# `command` rule whose binary cannot run is a config error (exit 1), so
# the whole `ci` job would fail on a missing tool instead of the rule
# reporting a conformance level.
#
# Kept out deliberately: zig and cargo-zigbuild (darwin-link only),
# zizmor (its own zizmor.yml workflow), and gh, release-plz and
# serena, which back agent and release tasks that never run here.
# `mise run ci-tools-check` fails if a name here drifts from mise.toml.
#
# Kept on ONE line on purpose: `mise run ci-tools-check` reads these
# lists, and a single-line scalar is unambiguous to parse, where a
# YAML block scalar's continuation lines are not (several tool names
# here begin with `aqua:` and read as new keys).
# `opa` and `regal` are here because two `deny` rows in batten.toml
# SPAWN them — `policy-modules-type-check` runs `opa check -s schema/
# policy/` and `policy-lint-rule-tests` runs `regal test .regal/rules`.
# With `MISE_EXEC_AUTO_INSTALL: false` above making this list binding, a
# tool absent from it is not slow, it is missing: both rows failed
# CLOSED in CI while passing locally, which is the verify/CI
# disagreement `land` refuses on (CLOUD-480). `ci-tools-check` cannot
# see this direction — it holds tools NAMED IN THE WORKFLOW to being
# declared in mise.toml, not tools a batten.toml row needs to being
# installed here.
install_args: rust hk github:nextest-rs/nextest aqua:EmbarkStudios/cargo-deny aqua:koalaman/shellcheck aqua:mvdan/sh aqua:tamasfe/taplo aqua:rhysd/actionlint aqua:jqlang/jq aqua:anchore/syft aqua:shenwei356/rush aqua:open-policy-agent/opa aqua:open-policy-agent/regal pkl deno node npm:prettier npm:renovate uv pipx:ntia-conformance-checker
# CLOUD-406. `hk.pkl:14` amends a pkl PACKAGE uri, and pkl resolves it over
# the network on every evaluation — a second dependency beside the `hk`
# binary `mise.lock` pins, and unpinned at runtime. Measured on run
# 31632519615: the fetch of `hk@1.54.0.zip` failed, hk aborted with exit
# 134 before reaching a single gate, and the `failure` grade then wedged
# the SHA, because `land` re-fires a ready only on a head carrying no
# graded run. An upstream blip on a release CDN became a red required
# check on a branch that changed nothing.
#
# This is the only job that reaches hk — `hooks` (`hk check --all`) is a
# dependency of `mise run ci` and of nothing else; `cross-check` and
# `darwin-link` reach `doctor`, whose hook probe is skipped under CI, and
# `semver` runs neither. So one cache step covers the whole exposure
# this repository controls.
#
# Keyed on `hk.pkl` itself, because the `amends` url lives in that file: a
# version bump changes the key by construction, with no second version
# string to keep honest (and `mise run hk-version` already gates that
# `mise.toml` agrees with the url). Deliberately no `restore-keys` — a
# prefix restore would hand this job a cache that is warm for the WRONG
# version, which is a miss wearing a hit's costume.
#
# A miss behaves exactly as today and then populates, so the exposure
# narrows from every run to one run per bump or eviction rather than being
# removed. It cannot be removed here: `mise-action`'s own bootstrap fetch
# precedes every seam this repository controls, which is why CLOUD-406
# carries a recovery half as well as this one.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.pkl/cache
key: pkl-${{ runner.os }}-${{ hashFiles('hk.pkl') }}
# Caching is left at its default (save on), which means each PR writes and
# restores its OWN cache. That is safe without any `save-if` guard because
# GitHub scopes a cache to the ref that created it: a `pull_request` run
# writes under `refs/pull/N/merge`, restorable only by re-runs of that same
# PR — never by another PR and never by main. Cross-PR poisoning is not
# something this workflow has to prevent; the platform already does.
#
# The previous `save-if: github.ref == 'refs/heads/main'` could never be
# true, because this workflow runs on `pull_request` only (see the header:
# main advances by fast-forward to already-tested commits, so it is
# deliberately not a trigger). The cache was therefore never written and
# every restore missed — the step was pure overhead while reading as though
# caching were handled. Measured cold on 6730729: ci 74s, cross 53s,
# darwin-link 66-75s, against ~7s for a warm local darwin link.
#
# THE FIRST RUN OF A NEW PR WAS STILL COLD, and that paragraph used to end
# here saying CLOUD-176 had costed base-branch warming and decided against
# it. CLOUD-840 reversed that for `windows` on measurements, and the same
# reversal applies here — CLOUD-176's own revisit clause named both
# conditions, and both hold.
#
# Measured 2026-08-21 off the cache API: this job's entries are
# `v0-rust-ci-Linux-x64-a263e760-97231082`, SIX live copies of one key,
# every one on a `refs/pull/N/merge` ref and NONE on `refs/heads/main`.
# GitHub scopes a cache read to the run's own ref plus the base branch, so
# no pull request can read another's and none has a base-branch copy to
# inherit. The restores show it: 1s (a miss) on 3 of the last 4 runs, and a
# miss run spent 1021s in `mise run ci` against 741s for the one hit —
# ~280s, billed 1:1 on a 1x runner.
#
# `shared-key` REPLACES the job-id component rather than substituting the
# same word into it, so this changes the composed key and orphans the six
# entries above. That is the intended effect: they were unreadable across
# pull requests anyway. The claim that it stays byte-identical was made
# once for `windows` and disproved by the live keys (CLOUD-840); it is not
# repeated here.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
# Must match `release-plz.yml`'s `cache-warm-linux` exactly or that job
# writes an entry this one cannot read, which is the current failure
# with extra steps.
# NO CONTENT HASH IN THIS VALUE, AND THE MEASUREMENT THAT USED TO
# ARGUE FOR ONE IS THE EVIDENCE AGAINST IT (CLOUD-1410).
#
# The true half of the retired comment: `[profile.*]` lives in the
# root `Cargo.toml`, which is a VIRTUAL manifest and therefore not a
# workspace member, so `getWorkspaceMembers()` never returns it and
# neither `rustEnvHash` nor `lockHash` covers it. That gap in the key
# is real. What does not follow is that the gap is a CORRECTNESS
# problem, and the retired comment asserted exactly that: that a
# restore without the hash hands back artifacts "built under the old
# profile", citing "0 -> 121 `Compiling` lines".
#
# Those 121 lines ARE cargo correctly invalidating. A per-unit
# fingerprint already carries opt-level, debuginfo, debug-assertions,
# overflow-checks, lto, codegen-units, panic, incremental and strip,
# and a profile edit moves each unit's `-C metadata` /
# `-C extra-filename` hash with it — so rebuilt units land at
# DIFFERENT filenames and linking resolves through the fresh
# fingerprint's dep-info rather than by name lookup in
# `target/debug/deps`. There is no path by which a stale artifact is
# linked. The measurement is the strongest evidence FOR dropping the
# hash, not against it.
#
# WHERE THE HASH LANDED IS WHAT MADE IT EXPENSIVE. `shared-key` is
# applied at `config.ts:77`, INSIDE the prefix assigned to
# `restoreKey` at `:133` — only `lockHash` (`:260`) falls outside it.
# So a hash here does not merely miss the exact key; it changes the
# fallback prefix, and no entry in the store matches. Every
# `[workspace.dependencies]` bump therefore went cold across all 343
# locked crates in every job at once. What the hash bought was a
# 121-unit incremental rebuild converted into a cold build plus a
# discarded multi-gigabyte restore, for identical correctness.
#
# THE ESCAPE HATCH, since a long-lived key has no natural eviction
# event: if a target directory is ever genuinely poisoned, bump
# `prefix-key` (`config.ts:73`) — one input, every family at once,
# separately revertable. That bump is deliberately NOT bundled here.
#
# In `shared-key` rather than `key` because `key` is an if/else
# fallback rust-cache ignores whenever `shared-key` is set.
shared-key: ci-
# READ-ONLY, because a write from here reaches nobody. GitHub scopes a
# cache read to the run's own ref plus the base branch, so the six
# live `refs/pull/N/merge` copies this job used to write were
# unreadable by every other pull request — pure upload time for a
# multi-gigabyte entry with no reader. `cache-warm-linux` on `main` is
# the family's one writer, which is also what stops this job and
# `bats` racing to fill a fresh key with the thinner of two trees.
save-if: false
# ASK WHETHER THIS DIFF CAN MOVE THE SLOW TIER (CLOUD-398). Measured
# 2026-08-21: this job is ~13 of the ~14.7 billed minutes a non-Rust pull
# request costs, `test:bats` is ~81% of it, and a one-file change to
# `.coderabbit.yaml` bought an 18m10s run. The six `slow`-tagged hk steps
# cannot be moved by a diff that touches only the paths `ci-slow-needed`
# calls inert, so that diff should not pay for them.
#
# DECIDED IN-JOB RATHER THAN BY A `paths:` FILTER, and the reason is
# branch protection: the ruleset `protect-main` requires exactly one
# context, `final`, which is a job in THIS workflow. A workflow that does
# not trigger reports nothing, so a filter here would leave that required
# check at "Expected" forever and the pull request would be unmergeable
# rather than fast. Every job and every check stays exactly where it is;
# only the work inside this step is conditional.
#
# `continue-on-error` is deliberately absent: a non-zero exit from the
# decision is exit 2 (could not look), and the steps below must not run on
# a verdict that was never taken.
- name: Can this diff move the slow tier?
id: slow
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
if mise run ci-slow-needed "$BASE_SHA" "$HEAD_SHA"; then
echo "needed=true" >>"$GITHUB_OUTPUT"
else
rc=$?
[ "$rc" -eq 1 ] || exit "$rc"
echo "needed=false" >>"$GITHUB_OUTPUT"
fi
- run: mise run ci
if: steps.slow.outputs.needed == 'true'
env:
# `test:bats` depends on `doctor`, which by default also installs the
# rustup cross targets — two std downloads this job never uses, since
# cross-check and darwin-link are their own jobs. Set but empty means
# "assert the submodule, no rust targets"; unset would take the
# default pair.
DOCTOR_TARGETS: ""
# THE SUITE RUNS IN THE `bats` JOB BELOW, NOT HERE (CLOUD-1140).
#
# Measured on run 33244045030: this job was 1983s of a 1992s run — the
# whole critical path, with `perf` finishing at 9.2 min and idling for
# 24 more — `mise run ci` was 1832s of that, and `test:bats` ~83% of
# THAT. CLOUD-386 swept the worker count at the runner's real width and
# every direction is worse, because each point redistributes workers
# inside one saturated box. A second runner is the experiment
# that sweep could not contain, and its own point D already measured the
# number: the suite alone with the box to itself is 623s against 829s
# contended. So the gate stops being a sum and becomes a max.
#
# `HK_SKIP_STEPS` RATHER THAN A PROFILE OR A SECOND TASK, and each
# alternative fails for its own reason. A profile cannot express it:
# hk resolves multiple profiles on a step with AND and the CLI value
# supplants the config list, so carving the suite out needs the whole
# lane partition CLOUD-398 costed at ~160s of duplication and
# explicitly sequenced after this. A second `mise` task would put a
# name in this workflow that `verify` does not run, which is
# `ci-local-parity` property 3. An env var changes neither: the task
# is still `mise run ci`, and a developer's `verify` — which sets
# nothing — still runs the whole gate.
#
# THE DIRECTION A MISTAKE FAILS IN. A misspelled name here skips
# nothing, so the suite runs in both jobs: wasteful and loud. The
# silent direction — a step skipped here and run by no job at all — is
# what `ci-suite-lane` in batten.toml refuses, because
# `hook-profile-check` reads `hk check --all --plan` and cannot see
# what a workflow job passes.
HK_SKIP_STEPS: test:bats,batten-check
# THE OTHER BRANCH, and it is not merely `ci` minus the slow steps.
#
# `batten-check` USED TO RUN HERE UNCONDITIONALLY and now runs in the
# `batten-check` job below (CLOUD-1413). The reason it was pinned here has
# not changed and is why the new job carries no `if:` either: it carries
# `batten.toml`'s `no-secrets` rule — `kind = "secrets"`, `glob = "**"`,
# `scope = "tree"`, `severity = "deny"` — the repository's ONLY secrets
# scan, so leaving it in the skipped tier would let a credential committed
# to a memory file land with every required check green. What moved is
# which runner pays for it, not whether it is unconditional.
- run: mise run ci:quick
if: steps.slow.outputs.needed != 'true'
env:
DOCTOR_TARGETS: ""
# CLOUD-236: THE CONFIG TRUST MECHANISM, ARMED AGAINST THIS PR'S OWN BASE.
# `--config-from` landed with CLOUD-31 and no caller ever passed it, so
# house style §8's security property — policy loads out of band of the
# change under review, so a branch cannot lower the bar it is judged by —
# was held nowhere.
#
# THE SAME TASK `verify` RUNS, which is what makes this a confirmation
# rather than a discovery (`ci-local-parity` property 3). `verify:gated`
# invokes `mise run config-lint` against `origin/main`; this invokes it
# against the PR's real base. The `hk` pre-commit step stays UNARMED — a
# pre-commit verdict must not depend on whatever a ref happens to be — and
# `verify` is different in kind, since it already refuses a branch that is
# not rebased on current `origin/main`.
#
# `origin/`-PREFIXED, and the prefix is what a red run taught. `base.ref` is
# a bare branch NAME; the fetch above lands it at
# `refs/remotes/origin/<name>`, and a CI checkout has no local branch of
# that name — it is a detached head on the PR merge ref. The unprefixed
# spelling resolved to nothing and the gate answered exit 1, "no such ref":
# the fail-loud path working, and still a red run. `tests/config-lint.bats`
# holds the fetch and this value to the same namespace.
#
# There is deliberately no bypass here. A deliberate relaxation belongs in
# the issue's Ready block at grooming time, not in a label the PR author
# sets on a review that happens after the merge.
- name: Judge this branch's config against the PR base
env:
CONFIG_LINT_BASE: origin/${{ github.event.pull_request.base.ref }}
run: mise run config-lint
# CONSUMER #1's GATE, OFF THE `test` CHAIN (CLOUD-1413).
#
# Measured on job 100792703868: inside the `ci` job, `batten-check` began
# 20 MILLISECONDS after `test` ended — 20:04:16.823 to 20:04:16.843 — for
# 485.4s of strictly serial work. That is 53% of the whole `mise run ci` step
# and 82% of its gate portion, once the ~317s of build that precedes hk's
# first step is set aside. Neither end is a build: `test` logs `Finished test
# profile … in 0.17s`, so its cost is pure execution, and this is one `cargo
# run` against an already-built binary. Split, the pair becomes
# max(295.8, 189.6) rather than their sum.
#
# WHY THIS WAS REFUTED BEFORE AND WHY THE REFUTATION EXPIRED. CLOUD-1225 ruled
# out giving `test` its own job because "a separate job would pay its own cold
# 11 minutes to save 229s". That was correct when written and rested entirely
# on a second job paying a COLD build — which is what a `shared-key` embedding
# a content hash inside the restore prefix guaranteed (CLOUD-1410). With the
# hash gone this job restores from the same warm `ci-` entry the `ci` job
# reads and pays a partial rebuild, so the premise no longer holds.
#
# IT RUNS THE TASK `verify` RUNS, which is what keeps `ci-local-parity`
# property 3 satisfied: that property constrains the TASKS a CI job invokes,
# not the steps around them, so a new job running an existing task adds no
# name a local run cannot reach.
#
# NO JOB-LEVEL `if:` BEYOND THE DRAFT GUARD, for `final`'s reason: a job
# skipped by its own `if:` reports success to branch protection and `skipped`
# to the fan-in, which `final`'s assertion treats as a failure. It is also
# unconditional for the reason the `ci` job's quick branch used to state —
# this is the repository's only secrets scan.
#
# THE `ci` JOB MUST NOT ALSO RUN IT. `HK_SKIP_STEPS: test:bats,batten-check`
# carves it out there, and `ci-suite-lane` refuses the silent direction of that
# pairing — a step skipped in `ci` and run by no job in this workflow.
#
# ADDED TO `final`'s `needs:` AND TO `CI_REQUIRED_CHECKS`. The first gates it
# automatically, because `final`'s assertion is generic over `needs.*` — which
# is why that expression is untouched; the second is what `ci-wait` polls, and
# a job that runs and is absent from that roster is a job `land` does not wait
# for (CLOUD-327).
batten-check:
name: batten-check
if: ${{ github.event.pull_request.draft == false }}
# x64, AND THIS IS THE ONE JOB OF THE `ci-` FAMILY THAT CANNOT MOVE.
# Measured on job 100903936005, the first arm64 run of this job:
#
# batten: provision ripsecrets: no artifact for linux-aarch64;
# the entry pins linux-x86_64, macos-aarch64, macos-x86_64
#
# `batten enforce` carries `no-secrets`, whose scanner is the pinned
# `[[provision]]` row for `ripsecrets` — and NO ripsecrets release has ever
# published a linux-aarch64 artifact. Every tag from v0.1.2 to v0.1.11 ships
# `x86_64-unknown-linux-gnu` and the two darwin arches and nothing else;
# `no-source-built-tool` forbids compiling one. So this job is x64 for as
# long as that holds, and every OTHER job in the family stays arm64: nothing
# else in this workflow runs `enforce`, and the `ci` job carves this step out
# with `HK_SKIP_STEPS`.
#
# WHY THE GAP EXISTED TO BE FOUND HERE. CLOUD-1416 asked whether every tool
# resolves on arm64 and answered from `lock-complete`, which requires each
# `[tools]` entry to install on linux-arm64 as one of three mandatory
# platforms. `ripsecrets` is not a `[tools]` entry — it is a `[[provision]]`
# row, a separate surface carrying no such requirement — so the standing gate
# that was supposed to make this a finding rather than a surprise does not
# reach it. That asymmetry is the row's to close, not this job's.
#
# WARM SINCE CLOUD-1477, AND THE TWO PRICINGS BEFORE IT WERE BOTH WRONG.
#
# The first priced this job's cold build as free: "a job running in parallel
# with a 1730s pole has ~1200s of slack." True when written, and falsified by
# this repository's own change — CLOUD-1410 removed the pole. Measured on run
# 33845451921, the first warm arm64 run of a real `crates/**` change: `ci`
# 364s, `bats` 271s, and THIS JOB 683s, 1.9x the next longest and the critical
# path of the whole workflow.
#
# The second declined the fix on a ceiling: buying a warm x64 entry needs a
# second warm writer, "doubling the cache families against a store measured at
# 10.72 GiB over a 10 GiB ceiling", so the answer was said to be CLOUD-1431 or
# a revert of the family to x64. Every term of that is now spent.
# `cache-sweep.yml` (CLOUD-1453, released v0.0.142) took the store to 3.16 GiB
# by evicting merged pull requests' entries, so the ceiling is not the binding
# constraint. CLOUD-1431 is Done and landed a GATE over the
# release-target/provision-platform pairing rather than an arm64 scanner, so
# this job still cannot move. And eviction bought 12% and not a hit: 683s to
# 600s across jobs 100903936005 and 101280975479, which is pressure lifting
# rather than a restore landing.
#
# So the second writer is what landed. `cache-warm-linux-x64` in
# `release-plz.yml` writes `ci-` on x64 for this job, exactly as
# `cache-warm-linux` writes it on arm64 for `ci`, `bats` and `commit-lint`,
# and `read-family-has-a-warm-writer` refuses the arrangement where a
# read-only consumer has no writer on its own architecture — so the orphaning
# that produced this comment's whole history is now a finding rather than a
# green run that took longer.
runs-on: ubuntu-latest
# DERIVED RATHER THAN OBSERVED, because this job has no history yet. The
# terms: 189.6s for the step itself (job 100792703868), plus this job's own
# preamble — checkout, the mise install — and a COLD build, for the reason
# above. 600s is that sum rounded up; x3 is the multiplier every job here
# carries. Re-derive from `mise run timeout-drift` once this job has 25 runs
# of its own.
timeout-minutes: 30 # budget: p95=600s x3 measured=2026-09-04
# `actions: write` is here for one call and one only: cancelling THIS run
# when the landing lease does not authorise this branch (CLOUD-420).
permissions:
contents: read
actions: write
steps:
# CLOUD-420: THE LANDING LEASE, FIRST, for the reason the `ci` job states
# at length. `ci-local-parity` property 7 asserts this is step one of every
# job that can start immediately. `|| exit 0` on both lines.
- name: Landing lease precondition
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
LEASE_HEAD_REF: ${{ github.head_ref }}
LEASE_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
LEASE_RUN_ID: ${{ github.run_id }}
run: |
installer=$(gh api -H "Accept: application/vnd.github.raw" \
"repos/$GH_REPO/contents/install.sh?ref=main") || exit 0
printf '%s\n' "$installer" | \
BATTEN_VERSION_FROM_REF=main BATTEN_INSTALL_DIR="$RUNNER_TEMP/batten-bin" \
BATTEN_ALLOW_OFF_PATH=1 BATTEN_REQUIRE="lease guard" sh || exit 0
# THE POLICY IS TRUNK'S TOO, AND IT HAD TO BE FETCHED. This step runs
# BEFORE any checkout, so the directory the guard stands in is empty and
# `config::load` found nothing — `[lease] landing_paths` read as *no
# paths declared* and the staleness half failed open on every run, which
# is the exact silence the row was written to end. Fetched from `main`
# for the installer's own reason, one line up: a head must not pin the
# policy it is judged by, and the only tree a checkout would offer here
# is the pull request's own.
mkdir -p "$RUNNER_TEMP/batten-config" || exit 0
gh api -H "Accept: application/vnd.github.raw" \
"repos/$GH_REPO/contents/batten.toml?ref=main" \
>"$RUNNER_TEMP/batten-config/batten.toml" || exit 0
"$RUNNER_TEMP/batten-bin/batten" --config-in "$RUNNER_TEMP/batten-config" \
lease guard \
"$LEASE_HEAD_SHA" "$LEASE_HEAD_REF" "$LEASE_RUN_ID" || exit 0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
# `batten enforce` reaches rules that read real ranges —
# `claim-race-check` and `release-tracking-check` among them — so a
# shallow graft would report trunk commits as this branch's own, which
# is the `ci` job's reason too.
fetch-depth: 0
- name: Fetch the comparison base refs
run: git fetch origin main:refs/remotes/origin/main
# A "Name the checkout's branch" STEP STOOD HERE AND IS DELETED, because
# the thing it fed retired out from under it (CLOUD-1422). It created a
# local branch from `github.head_ref` so that `claim-not-raced` — then a
# shell gate that identified its own PR with `gh pr view` and no argument —
# could resolve `self` on a checkout `actions/checkout` leaves detached.
#
# `mise-tasks/claim-race-check.sh` is now `batten claim race`, and
# `race::identify` matches the head COMMIT against the pull-request listing
# rather than reading a branch name. The step changed no commit, so it fed
# nothing: dead the moment that landed.
#
# AND THE SUCCESSOR ABSTAINS HERE, WHICH IS THE FINDING THE STEP WAS
# HIDING. `race.rs` says "a SHA survives a detached checkout" — true of a
# checkout at the PR's HEAD sha, false of one at the MERGE commit, which is
# the only kind a `pull_request` event produces. Measured: PR #848's merge
# ref is `a31c22b3` and its head sha is `54789fe9`, and from that checkout
# the verb exits 0 with "no open pull request has this commit as its head,
# so there is nothing claiming anything yet". A clean pass, byte-identical
# to a real one. That is CLOUD-1433's class in freshly landed code, and it
# is recorded there rather than papered over with a step that never fixed it.
- uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release)
with:
# PINNED TO `batten.toml`'s `[[provision]]` ROW, NOT LEFT TO RESOLVE
# (CLOUD-1672). The action digest above pins the ACTION; this pins the
# MISE it installs, which is a separate resolution the digest does not
# reach. Unset, it takes whatever is newest when the job starts —
# measured 2026-09-08, that was v2026.9.3, whose linux-x64 asset 404s,
# and every job in every workflow died at this step inside 11 seconds.
# v0.0.152 published with zero binaries as a result.
#
# The provision row's own comment already names this harm one layer
# down: a runner that updates itself under a container makes two
# sessions on one commit run different toolchains. The pin existed and
# this layer could not see it, so the tree declared one version and CI
# ran another. `ci-tools-check` now refuses any drift between the two.
version: 2026.9.1
# THE `ci` JOB'S LIST, NOT A NARROWER ONE, and the width is decided by
# what `enforce` reaches rather than by what this job looks like it
# needs. `batten.toml`'s `command` rules shell out to `hk`, to `regal`
# and `opa` directly, and to `mise run` tasks that reach `syft`, the
# NTIA checker and `jq`. `MISE_EXEC_AUTO_INSTALL: false` at the top of
# this file makes a missing tool a "command not found" INSIDE a rule,
# which reads as a failing gate rather than a provisioning error — so
# narrowing here is a trap rather than an economy, the same one the
# `bats` job's header records. One line, for `ci-tools-check`'s reason:
# it parses these as a scalar and several names begin with `aqua:`.
install_args: rust hk github:nextest-rs/nextest aqua:EmbarkStudios/cargo-deny aqua:koalaman/shellcheck aqua:mvdan/sh aqua:tamasfe/taplo aqua:rhysd/actionlint aqua:jqlang/jq aqua:anchore/syft aqua:shenwei356/rush aqua:open-policy-agent/opa aqua:open-policy-agent/regal pkl deno node npm:prettier npm:renovate uv pipx:ntia-conformance-checker
# `no-conflict-markers` delegates to `hk`, which resolves the pkl package
# uri, so this job reaches it for the same reason the `bats` job does.
# Keyed on `hk.pkl`, no `restore-keys`, for the reasons recorded there.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.pkl/cache
key: pkl-${{ runner.os }}-${{ hashFiles('hk.pkl') }}
# THE `ci-` FAMILY, READ-ONLY LIKE EVERY OTHER PULL-REQUEST CONSUMER OF IT
# (CLOUD-1477). This step was this workflow's last writer, and the reason it
# was one has been removed rather than merely overruled.
#
# The rule is the one the `ci`, `bats` and `windows` steps are stated over:
# `save-if: false` belongs on a reader of a family a trunk-side job WARMS,
# and is wrong where no warm writer exists, because a pure consumer of an
# empty family has nothing at all. That exception applied here for as long
# as `cache-warm-linux` wrote `ci-` on arm64 alone; `cache-warm-linux-x64`
# now writes it on x64, so this job is an ordinary consumer and the
# exception is spent.
#
# WHAT THE WRITE WAS BUYING, AND WHY IT IS NOT WORTH KEEPING BESIDE A WARM
# WRITER. A cache read is scoped to the run's own ref plus the base branch,
# so the entry could never serve another pull request — only the next lap of
# this one, under the same `refs/pull/N/merge` ref that `land` re-runs on.
# Against a warm x64 entry the first lap now restores instead of building,
# so the later laps have nothing left to inherit that they would not already
# get; what the write still costs is a multi-gigabyte upload per lap into
# the 10 GiB store that `cache-sweep.yml` then has to reclaim.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
shared-key: ci-
save-if: false
- run: mise run batten-check
env:
# `batten-check` reaches `doctor` through the same chain the `ci` job
# does, and this job never cross-compiles. Set but empty means "assert
# the submodule, no rust targets"; unset would take the default pair.
DOCTOR_TARGETS: ""
# THE SHELL SUITE, ON ITS OWN RUNNER (CLOUD-1140 — CLOUD-398's slice 1).
#
# It is ~83% of `mise run ci` and `mise run ci` is 92% of the `ci` job, so
# until this job existed the gate's wall clock was a sum of two chains sharing
# one runner's cores. Now it is a max. The `ci` job carves the step out with
# `HK_SKIP_STEPS`, and `ci-suite-lane` refuses the pairing coming apart in the
# silent direction — a step skipped there and run by no job here.
#
# THE SETUP MIRRORS THE `ci` JOB DELIBERATELY, and narrowing it is a trap
# rather than an economy. These suites are the tests for `mise-tasks/`, so
# between them they invoke very nearly the whole toolchain — `hk` itself
# (`hk-selection`, `hook-profile-check`), `jq`, `git`, the `cargo run -p
# batten` that a dozen gates shell out to. A tool missing from the list is not
# a slower run: `MISE_EXEC_AUTO_INSTALL: false` at the top of this file makes
# it a "command not found" inside a test, which reads as a failing gate rather
# than a provisioning error. The one thing this job does NOT need is
# `DOCTOR_TARGETS`' rustup cross targets, for the reason the `ci` job states.
#
# NOT A JOB-LEVEL `if:`, for `final`'s reason: a job skipped by its own `if:`
# reports success to branch protection and `skipped` to the fan-in, which
# `final`'s assertion treats as a failure. So this job always runs and asks
# the same in-job question the `ci` job asks.
bats:
name: bats
if: ${{ github.event.pull_request.draft == false }}
# ARM64, AS ONE SET WITH THE REST OF THE `ci-` FAMILY (CLOUD-1416). rust-cache
# puts `runnerOS-runnerArch` inside the restore prefix (`config.ts:93`,
# before `:133`), so a reader that moves while its family's writer stays put
# has no warm entry at all. The `ci` job's header carries the reasoning, the
# list of jobs that deliberately do NOT move, and the note that every budget
# here is still an x64 reading owed a re-derivation.
runs-on: ubuntu-24.04-arm
# DERIVED FROM THE STEP IT INHERITS, and stated because it is not yet this
# job's own p95. `test:bats` is 1435.7s of the `ci` job's 1670.9s hk gate on
# a runner (CLOUD-1084), and CLOUD-386's point D measured 623s for the suite
# alone with the box to itself on a 2-core mask. Alone on a real runner it
# should land between those, so the budget is taken from the larger reading
# plus this job's own preamble, times the x3 multiplier every job here
# carries. Re-derive it from `mise run timeout-drift` once this job has 25
# runs of its own; an over-declared budget is a weaker backstop, which is the
# safe direction to be wrong in and still the wrong number.
timeout-minutes: 78 # budget: p95=1550s x3 measured=2026-08-29
permissions:
contents: read
actions: write
steps:
# CLOUD-420: THE LANDING LEASE, FIRST, for the reason the `ci` job states
# at length. `ci-local-parity` property 7 asserts this is step one of every
# job that can start immediately. `|| exit 0` on both lines.
- name: Landing lease precondition
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
LEASE_HEAD_REF: ${{ github.head_ref }}
LEASE_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
LEASE_RUN_ID: ${{ github.run_id }}
run: |
installer=$(gh api -H "Accept: application/vnd.github.raw" \
"repos/$GH_REPO/contents/install.sh?ref=main") || exit 0
printf '%s\n' "$installer" | \
BATTEN_VERSION_FROM_REF=main BATTEN_INSTALL_DIR="$RUNNER_TEMP/batten-bin" \
BATTEN_ALLOW_OFF_PATH=1 BATTEN_REQUIRE="lease guard" sh || exit 0
# THE POLICY IS TRUNK'S TOO, AND IT HAD TO BE FETCHED. This step runs
# BEFORE any checkout, so the directory the guard stands in is empty and
# `config::load` found nothing — `[lease] landing_paths` read as *no
# paths declared* and the staleness half failed open on every run, which
# is the exact silence the row was written to end. Fetched from `main`
# for the installer's own reason, one line up: a head must not pin the
# policy it is judged by, and the only tree a checkout would offer here
# is the pull request's own.
mkdir -p "$RUNNER_TEMP/batten-config" || exit 0
gh api -H "Accept: application/vnd.github.raw" \
"repos/$GH_REPO/contents/batten.toml?ref=main" \
>"$RUNNER_TEMP/batten-config/batten.toml" || exit 0
"$RUNNER_TEMP/batten-bin/batten" --config-in "$RUNNER_TEMP/batten-config" \
lease guard \
"$LEASE_HEAD_SHA" "$LEASE_HEAD_REF" "$LEASE_RUN_ID" || exit 0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
# tests/bats — the runner itself. Without it there is no bats to run.
submodules: true
# The suites read history: `land`, `land-lock`, `linear-check` and
# `commit-lint` all build fixtures over real ranges, and several assert
# against `origin/main`. The `ci` job's own note explains why a partial
# depth is worse than none here — a shallow graft reports trunk commits
# as the branch's own.
fetch-depth: 0
- name: Fetch the comparison base refs
run: git fetch origin main:refs/remotes/origin/main
- uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release)
with:
# PINNED TO `batten.toml`'s `[[provision]]` ROW, NOT LEFT TO RESOLVE
# (CLOUD-1672). The action digest above pins the ACTION; this pins the
# MISE it installs, which is a separate resolution the digest does not
# reach. Unset, it takes whatever is newest when the job starts —
# measured 2026-09-08, that was v2026.9.3, whose linux-x64 asset 404s,
# and every job in every workflow died at this step inside 11 seconds.
# v0.0.152 published with zero binaries as a result.
#
# The provision row's own comment already names this harm one layer
# down: a runner that updates itself under a container makes two
# sessions on one commit run different toolchains. The pin existed and
# this layer could not see it, so the tree declared one version and CI
# ran another. `ci-tools-check` now refuses any drift between the two.
version: 2026.9.1
# One line, for the reason the `ci` job's list states: `ci-tools-check`
# parses these as a scalar and several names begin with `aqua:`, which
# a block scalar's continuation lines read as new keys.
install_args: rust hk github:nextest-rs/nextest aqua:EmbarkStudios/cargo-deny aqua:koalaman/shellcheck aqua:mvdan/sh aqua:tamasfe/taplo aqua:rhysd/actionlint aqua:jqlang/jq aqua:anchore/syft aqua:shenwei356/rush aqua:open-policy-agent/opa aqua:open-policy-agent/regal pkl deno node npm:prettier npm:renovate uv pipx:ntia-conformance-checker
# The suites drive `hk` directly (`hk-selection`, `hook-profile-check`), so
# this job reaches the pkl package uri too and needs the same cache the
# `ci` job takes for it. Keyed on `hk.pkl`, no `restore-keys`, for the
# reasons recorded there.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.pkl/cache
key: pkl-${{ runner.os }}-${{ hashFiles('hk.pkl') }}
# THE WARMED `ci-` FAMILY, READ-ONLY — the `bats-` family is retired
# (CLOUD-1410, CLOUD-1420).
#
# WHAT THE RETIRED COMMENT GOT RIGHT AND WHAT IT MISSED. It said a
# distinct key avoids racing the `ci` job to save one entry, and that is
# true. What it never asked is whether anything WRITES `bats-` on `main`.
# Nothing does: `release-plz.yml`'s `cache-warm-linux` writes `ci-` and
# `cache-warm-windows` writes `windows-`, and GitHub scopes a cache read
# to the run's own ref plus the base branch — so a `bats-` entry written
# on `refs/pull/N/merge` is unreadable by every other pull request,
# including the next push to the same branch under a new merge ref.
# `bats` has therefore been COLD ON EVERY PULL REQUEST since the job
# existed, and the manifest hash was never its binding constraint.
#
# `cache-warm-linux` runs `cargo nextest run --no-run --workspace`, which
# builds the workspace and every test target on the linux dev profile —
# a strict superset of what `test:bats` needs, since the suites invoke
# `cargo run -p batten` and nothing this job runs is outside that build.
# So this job is a pure consumer of an entry another job already paid for.
#
# `save-if: false` IS WHAT MAKES THE SHARING SAFE, and it is the retired
# comment's race answered rather than ignored. A pure consumer cannot race
# `ci` to save, because it does not enter the race — and a cache entry is
# immutable once written, so the first finisher on a fresh key would
# otherwise become the entry every later reader inherits. One designated
# writer on `main`, every pull-request job read-only.
#
# THE CONSEQUENCE, stated rather than discovered: a key
# `cache-warm-linux` has not yet written stays empty for the whole pull
# request. That is the correct direction — a cold PR beats a poisoned
# cache — but it makes the warm job's cadence load-bearing.
#
# NOT DONE FOR `cross-`, `semver-` OR `${{ matrix.target }}`: those build
# different things and cannot share this entry. They need warm jobs of
# their own, which is a follow-up rather than this change, so they keep
# writing.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
shared-key: ci-
save-if: false
# THE SAME QUESTION THE `ci` JOB ASKS, and it must stay in-job rather than
# becoming a `paths:` filter or a job `if:` — see this job's header and
# `final`'s. `test:bats` is one of the six `slow`-tagged steps, so a diff
# `ci-slow-needed` calls inert cannot move it.
- name: Can this diff move the slow tier?
id: slow
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
if mise run ci-slow-needed "$BASE_SHA" "$HEAD_SHA"; then
echo "needed=true" >>"$GITHUB_OUTPUT"
else
rc=$?
[ "$rc" -eq 1 ] || exit "$rc"
echo "needed=false" >>"$GITHUB_OUTPUT"
fi
- run: mise run test:bats