ajhahn.de
← FlashOS
YAML 219 lines
# 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