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: b607e9b9234790a008116ae5bdb71c6243b84b9fb42a53a9e70fde41c06c536a
- os: Linux
arch: X64
runner: linux-x64-zit
zig_target: x86_64-linux
zig_shasum: 1cbe9df9f27e6b78d14ccbca43b6703a404ef79ef1c463de901d7f088d4e2026
- os: Linux
arch: ARM64
runner: linux-arm64-zit
zig_target: aarch64-linux
zig_shasum: 9e8d11661d4ae3bd57702a3832781e23ad151dde5798e16a5ccd503f65234ff8
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.17.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; }