GitHub-specific metadata: the CI workflow that calls ./ci.sh per
platform, issue and PR templates (if any), and CODEOWNERS hooks. Kept
deliberately thin per zackees/zccache#835 rule 6:
every CI step is a single run: ./ci.sh <gate> line. The actual logic
lives in ci/gates/*.py so the same bytes run on a developer laptop.
.github/
└── workflows/
└── ci.yml # 8-platform matrix; every step is `./ci.sh <gate>`
workflows/ci.yml declares one matrix entry per platform target:
linux-x86(ubuntu-latest, x86_64-unknown-linux-gnu)linux-x86-musl(ubuntu-latest, x86_64-unknown-linux-musl)linux-arm(ubuntu-24.04-arm, aarch64-unknown-linux-gnu)linux-arm-musl(ubuntu-24.04-arm, aarch64-unknown-linux-musl)mac-x86(macos-13, x86_64-apple-darwin)mac-arm(macos-14, aarch64-apple-darwin)windows-x86(windows-latest, x86_64-pc-windows-msvc)windows-arm(windows-11-arm, aarch64-pc-windows-msvc)
Each runner sets up uv + rustup, then runs each gate as a separate
named step with continue-on-error: true — except build, which is
fatal (a failing build short-circuits downstream gates because they'd
produce noise against an uncompiled tree). A final reporting step
collects step outcomes and exits non-zero with the list of failed
gates so the PR check surface is "exactly these gates need attention,"
not "the run failed, hunt through logs."
The historic anti-pattern is multi-line shell embedded in YAML —
unlintable, untestable, only validates when CI runs. Pushing logic
into ci/gates/<name>.py makes each gate import ci.gates.fmt; ci.gates.fmt.run() from a future tests/test_gates.py. The
workflow file only has to know which gates exist (it lists them by
name), not what they do.
- Write the gate at
ci/gates/<name>.pywithdef run() -> int. - Register it in
ci.py::GATE_ORDER. - Add a row under the matrix step list in
workflows/ci.ymlthat runs./ci.sh <name>with the appropriatecontinue-on-errorpolicy.
No multi-line shell — if you find yourself reaching for
run: |\n ..., the logic belongs in the gate, not the workflow.