CI pipeline
CI exists to enforce one invariant: no repo can merge a change that breaks the
emulator boot or diverges from the shared contract. Because the stack is split
across sibling repos (meta,
runtime,
launcher,
buildroot_os), that guarantee has to
be cross-repo: a launcher change is only really green once it has been baked
into an image and passed the A1 acceptance test.
Two tiers
| Tier | Runs on | Cost | What it protects |
|---|---|---|---|
| Fast per-repo | every push/PR | seconds–minutes | lint, unit tests, contract + version-pin parity |
| Expensive image + A1 | every push/PR (skippable by label) | tens of minutes warm, hours cold | the real boot → game session → reboot on a built image |
Fast per-repo jobs
| Repo | Job(s) |
|---|---|
runtime | make lint + make test (Python), C compile-check, contract parity (gen_from_contract.py --check — committed constants must match meta/shared/hardware.toml), install-script parity |
launcher | make setup + make lint + make test (checks out runtime as an editable sibling dependency) |
buildroot_os | contract parity (scripts/gen_hardware.py --check) + version-pin parity (scripts/check_pins.py: meta/versions.env vs package/renpy/renpy.mk, the Buildroot submodule tag, and the Buildroot config's target-Python / host-Cython pins) |
The parity jobs check out meta (and, for the pin check, the Buildroot
submodule) side-by-side, matching the sibling layout the scripts resolve by
default.
The expensive job — image-e2e.yml (reusable)
buildroot_os/.github/workflows/image-e2e.yml is a reusable workflow
(workflow_call) that every repo's CI invokes. It:
- checks out the four repos side-by-side at the refs the caller passes;
- frees disk, restores the
.dl/.ccache/output caches; - builds
inky_qemu_defconfigvia./build.sh qemu(containerizedbr.sh); - runs
make e2e— the A1 test — against the freshly built image; - uploads the A1 failure artifacts (frame PNGs + guest serial) on failure.
Because br.sh mounts the sibling runtime/launcher checkouts via
OVERRIDE_SRCDIR, the built image contains the PR's code, not the pinned
tags.
The cross-repo checkout matrix
The trap: a launcher PR must build the image with its branch but the
default branches of the others — a same-named branch may not exist
elsewhere. So each caller passes an explicit matrix; it never assumes
branch names line up.
| Caller (PR in…) | buildroot_os_ref | runtime_ref | launcher_ref | meta_ref |
|---|---|---|---|---|
buildroot_os | PR head | main | main | main |
runtime | main | PR head | main | main |
launcher | main | main | PR head | main |
Each caller sets only its own input; the rest fall back to the main defaults
declared in image-e2e.yml.
# launcher/.github/workflows/ci.yml
image-e2e:
uses: einky/buildroot_os/.github/workflows/image-e2e.yml@main
with:
launcher_ref: ${{ github.event.pull_request.head.sha || github.sha }}
secrets: inherit
Caching
The Mesa/LLVM build is hours cold, minutes warm, so caching is load-bearing:
.dl/(Buildroot downloads) and.ccache/persist across runs.- Keys are pinned to the Buildroot submodule SHA plus a manual
cache_bustinput — a corrupted entry can otherwise wedge every build; bumpcache_bustto discard it, and a Buildroot bump starts clean automatically. - The whole output tree is cached too, but only advisorily: the key
includes the
inky_qemu_defconfighash, so a defconfig change misses the cache and forces a clean build (config-only changes don't otherwise rebuild a package).
Runner requirements & QEMU timing
- Docker (
br.shis containerized) and tens of GB of disk — the job frees the runner's preinstalled toolchains first; move to a larger runner if the build outgrows the default. qemu-system-aarch64(fromqemu-system-arm) + Pillow for the A1 harness.- CI runs QEMU under TCG (no KVM for aarch64-on-x86), so the guest is
several times slower than a laptop. A1 scales every deadline with
E2E_TIMEOUT_MULT(default4in CI) rather than hard-coding CI timeouts.
Skipping, nightly, and secrets
- Docs-only changes: label the PR
docs-onlyand theimage-e2ejob is skipped (the fast parity/lint jobs still run). - Nightly pinned build (
nightly.yml): once a day it builds withuse_overrides: false(INKY_NO_OVERRIDE=1) — i.e. from the pinned tags in the Buildroot packages, not the working trees — to catch pin drift the PR jobs (which always override) can't see. This needs the pinned sources to be fetchable (public, or a credentialed token in the container). - Secrets: the sibling repos may be private, so the reusable workflow takes
an optional read-only
SIBLING_CHECKOUT_TOKEN(passed viasecrets: inherit) to clone them; it falls back to the job token for public/same-repo runs.
Reproducing CI locally
# fast parity gate (from buildroot_os, meta checked out as a sibling)
python3 scripts/gen_hardware.py --check
python3 scripts/check_pins.py
# the full image + A1 (what image-e2e.yml runs)
./build.sh qemu
E2E_TIMEOUT_MULT=1 make e2e # bump the mult on a slow/loaded host
What a broken commit trips
| Break | Caught by |
|---|---|
| lint / type / unit-test regression in runtime or launcher | that repo's fast lint-test job |
committed constants edited out of sync with hardware.toml | contract-parity (runtime and buildroot_os) |
meta/versions.env bumped but a Buildroot mirror not | check_pins.py (buildroot_os) |
| a change that breaks boot / frame pipeline / input / session / reboot | image-e2e A1 (in whichever repo's PR introduced it) |
| a pinned runtime/launcher tag that no longer builds or passes A1 | the nightly pinned build |