Artifact a98929b109c91cc53bd395c1470ed5ec239d420a6bd29d9ecc73c7f1fb7f621c:
- File
.github/workflows/ci.yml
— part of check-in
[ab3a1983cb]
at
2026-08-17 21:30:58
on branch trunk
— CI: assert fossil's version, do not only print it (#100)
smoke is the only lane that runs a real fossil against zit, so runners on different fossils make the conformance claim mean a different thing on each -- a regression against the newer can pass on the older, and newer-only behaviour reddens the older for something that is not a defect. Both are silent, because the version is printed hundreds of log lines before the failure it explains. fossil sqlite3 quoting its output on 2.26 but not 2.28 cost three round trips that way. The printed line stays; the release notes still need it. (user: dmestas, size: 16680) [annotate] [blame] [check-ins using]
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 # 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 (#62). - 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 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 # The fossil -> zit -> fossil round trip (#73), 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 # (#100), 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; }