# Per-push CI: host unit tests + rpi4b QEMU boot smoke. The rpi4b boot # suite takes 5–8 min; it doubles as the link / cross-compile check for # the rpi4b path (board_asm_defs.inc, mini-uart, BCM2711 timer, armstub, # …). The faster virt boot smoke that used to run here is frozen as of # 2026-06-17 (virt board deprioritized — rpi4b + real HW are the live # gates); revive by restoring the -Dboard=virt test-virt step. # # Toolchain note: build.zig hardcodes the `aarch64-elf-` binutils # prefix (matches the macOS Homebrew naming). Ubuntu's apt ships the # same tools under `aarch64-linux-gnu-`, so the install step below # symlinks them into PATH under the expected names. name: test on: push: branches: [main] pull_request: workflow_dispatch: # GitHub switches all JavaScript actions to the Node 24 runtime on # 2026-06-16. checkout@v5 already declares node24 and codecov-action@v5 # is a composite action (no Node runtime), but mlugg/setup-zig@v2 # (latest release) still declares node20 — upstream has no node24 # release yet. Forcing Node 24 now means every CI run proves the # pipeline survives the switch, instead of finding out on enforcement # day. Drop this once setup-zig ships a node24 release. env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: test: # ubuntu-24.04 (noble) ships QEMU 8.2, whose aarch64 system emulator # carries the `raspi4b` machine type the rpi4b boot smoke needs; # 22.04's QEMU 6.2 predates it (`unsupported machine type`). kcov is # absent from noble's repos but that is moot — the coverage step # already builds kcov v43 from source (jammy's apt v38 is too old to # read zig's DWARF), so nothing here depends on an apt kcov. runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v5 - name: install zig 0.16.0 uses: mlugg/setup-zig@v2 with: version: 0.16.0 - name: hygiene gate # Fails on trailing spaces, hard tabs in *.zig, CRLF, or # lowercase hex literals in src/. run: zig build check-hygiene - name: doc-drift gate # Deterministic doc-drift subset: fails the build only on a dead # repo-relative path referenced by an active public doc (README, # DOCUMENTATION, SETUP). Version/target/scenario mismatches are # printed as advisory warnings; the fuzzy prose review stays with # the human-driven /doc-drift pass. run: bash scripts/check_doc_drift.sh - name: install aarch64 binutils + mtools + qemu build deps run: | sudo apt-get update # mtools (mformat/mcopy/minfo/mdir) builds the rpi4b FAT32 test # disk in scripts/make_test_disk.sh — needed since rpi4b became # the sole boot gate (virt did not seed a backing image). # # No apt qemu: the `raspi4b` machine type landed in QEMU 9.0 # (Apr 2024); every GitHub-hosted runner's apt qemu predates it # (noble 8.2, jammy 6.2), so the next steps build qemu-system- # aarch64 from a pinned source release. The libs below are both # its build and (via the runtime .so they pull in) its run deps, # so this always-run step keeps a cache-hit qemu linkable. sudo apt-get install -y binutils-aarch64-linux-gnu mtools \ ninja-build pkg-config python3-pip python3-venv \ libglib2.0-dev libpixman-1-dev flex bison # build.zig invokes `aarch64-elf-objcopy` and `aarch64-elf-nm`; # Ubuntu's apt ships them as `aarch64-linux-gnu-*`. Symlinks # bridge the prefix without touching build.zig (Pi byte-identity # is sensitive to build.zig changes). sudo ln -sf /usr/bin/aarch64-linux-gnu-objcopy /usr/local/bin/aarch64-elf-objcopy sudo ln -sf /usr/bin/aarch64-linux-gnu-nm /usr/local/bin/aarch64-elf-nm # The rpi4b boot smoke needs QEMU >= 9.0 for the `raspi4b` machine. # Pin 11.0.1 — the version the boot contract in run_qemu_test.sh was # captured against locally — so CI emulates byte-for-byte what the # operator validates by hand (free-page checkpoints, scenario tally). # Built once per version, cached on the version key, then instant. - name: cache qemu-system-aarch64 (pinned source build) id: qemu uses: actions/cache@v4 with: path: ~/qemu-prefix key: qemu-aarch64-11.0.1-${{ runner.os }} - name: build qemu-system-aarch64 from pinned source if: steps.qemu.outputs.cache-hit != 'true' # Single-target build (aarch64-softmmu only) keeps it to a few # minutes; we drive QEMU headless (-display none), so the UI/tools # frontends are disabled. run: | ver=11.0.1 curl -fsSL -o /tmp/qemu.tar.xz "https://download.qemu.org/qemu-$ver.tar.xz" mkdir -p /tmp/qemu-src tar -xJf /tmp/qemu.tar.xz -C /tmp/qemu-src --strip-components=1 cd /tmp/qemu-src ./configure --prefix="$HOME/qemu-prefix" --target-list=aarch64-softmmu \ --disable-docs --disable-tools --disable-gtk --disable-sdl \ --disable-vnc --disable-curses --disable-werror make -j"$(nproc)" make install - name: put pinned qemu on PATH # GITHUB_PATH prepends for all later steps, so the boot smoke's # bare `qemu-system-aarch64` resolves to the pinned build, not apt. run: | echo "$HOME/qemu-prefix/bin" >> "$GITHUB_PATH" "$HOME/qemu-prefix/bin/qemu-system-aarch64" --version "$HOME/qemu-prefix/bin/qemu-system-aarch64" -machine help | grep -i raspi4b # FlashOS source modules are written in Flash (*.flash) and transpiled # to Zig at build time by flashc. build.zig resolves the compiler at # ~/Flash/zig-out/bin/flashc-stage1 by default; the pinned revision # lives in flash-toolchain.lock. Flash ships no prebuilt binaries, so # build the self-hosted stage1 from source at the pinned commit. The # binary is cached on the lock hash, so it only rebuilds when the pin # moves. - name: cache flashc (pinned Flash toolchain) id: flashc uses: actions/cache@v4 with: path: ~/Flash/zig-out/bin/flashc-stage1 key: flashc-stage1-${{ runner.os }}-${{ hashFiles('flash-toolchain.lock') }} - name: build flashc from the pinned Flash commit if: steps.flashc.outputs.cache-hit != 'true' # `zig build stage1` (not the bare `zig build`, which emits only the # stage0 bootstrap seed `flashc`) produces the self-hosted # `flashc-stage1` that flash-toolchain.lock pins. run: | commit=$(grep -oE '^flash-commit[[:space:]]*=[[:space:]]*[0-9a-f]{40}' flash-toolchain.lock | grep -oE '[0-9a-f]{40}') git clone https://github.com/ajhahnde/Flash.git ~/Flash git -C ~/Flash checkout "$commit" ( cd ~/Flash && zig build stage1 ) test -x ~/Flash/zig-out/bin/flashc-stage1 - name: host unit tests run: zig build test - name: build kcov v43 from source # Ubuntu jammy ships kcov v38 (2020). Its DWARF and breakpoint # handling predates two fixes that zig binaries need: addresses # that map to multiple lines (v39) and BFD state clearing # between files (v41) — without them kcov silently drops # coverage for most of the test binaries. Ubuntu 24.04 dropped # the kcov package entirely, so build the current release from # source instead of using apt. run: | sudo apt-get install -y binutils-dev libssl-dev libelf-dev zlib1g-dev libdw-dev libiberty-dev libcurl4-openssl-dev git clone --depth 1 --branch v43 https://github.com/SimonKagstrom/kcov /tmp/kcov-src cmake -S /tmp/kcov-src -B /tmp/kcov-build -DCMAKE_BUILD_TYPE=Release make -C /tmp/kcov-build -j"$(nproc)" sudo make -C /tmp/kcov-build install kcov --version - name: host unit tests with coverage # Coverage pass. Four things make this report trustworthy: # # - -Dcoverage builds the test binaries with the LLVM backend; # zig's self-hosted x86_64 backend (Debug default on # x86_64-linux) emits DWARF that kcov cannot read. # - Both zig cache tiers point at fresh run-local dirs. The # shared .zig-cache is restored from the Actions cache, and # zig satisfying test roots from stale artifacts there # silently shrinks the report. # - The binary count is checked against the addHostTest # registrations in build.zig, so a missing test root fails # the job instead of shrinking the badge. # - kcov output dirs are numbered (every zig test binary is # named `test`, so basenames collide) and merged into a # single report; the include filter is anchored to the # workspace so zig's own standard library stays out. # # The `zig build test` step above stays the green/red # correctness gate; kcov failures on individual binaries stay # non-fatal (|| true). run: | export ZIG_LOCAL_CACHE_DIR="$RUNNER_TEMP/cov-local" export ZIG_GLOBAL_CACHE_DIR="$RUNNER_TEMP/cov-global" zig build test -Dcoverage bins=$(find "$ZIG_LOCAL_CACHE_DIR" "$ZIG_GLOBAL_CACHE_DIR" -type f -name 'test*' -perm -111 2>/dev/null) expected=$(grep -c 'addHostTest(b, test_step' build.zig) found=$(echo "$bins" | grep -c .) echo "coverage: found $found test binaries, expected $expected" test "$found" -eq "$expected" || { echo "coverage: binary count mismatch" >&2; exit 1; } mkdir -p coverage/raw i=0 for bin in $bins; do i=$((i+1)) kcov --include-path="$GITHUB_WORKSPACE/src,$GITHUB_WORKSPACE/lib,$GITHUB_WORKSPACE/user_space" "coverage/raw/$i" "$bin" || true done kcov --merge coverage/merged coverage/raw/* echo "coverage: merged report contains:" find coverage/merged -name 'cobertura.xml' -o -name 'codecov.json' - name: upload coverage to codecov uses: codecov/codecov-action@v5 with: directory: ./coverage/merged fail_ci_if_error: false - name: rpi4b QEMU boot smoke # -Dci-login-seed seeds flash/flash before /bin/login so the # unattended boot authenticates with no typist. -Dboot-selftest runs # the in-kernel [TEST] harness (the 28-scenario / 32-checkpoint # boot-as-test path run_qemu_test.sh asserts). Both default OFF so # hardware deploys boot clean straight to a real login: prompt; the # watchdog needs both ON. run: zig build -Dboard=rpi4b -Dci-login-seed=true -Dboot-selftest=true test-rpi4b