Skip to content
zit Artifact [0b3882c6ab]
Login

Artifact [0b3882c6ab]

Artifact 0b3882c6ab65f9a4de656ae445e904bb7844e896386add20cd9b9d72677c2bf4:


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

      # 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.
          rows=$(fossil sqlite3 --readonly -R "$origin/o.fossil" "select count(*) from shun;")
          [ "$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; }