Artifact 8f6a1c7775c19750ab3d7d927b24fb2099f948067432ddc47da133bec240256f:
- File
.github/workflows/ci.yml
— part of check-in
[8ebc3e4dd8]
at
2026-09-24 01:02:57
on branch trunk
— soak: record a finding and keep sweeping; CI: cap the runners' zig cache
A failing fuzz target is abandoned and the sweep moves on, so one recurring crash no longer hides the targets after it; the exit lists every finding. Each workflow clears its zig-local cache whole at job start once it passes 8 GB, since Zig never prunes it. (user: dmestas, size: 19706) [annotate] [blame] [check-ins using]
name: ci # Every push on every branch, plus pull requests: a release branch stays # verified, not only the default branch. Tag pushes are the one exclusion. A # release tag lands on a commit whose branch push already ran this battery, # and the tag push used to run it AGAIN on the same three single-slot runners # the release workflow was queueing for — one tagged push, three workflows, # and the release build waited behind a battery it did not need. The release # workflow triggers on the tag itself and is the gate for what it ships. # Both filters are required: GitHub runs a `push` trigger that names only a # tag filter for tag pushes alone, so `tags-ignore` by itself silently ran # nothing for two commits. `branches: ['**']` is every branch, no filter. on: push: branches: ['**'] tags-ignore: ['**'] pull_request: # Least privilege: the battery only reads the checkout. Without an explicit # block the job inherits whatever the repo/org default grants, which is # read/write on many configurations. permissions: contents: read # Unfiltered `push:` plus `pull_request:` means a PR branch runs the whole # battery twice per push. Superseded runs are cancelled rather than left to # finish against a commit nobody is waiting on. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true # One release gate across the native macOS/ARM64 and Linux x86-64/ARM64 # platforms. Each label set is intentionally precise: Linux legs remain queued # until the corresponding self-hosted runner is registered; they never fall # back to a hosted or arbitrary self-hosted machine. jobs: battery: strategy: fail-fast: false matrix: include: - os: macOS arch: ARM64 runner: mac-mini-zit zig_target: aarch64-macos zig_shasum: b23d70deaa879b5c2d486ed3316f7eaa53e84acf6fc9cc747de152450d401489 - os: Linux arch: X64 runner: linux-x64-zit zig_target: x86_64-linux zig_shasum: 70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00 - os: Linux arch: ARM64 runner: linux-arm64-zit zig_target: aarch64-linux zig_shasum: ea4b09bfb22ec6f6c6ceac57ab63efb6b46e17ab08d21f69f3a48b38e1534f17 runs-on: [self-hosted, "${{ matrix.os }}", "${{ matrix.arch }}", "${{ matrix.runner }}"] # An explicit ceiling, per the bounded-waits rule: every wait needs an # interval and a timeout. Without this the job inherits GitHub's 360-minute # default, so a hang — most plausibly smoke's spawned server never exiting — # turns a red gate into a six-hour silent stall instead of a failure. timeout-minutes: 20 env: # Kept in lockstep with src/ci_test.zig's `pinned_zig_version` and # `pinned_zig_shasums`, which fail `zig build test` if this file drifts. # These are the target-specific published digests from # ziglang.org/download/index.json. ZIG_VERSION: 0.16.0 ZIG_TARGET: ${{ matrix.zig_target }} ZIG_SHASUM: ${{ matrix.zig_shasum }} steps: - uses: actions/checkout@v5 # A `§X.Y Lnnn` citation that resolves to the wrong paragraph is worse # than no citation: it looks authoritative, so the next person checking a # normative claim against it is told the wrong thing. Line numbers rot # every time a draft is re-rendered to apply an erratum, and nothing # noticed until a sweep found most of them wrong. # # A GATE ON ZERO. This began as a two-way ratchet over a 512-citation # backlog; the backlog is gone, so an allowance would only be somewhere # for new rot to hide. Any citation whose lines fall outside the section # it names now fails the build outright. Needs no toolchain, so it runs # before the Zig install and fails fast. - name: Check draft citations run: python3 scripts/check-citations.py # smoke drives the real fossil binary — it is the only step that proves # the conformance claim against the reference implementation rather than # against our own reading of the RFCs. Fossil is provisioned by each # self-hosted runner image, so print the actual version used by the gate. - name: Verify fossil run: | set -euo pipefail command -v fossil >/dev/null 2>&1 || { echo "fossil is not installed on this runner; smoke cannot run." >&2 echo "provision fossil in this self-hosted runner image." >&2 exit 1 } fossil version # ASSERTED, not merely printed. Runners drifting onto different # fossils makes the conformance claim mean a different thing on each: # a regression against the newer one can pass on the older, and # behaviour only the newer has reddens the older for something that # is not a defect. Both are silent, because the version is printed # here and the failure happens hundreds of log lines later. # # Not hypothetical. `fossil sqlite3` quotes its output on 2.26 and # not on 2.28, which failed the archive differential three times # while two runners stayed green; the second attempt shipped a fix # that fixed nothing. This check would have said so on the first. version="$(fossil version | awk '{print $5}')" minimum="2.28" if [ "$(printf '%s\n%s\n' "$minimum" "$version" | sort -V | head -1)" != "$minimum" ]; then echo "fossil ${version} is older than ${minimum}; smoke and the fossil" >&2 echo "differentials state their conformance claim against ${minimum}." >&2 echo "upgrade fossil on this self-hosted runner image." >&2 exit 1 fi # import-git spawns `git --no-lazy-fetch …`, which needs git >= 2.45; an # older distro git (Ubuntu 24.04 ships 2.43) fails the e2e step with 13 # opaque `unknown option` errors instead of one named sentence. Fail the # runner preflight by name, the way the fossil check above does. - name: Verify git >= 2.45 run: | set -euo pipefail git --version version="$(git --version | awk '{print $3}')" minimum="2.45.0" if [ "$(printf '%s\n%s\n' "$minimum" "$version" | sort -V | head -1)" != "$minimum" ]; then echo "git ${version} is older than ${minimum}; import-git needs --no-lazy-fetch." >&2 echo "upgrade git on this self-hosted runner (e.g. the git-core PPA)." >&2 exit 1 fi # Installed by hand rather than through a setup action: the toolchain is # one tarball, the URL is stable, and verifying the published digest here # is both simpler and stronger than trusting a third-party action to pin # the same thing. `sha256sum` is standard on Linux; macOS supplies # `shasum`, so select the available native checksum tool. - name: Install Zig ${{ env.ZIG_VERSION }} run: | set -euo pipefail # The runners are self-hosted and persistent, and `actions/checkout` # cleans the workspace, so nothing left inside it survives to the # next run. Zig's compilation cache is content-addressed, so a cache # directory OUTSIDE the workspace turns each later `zig build <step>` # into a warm build over unchanged sources instead of a cold compile # per step — which was most of this battery's wall clock, with the # unit and end-to-end steps each recompiling the same sources into # their own binary from nothing. The toolchain tarball is kept the # same way, and still verified against the pinned digest on every # run: a stale or tampered copy is refused and refetched, never # trusted because it was there. cache="${HOME}/.cache/zit-ci" # Zig never prunes its compilation cache: every source change leaves # a generation behind, and these runners grew 9-14 GB of it in a week. # Past the cap it is cleared WHOLE, at job start, when nothing else is # building on this single-slot runner. Not pruned by age: Zig does not # touch an entry it reuses, so an old entry may still be live, and a # manifest naming a deleted output costs more than one cold compile. cap_kb=$((8 * 1024 * 1024)) if [ -d "${cache}/zig-local" ]; then used_kb=$(du -sk "${cache}/zig-local" | awk '{print $1}') if [ "${used_kb}" -gt "${cap_kb}" ]; then echo "zig cache is ${used_kb} KB, over the ${cap_kb} KB cap; clearing it" rm -rf "${cache}/zig-local" fi fi mkdir -p "${cache}/zig-local" "${cache}/zig-global" "${cache}/toolchains" echo "ZIG_LOCAL_CACHE_DIR=${cache}/zig-local" >> "$GITHUB_ENV" echo "ZIG_GLOBAL_CACHE_DIR=${cache}/zig-global" >> "$GITHUB_ENV" tarball="zig-${ZIG_TARGET}-${ZIG_VERSION}.tar.xz" kept="${cache}/toolchains/${tarball}" digest() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' else shasum -a 256 "$1" | awk '{print $1}' fi } if [ ! -f "$kept" ] || [ "$(digest "$kept")" != "${ZIG_SHASUM}" ]; then rm -f "$kept" curl -fsSL -o "${kept}.part" "https://ziglang.org/download/${ZIG_VERSION}/${tarball}" mv "${kept}.part" "$kept" fi actual="$(digest "$kept")" if [ "$actual" != "${ZIG_SHASUM}" ]; then echo "zig tarball digest mismatch: expected ${ZIG_SHASUM}, got ${actual}" >&2 rm -f "$kept" exit 1 fi tar -xf "$kept" echo "${GITHUB_WORKSPACE}/zig-${ZIG_TARGET}-${ZIG_VERSION}" >> "$GITHUB_PATH" - name: Zig version run: zig version # `--check build.zig src` rather than `--check .`: the bare dot walks # .zig-cache/ and zig-out/, whose generated sources are not ours to # format. Naming the source roots keeps the check meaning the same thing # on a cold runner and in a warm local tree. - name: Format check run: zig fmt --check build.zig src - name: Build run: zig build # The battery runs each step explicitly. Local semantics are deliberately # unchanged: e2e staying compile-only under `zig build test` is a good # inner-loop design, so CI names the steps rather than the build graph # being rewired to fold them in. - name: Unit tests run: zig build test - name: End-to-end tests run: zig build e2e - name: Native Git import differential tests timeout-minutes: 5 run: zig build git-import-e2e - name: Real-Git export differential tests run: zig build git-export-e2e - name: Deterministic simulation run: zig build dst # The seeded fuzz corpus, bounded. The targets also reach `zig build test` # through fixed-seed smoke cases, but this step exercises every registered # target through the real fuzz runner. # # `zig build fuzz` runs ONE target per invocation, so this sweeps them. # `--list` exits successfully and writes the complete registration table; # unlike scraping a failed bare invocation, its output is not truncated by # Zig's build-failure summary on Linux. A target added to fuzz_tests.zig is # therefore fuzzed automatically, with no second list to drift out of sync. # # Seed and rounds are fixed, not random: a red CI run has to be # reproducible verbatim on a developer machine. Deeper sweeps belong to # the out-of-band runner with its own budget. - name: Seeded fuzz sweep timeout-minutes: 10 env: FUZZ_SEED: 1 # 100, not 2000. Per-target cost varies by ~276x across the corpus — # artifact_parse_garbage runs 100 rounds in 0.18s while # logstore_pack_same_path_round_trip takes 49s, because it performs a # full store pack per round. The original 2000 was extrapolated from # one cheap target and made the sweep unable to finish inside this # step's own timeout: that single target alone would need ~16 minutes. # Measured at 100 rounds the whole sweep is ~76s, which is the budget # this number is chosen against, not a guess. # # What this buys is REGRESSION DETECTION — every target still runs # clean on every push. It is not a bug hunt: finding new defects needs # depth, and depth belongs in an out-of-band run with its own budget, # where a 30-minute sweep costs nobody a merge. FUZZ_ROUNDS: 256 run: | set -euo pipefail targets="$(zig build fuzz -- --list)" if [ -z "${targets}" ]; then echo "no fuzz targets enumerated — the usage format changed and this sweep is vacuous" >&2 exit 1 fi count=0 for target in ${targets}; do echo "fuzz: ${target} (seed ${FUZZ_SEED}, rounds ${FUZZ_ROUNDS})" zig build fuzz -- "${target}" --seed "${FUZZ_SEED}" --rounds "${FUZZ_ROUNDS}" count=$((count + 1)) done echo "fuzz sweep: ${count} targets at seed ${FUZZ_SEED}" # The tightest per-step bound in the battery, and deliberately so: smoke # spawns a server, which is the one step that can hang without producing # output. The job ceiling would eventually catch it; this catches it while # the log still reads as a failed step rather than a dead run. - name: Fossil interop smoke timeout-minutes: 5 run: zig build smoke # The differential transcript: one scripted session through # fossil and zit, compared step for step; a divergence that is neither # deliberate nor tracked fails the step. - name: fossil-muscle-memory timeout-minutes: 5 run: zig build muscle # The fossil -> zit -> fossil round trip, on a corpus built here. # `zig build e2e` can only ever spawn zit, so nothing in the Zig suite can # assert that a STOCK fossil accepts what zit served; this step is the only # thing in the battery that closes that loop, and the reason the harness is # a script rather than a test. Small corpus deliberately — this proves the # protocol, not throughput; a real corpus is an out-of-band run. - name: Fossil round trip timeout-minutes: 5 run: | set -eu work=$(mktemp -d) origin="$work/origin" mkdir -p "$origin" # A corpus with the pieces the round trip has actually lost before: # several check-ins, and a shun row (a takedown record whose loss is a # safety defect, not a cosmetic one). fossil init "$origin/o.fossil" >/dev/null ( cd "$origin" && fossil open o.fossil >/dev/null for n in a b c; do printf 'content %s\n' "$n" > "$n.txt" fossil add "$n.txt" >/dev/null fossil commit -m "$n" --user-override ci >/dev/null done fossil close >/dev/null ) # Fossil has no `shun` COMMAND -- shunning is a web-UI action -- so the # row goes in the way that page writes it. The hash is deliberately one # the repository does NOT hold (a pre-emptive block, which fossil # allows): that isolates what this step is for, the CARRIAGE of the # row, from the separate question of suppressing content that exists. fossil sqlite3 -R "$origin/o.fossil" \ "insert into shun(uuid,mtime,scom) values('0000000000000000000000000000000000000000', strftime('%s','now'),'ci round-trip fixture');" # Fail loudly if the fixture did not take. A silently-empty shun table # would leave this step passing while testing nothing. # Digits only: `fossil sqlite3` quotes its output on some versions, # and a fixture guard comparing '1' against 1 fails for a reason # that has nothing to do with the fixture. rows=$(fossil sqlite3 --readonly -R "$origin/o.fossil" "select count(*) from shun;" | tr -cd '0-9') [ "$rows" = "1" ] || { echo "fixture failed: shun rows=$rows, expected 1" >&2; exit 1; } scripts/roundtrip-fossil.sh "$origin/o.fossil" "$work/rt" - name: Fossil archive differential timeout-minutes: 5 run: | set -eu work=$(mktemp -d) origin="$work/origin" mkdir -p "$origin" export FOSSIL_HOME="$work" # A corpus whose whole point is the PERMISSION token: a plain file, an # executable, a symbolic link, and a nested path. An archive of only # plain files would compare "identical" while testing nothing about # the bit that makes ./configure run. fossil init "$origin/o.fossil" >/dev/null ( cd "$origin" && fossil open o.fossil >/dev/null fossil setting allow-symlinks on >/dev/null mkdir -p src printf 'readme\n' > README printf 'int main(){}\n' > src/main.c printf '#!/bin/sh\necho hi\n' > configure chmod +x configure ln -s src/main.c link.c fossil add README src/main.c configure link.c >/dev/null fossil commit -m base --user-override ci >/dev/null fossil close >/dev/null ) # Fail loudly if the fixture did not take. Fossil records `x` and `l` # in the manifest's F cards only when it saw them on disk, and a # corpus that silently lost them leaves this step passing while # comparing four ordinary files. # The check-in is NOT resolved here: the harness resolves and # validates it, and a second copy of that query is a second place for # `fossil sqlite3`'s cross-version quoting to bite. # 2>&1: the per-format comparison (member sets, modes, contents, and # the "kinds compared" line the guard below reads) is written to # STDERR by archive-fossil-diff.sh, so capturing only stdout left # diff.log without the one line this step asserts on -- the guard # counted 0 on every platform and the step could never pass. # pipefail: without it the pipeline takes tee's status, so the # differential's own failure -- including its exit 2 for a missing # tool -- was swallowed and only the guard could ever fail this step. set -o pipefail scripts/archive-fossil-diff.sh "$origin/o.fossil" "" "$work/tb" 2>&1 | tee "$work/diff.log" # The differential reports which kinds it actually compared; a run # that never saw an executable or a symlink proved nothing about the # case this step exists for. # Once per format: tar and zip carry the mode in completely different # fields, so a corpus that exercised one and not the other would leave # half the comparison vacuous. kinds=$(grep -c 'kinds compared: exec, file, symlink' "$work/diff.log" || true) [ "$kinds" = "2" ] \ || { echo "fixture failed: expected exec+symlink coverage in both formats, got $kinds" >&2; exit 1; }