Repository navigation
300 lines (282 loc) · 14.5 KB
/
Copy pathci.yml
File metadata and controls
300 lines (282 loc) · 14.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
# CI — the host-local gate on the supported OSes (macOS arm64 + Linux x86_64),
# mirroring the local gate. Each matrix leg runs `scripts/ci_gate.sh`, so CI
# verifies exactly what the maintainer runs per host (single source of truth).
# ci_gate.sh reaches the suite only through `test/cljw-test`, the one entry
# point: `--only fmt`, then `--only full`. What `full` runs is a row in
# test/units.list, so the gate configuration is defined once, not once per
# launcher.
#
# Triggers: pull_request (any target) + push to `main` + manual dispatch.
# A push to `staging` runs nothing here on purpose: the staging→main PR is its
# gate (see .github/CONTRIBUTING.md "When CI runs").
# The gate provisions pinned Zig 0.16.0 and Babashka; python3 is preinstalled
# on GitHub runners. `zig build -Dwasm` fetches the embedded zwasm engine +
# the zlinter dev-dep from git over the network.
#
# ONE configuration, everywhere (2026-08-12). PR, push, dispatch and the local
# `scripts/run_gate.sh` all run the identical full gate, so "green" means the
# same run wherever it was said. The tiers this used to have (PR → smoke,
# nightly → full + a tree_walk sweep) were the drift they were meant to catch:
# the last nightly failed on a wall-clock bound in a config nothing else ran,
# which is a finding about the runner, not about cljw.
# `scripts/check_gate_parity.sh` enforces the single configuration mechanically.
# The Zig toolchain, the fetched deps (zwasm/zlinter), and the build cache are
# all preserved across runs (actions/cache), so a warm run rebuilds only what
# changed instead of three cold ReleaseSafe builds — the dominant cost.
name: ci
on:
# Skip CI on doc/scaffolding-only pushes (markdown, .dev/ ledger + ADRs + notes):
# they cannot change build/test outcome, so a ~15-min gate on them is pure waste.
# A commit that also touches src / scripts / build.zig* / flake.nix / .github /
# a root data-yaml still runs (paths-ignore skips only when ALL changed files match).
pull_request:
paths-ignore: ['**/*.md', '.dev/**']
push:
branches: [main]
paths-ignore: ['**/*.md', '.dev/**']
workflow_dispatch:
permissions:
contents: read
env:
# Homebrew 6.0 (2026-05-30) enforces tap-trust: the macos-15 image ships a
# pre-tapped third-party `aws/tap`, so every `brew` invocation (auto-update
# included) spews a "not trusted / ignoring formulae" block and any future
# third-party formula install would be rejected outright. Our own installs
# are homebrew/core (official) so they still work, but this keeps the log
# clean and future-proofs a third-party dep. Harmless on Linux (no brew).
HOMEBREW_NO_REQUIRE_TAP_TRUST: 1
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
gate:
name: gate (${{ matrix.name }})
runs-on: ${{ matrix.runs-on }}
timeout-minutes: 75
strategy:
fail-fast: false
matrix:
include:
- name: aarch64-macos
runs-on: macos-15
zig-dir: zig-aarch64-macos-0.16.0
- name: x86_64-linux
runs-on: ubuntu-22.04
zig-dir: zig-x86_64-linux-0.16.0
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.sha }}
# scripts/check_changelog_reachability.sh lists release tags with
# `git tag --merged HEAD`, which needs both the tags and the history
# that connects them to HEAD. A depth-1 clone (even with fetch-tags)
# reaches no tag, so the step would report "no release tag" and pass.
fetch-depth: 0
fetch-tags: true
- name: Cache Zig 0.16.0
id: cache-zig
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.local/zig-0.16.0
key: zig-0.16.0-${{ matrix.name }}
- name: Install Zig 0.16.0
if: steps.cache-zig.outputs.cache-hit != 'true'
shell: bash
run: |
set -euo pipefail
source .github/versions.lock
mkdir -p ~/.local
curl -fsSL "https://ziglang.org/download/${ZIG_VERSION}/${{ matrix.zig-dir }}.tar.xz" -o /tmp/zig.tar.xz
# Clear both paths first so a re-run is idempotent: tar refuses to
# overwrite existing entries ("Cannot open: File exists"), and `mv` into
# an EXISTING directory nests instead of replacing, which would put the
# toolchain at zig-0.16.0/<zig-dir>/ and leave `zig` off PATH.
rm -rf "$HOME/.local/${{ matrix.zig-dir }}" "$HOME/.local/zig-0.16.0"
tar -xJf /tmp/zig.tar.xz -C ~/.local
mv "$HOME/.local/${{ matrix.zig-dir }}" "$HOME/.local/zig-0.16.0"
- name: Add Zig to PATH
shell: bash
run: echo "$HOME/.local/zig-0.16.0" >> "$GITHUB_PATH"
- name: Pin Zig global cache dir
shell: bash
run: echo "ZIG_GLOBAL_CACHE_DIR=$HOME/.cache/zig" >> "$GITHUB_ENV"
# Cache the fetched deps (zwasm git dep + zlinter, under the global cache's
# p/) AND the build artifacts (global cache + the repo-local .zig-cache).
# Zig's cache is content-addressed, so a stale restore is always SAFE — it
# can only cause a rebuild, never a wrong build. That makes "restore the
# newest thing available, always save what this run produced" the correct
# policy, and it is why the key carries the run id.
#
# It did not used to. The key was `hashFiles('build.zig.zon','build.zig')`
# — files that change a few times a year — so it HIT on essentially every
# run, and `actions/cache` only WRITES a cache when its key MISSES. The
# restored `.zig-cache` was therefore frozen at whatever the first run
# with that build.zig produced, and every run since rebuilt everything
# that had changed after that snapshot and then threw the result away.
# Measured 2026-08-19: `zig_build_test_vm` 375 s + `zig_build_test_tree_walk`
# 371 s on the Linux leg, against ~1 s warm locally (D-487) — i.e. cold
# builds, every run, while the step reported a cache hit every time.
# A cache that reports a hit and saves nothing is worse than no cache:
# it makes the waste invisible.
#
# restore/save are split so a FAILED run still saves its build products.
# A red gate is exactly when the next run is a fix for it, seconds later,
# against nearly the same tree — the moment the cache is worth the most.
- name: Restore Zig deps + build
id: zigcache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cache/zig
.zig-cache
key: ${{ matrix.name }}-zigbuild-${{ hashFiles('build.zig.zon', 'build.zig') }}-${{ github.run_id }}
restore-keys: |
${{ matrix.name }}-zigbuild-${{ hashFiles('build.zig.zon', 'build.zig') }}-
${{ matrix.name }}-zigbuild-
# Gate deps not universally preinstalled: ripgrep (check_debt_id_refs.sh),
# Babashka (native Clojure harness supervision),
# and, on macOS, bash >= 4 — the macos-15 runner resolves `bash` to the
# system 3.2, which cannot parse the e2e harness idioms (heredoc inside
# `$()`, `case` inside `$()`, `declare -A`); 43 gate scripts fail under it
# (first exposed by the 2026-07-02 nightly, the first FULL run on the
# hosted mac runner). Every script is invoked as `bash foo.sh` or via
# `#!/usr/bin/env bash`, so a brew bash 5 on PATH fixes all of them.
# Install only what is absent (fast no-op when the image ships it).
# yq is listed even though both hosted images ship it: `check_compat_members`
# exits 1 without it, so relying on the image means the gate is green here
# for a reason nobody wrote down and red on a developer machine.
- name: Install gate deps if missing (bb, ripgrep, yq; bash>=4 on macOS)
shell: bash
run: |
if ! command -v bb >/dev/null 2>&1; then
source .github/versions.lock
case "$RUNNER_OS:$RUNNER_ARCH" in
Linux:X64)
bb_platform=linux-amd64
bb_sha="$BABASHKA_LINUX_AMD64_SHA256"
;;
macOS:ARM64)
bb_platform=macos-aarch64
bb_sha="$BABASHKA_MACOS_AARCH64_SHA256"
;;
*)
echo "::error::unsupported Babashka runner $RUNNER_OS/$RUNNER_ARCH"
exit 1
;;
esac
bb_archive="/tmp/babashka-${BABASHKA_VERSION}.tar.gz"
curl -fsSL "https://github.com/babashka/babashka/releases/download/v${BABASHKA_VERSION}/babashka-${BABASHKA_VERSION}-${bb_platform}.tar.gz" -o "$bb_archive"
bb_actual=$(shasum -a 256 "$bb_archive" | awk '{print $1}')
if [ "$bb_actual" != "$bb_sha" ]; then
echo "::error::Babashka checksum mismatch: expected $bb_sha, got $bb_actual"
exit 1
fi
mkdir -p "$HOME/.local/bin"
tar -xzf "$bb_archive" -C "$HOME/.local/bin" bb
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
fi
if [ "$RUNNER_OS" = "Linux" ]; then
command -v rg >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep; }
command -v yq >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq yq; }
else
command -v rg >/dev/null 2>&1 || brew install ripgrep
command -v yq >/dev/null 2>&1 || brew install yq
if [ "$(bash -c 'echo "${BASH_VERSINFO[0]}"')" -lt 4 ]; then
brew install bash
fi
fi
- name: Verify toolchain
shell: bash
run: |
zig version
bb --version
python3 --version
rg --version | head -1
# Check the PATH-resolved bash (what `bash foo.sh` + env-shebang
# scripts actually get), not this step's own shell.
bash --version | head -1
if [ "$(bash -c 'echo "${BASH_VERSINFO[0]}"')" -lt 4 ]; then
echo "::error::gate requires bash >= 4 on PATH (PATH bash is $(bash -c 'echo "$BASH_VERSION"'))"; exit 1
fi
# Warm the build cache in a step that can RECOVER from a poisoned restore.
# A restored `.zig-cache` can reference a file it does not contain (a job
# cancelled mid-build used to save one, see the save step below), and
# Zig treats that as a hard compilation error rather than rebuilding.
# Left alone, `restore-keys` hands that same entry to every later run and
# CI stays red until a human deletes it through the API.
#
# The recovery is UNCONDITIONAL: on any failed warm build, clear the cache
# and build once more. Wiping a content-addressed cache is always safe (it
# costs a rebuild, nothing else), and the retry is a better discriminator
# than any log predicate, because a failure with NO cache present is
# unambiguously about the source.
#
# This step used to gate the recovery on `grep "failed to check cache"`,
# the exact wording of the first poisoning observed. A restored cache has
# at least two spellings for one condition, and the second one landed on
# the v1.14.5 release PR (2026-09-10):
# error: failed to spawn build runner .zig-cache/o/<hash>/build: FileNotFound
# The predicate did not match, so the step printed "build failed for a
# reason unrelated to the cache" and failed the release for the very
# condition it was written to heal. Matching a symptom's wording is how a
# self-heal reports a false negative with confidence; not matching at all
# cannot.
#
# This is not extra work on a healthy run: the gate's own first step is
# this same build, so a healthy run does it here and finds it warm there.
- name: Warm build (self-healing)
shell: bash
run: |
set -uo pipefail
if zig build -Dwasm -Doptimize=ReleaseSafe; then
exit 0
fi
echo "::warning::warm build failed, clearing the restored .zig-cache and rebuilding once"
rm -rf .zig-cache
zig build -Dwasm -Doptimize=ReleaseSafe
- name: ci_gate.sh
shell: bash
run: bash scripts/ci_gate.sh
# Regression guard: the wasm32-wasi target must keep compiling (ADR-0193).
# Compile-only — a runtime smoke needs a wasm engine the runners do not
# ship. Linux leg only; the wasm target is host-arch independent.
- name: Build wasm32-wasi target (ADR-0193)
if: runner.os == 'Linux'
shell: bash
run: zig build -Dtarget=wasm32-wasi
# Save on success OR failure, but NEVER on cancellation.
#
# Failure is the case worth saving for: the next run is usually a fix for
# that red, minutes later, against nearly the same tree, and a failed job
# ran its BUILD steps to completion — only a test or a check went red, so
# `.zig-cache` is internally consistent.
#
# A CANCELLED job is the opposite, and this condition was originally
# `always()`, which INCLUDES cancellation. Measured 2026-08-19: pushing a
# fix fired `cancel-in-progress`, killing the Linux job in the middle of
# `zig build`. The save captured `.zig-cache` half-written — an
# `o/<hash>` manifest present, `bootstrap_sources_stub.zig` inside it
# never flushed. The next run restored that and Zig refused to build:
#
# error: failed to check cache: '.zig-cache/o/…/bootstrap_sources_stub.zig'
# file_hash FileNotFound
#
# Zig does NOT recover from this on its own — it errors instead of
# rebuilding. And since `restore-keys` matches by PREFIX, a poisoned
# entry is the newest match for every later run, so one cancelled job
# wedges the whole lineage until someone deletes the entry by hand. That
# is a worse failure mode than the stale-cache bug this caching replaced,
# which is why the condition is narrowed rather than the symptom patched.
- name: Bound the Zig cache before saving
if: success() || failure()
shell: bash
run: bash scripts/prune_zig_cache.sh
- name: Save Zig deps + build
if: success() || failure()
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cache/zig
.zig-cache
key: ${{ steps.zigcache.outputs.cache-primary-key }}