name: ci
# Every push on every branch, plus pull requests. Deliberately unfiltered: the
# 0.1.0 release is developed on `release-0.1`, and a default-branch-only gate
# would leave the entire release unverified until the merge that is supposed to
# be the safe part.
on:
push:
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
# 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
tarball="zig-${ZIG_TARGET}-${ZIG_VERSION}.tar.xz"
curl -fsSL -o "$tarball" "https://ziglang.org/download/${ZIG_VERSION}/${tarball}"
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$tarball" | awk '{print $1}')"
else
actual="$(shasum -a 256 "$tarball" | awk '{print $1}')"
fi
if [ "$actual" != "${ZIG_SHASUM}" ]; then
echo "zig tarball digest mismatch: expected ${ZIG_SHASUM}, got ${actual}" >&2
exit 1
fi
tar -xf "$tarball"
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: 100
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