# bfe-core1106-sdk CI. # # Policy (mirrors flare-edge): only GitHub-owned actions get the repo token; the one # third-party helper (taiki-e/install-action) is pinned and never handed a token. # Every job runs on GitHub-hosted runners: no self-hosted runner may be reachable # from this repo's workflows (ADR-0007: public-repo fork PRs would otherwise be # able to run code on private infrastructure). kernel-build is dispatch-only. name: ci on: push: paths-ignore: ['.github/badges/**'] pull_request: workflow_dispatch: concurrency: group: ci-${{ github.ref }} cancel-in-progress: true # Least privilege by default: every job gets a read-only token; only `badges` # (which commits rendered SVGs) overrides this with contents: write below. permissions: contents: read jobs: test: runs-on: ubuntu-latest timeout-minutes: 25 outputs: passed: ${{ steps.result.outputs.passed }} coverage: ${{ steps.result.outputs.coverage }} steps: - uses: actions/checkout@v4 - name: cargo test (all crates) run: | set -o pipefail : > /tmp/test.log for d in sim tools/config-lint qemu/rs485-bridge qemu/tests/clockprobe; do echo "== cargo test in $d ==" | tee -a /tmp/test.log ( cd "$d" && cargo test --locked ) 2>&1 | tee -a /tmp/test.log done - name: coverage (cargo-llvm-cov on sim) run: rustup component add llvm-tools-preview - uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc # v2.86.7 with: tool: cargo-llvm-cov - name: run coverage working-directory: sim run: cargo llvm-cov --locked --json --summary-only --output-path /tmp/cov.json - name: parse results id: result run: | passed=$(grep -oE '[0-9]+ passed' /tmp/test.log | awk '{s+=$1} END{print s+0}') pct=$(python3 -c 'import json;print("%.0f"%json.load(open("/tmp/cov.json"))["data"][0]["totals"]["lines"]["percent"])') echo "passed=$passed" >> "$GITHUB_OUTPUT" echo "coverage=$pct" >> "$GITHUB_OUTPUT" echo "tests passed: $passed | sim line coverage: ${pct}%" mcdc: # 100% MC/DC (condition coverage) enforced on every Tier-1 driver harness. runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: install gcc-14 run: sudo apt-get update -qq && sudo apt-get install -y -qq gcc-14 - name: enforce 100% MC/DC on drivers/*/test run: | fail=0; ran=0 for t in drivers/*/test; do [ -f "$t/Makefile" ] || continue ran=1 echo "== MC/DC: $t ==" make -C "$t" check CC=gcc-14 GCOV=gcov-14 || fail=1 done [ "$ran" = 1 ] || { echo "no driver MC/DC harnesses found"; exit 1; } exit $fail bench: # Smoke-run the sim micro-benchmarks and emit the ns/op trend JSON. Regression # gating against stored history is future work (no flare-edge pattern to copy). runs-on: ubuntu-latest timeout-minutes: 20 steps: - uses: actions/checkout@v4 - name: run sim benchmarks working-directory: sim run: | cargo bench --locked --bench sim_bench 1> bench.txt 2> bench.json echo "== timings =="; cat bench.txt echo "== trend json =="; grep '"bench"' bench.json - name: run rs485-bridge benchmarks working-directory: qemu/rs485-bridge run: | cargo bench --locked --bench bridge_bench 1> bench.txt 2> bench.json echo "== timings =="; cat bench.txt echo "== trend json =="; grep '"bench"' bench.json qemu-tools: # The qemu/ device-sim build tooling must stay healthy on a plain hosted # runner: shellcheck the scripts, build the initramfs (pinned busybox, # fail-closed sha), and build the A/B disk image (unprivileged mkfs -d). # Booting needs a zImage and therefore lives in kernel-build's smoke step. runs-on: ubuntu-latest timeout-minutes: 20 steps: - uses: actions/checkout@v4 - name: shellcheck qemu scripts run: | sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck shellcheck -x qemu/*.sh qemu/tests/*.sh build/*.sh \ qemu/rootfs/etc/warden-lib.sh qemu/rootfs/etc/rc \ qemu/rootfs/sbin/init qemu/rootfs/init tests/mk-bootimg/*.sh \ tests/fetch-vendor/*.sh tests/fetch-buildroot-tarball/*.sh - name: cache apt archives (python3-pil) # Same cost class as the busybox binary cached below: a system package # plus its libjpeg/libpng transitive deps, downloaded fresh on every # push otherwise. uses: actions/cache@v4 with: path: /var/cache/apt/archives key: apt-archives-python3-pil-${{ runner.os }} - name: ui-drive driver and image tools (offline) # qmp.py's drive() with QMP and the control channel faked, plus # imgtools' self-test: the per-step ok/fail/fatal contract and the # region reference math, no VM needed. run: | sudo apt-get install -y -qq python3-pil python3 qemu/tests/imgtools.py selftest python3 qemu/tests/test_qmp_drive.py - name: imgtools bench (smoke, printed for trend-watching) # Same pattern as the sim/rs485-bridge bench job above: no stored # baseline yet, just a number in the log so a phash/structural # regression (DCT size, downscale filter, occupancy thresholds) # is visible instead of only showing up as an unexplained slower # flow run later. run: python3 qemu/tests/imgtools.py bench - name: mk-bootimg probe regression tests # Guards issue #17 (mkimage's non-zero exit sinking the probe's grep # pipeline under set -o pipefail) on every push/PR, not only on the # next workflow_dispatch that happens to exercise mk-bootimg.sh for # real via kernel-build. run: bash tests/mk-bootimg/run-probe-tests.sh - name: run.sh argv ordering regression test # Pins the CTL-before-RS485 pci-serial argv order that # rootfs/sbin/init's ttyS0-vs-ttyS1 alias depends on: a swap here # reproduces run.sh:131-133's own incident, Modbus frames landing # on the debug channel. All offline (a stub qemu-system-arm on # PATH), so it runs on every push/PR, not only a real boot. run: bash qemu/tests/run-sh-args-test.sh - name: mkimage.sh SEED_DIR regression test # Only ui-drive.sh --seed (a real VM boot) exercises this hook # otherwise; this builds the same unprivileged mkfs.ext4 image and # reads it back with debugfs, no VM needed. run: bash qemu/tests/seed-dir.sh - name: qemu_stage_rootfs permission regression test # Git tracks only the executable bit, so a fresh checkout can land # the source etc/shadow world-readable under a permissive umask; # this pins the staged copy at 0600 regardless of the source mode. run: bash qemu/tests/stage-rootfs-perms.sh - name: fetch-vendor regression tests # --check state machine (MISSING/OK/DRIFTED), --help, and the # clone stall guard, against local throwaway repos: no network. run: bash tests/fetch-vendor/run-fetch-vendor-tests.sh - name: fetch-buildroot-tarball regression tests # Retry-on-mismatch, cleanup, and the already-verified # short-circuit, against a fake curl on PATH: no network. run: bash tests/fetch-buildroot-tarball/run-fetch-buildroot-tarball-tests.sh - name: mk-bootimg boot.img validation regression tests # Guards issue #22 (a missing/erroring fdtget silently skipping the # data-position check) plus the FIT metadata and per-image # data-position %512 checks and the embedded-data-FIT check. run: bash tests/mk-bootimg/run-boot-img-validate-tests.sh - name: mk-bootimg --help regression test # Pins --help against its own header comment so a hardcoded line # range can't silently start printing code again the next time the # header grows or shrinks (the bug fetch-vendor.sh's --help had). run: bash tests/mk-bootimg/run-help-tests.sh - name: qemu-tools CI wiring regression test # Catches a regression test shipping in this job without this job # ever calling it -- the exact gap run-probe-tests.sh sat in before # the step above wired it in. run: bash tests/mk-bootimg/run-ci-wiring-tests.sh - name: cache pinned busybox uses: actions/cache@v4 with: path: qemu/out/busybox-armv7l key: busybox-armv7l-${{ hashFiles('qemu/busybox.sha256') }} - name: build initramfs run: bash qemu/mkinitramfs.sh - name: build A/B disk image run: bash qemu/mkimage.sh patches-apply: # The RV1106 series must apply cleanly onto pristine linux-6.18.46. runs-on: ubuntu-latest timeout-minutes: 20 steps: - uses: actions/checkout@v4 - name: cache pristine kernel tarball uses: actions/cache@v4 with: path: ~/linux-6.18.46.tar.xz key: linux-6.18.46-tarball - name: fetch + verify pristine (shared fail-closed fetcher) run: bash build/fetch-kernel-tarball.sh "$HOME/linux-6.18.46.tar.xz" - name: apply the series in order run: | tar -C /tmp -xf ~/linux-6.18.46.tar.xz for p in patches/*.patch; do git -C /tmp/linux-6.18.46 apply --whitespace=nowarn "$GITHUB_WORKSPACE/$p" \ && echo "applied $p" || { echo "FAILED to apply $p"; exit 1; } done echo "full series applied cleanly onto pristine 6.18.46" # Automatic artifact GC (mirrors flare-edge). An exceeded account-wide Actions # storage quota blocks ALL new runs (startup_failure), not just uploads, so # before a new kernel artifact is uploaded, drop older kernel-rv1106 artifacts # beyond the newest few: storage stays bounded across dispatches. gh + the # built-in token (no third-party action); needs actions:write to delete. # Best-effort: it never fails the run, so it can't block the build that needs it. prune-artifacts: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest timeout-minutes: 10 permissions: actions: write steps: - name: Keep only the newest few kernel artifacts env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} KEEP: "3" run: | ids=$(gh api --paginate "repos/$REPO/actions/artifacts?per_page=100" \ --jq "[.artifacts[] | select(.name==\"kernel-rv1106\")] | sort_by(.created_at) | reverse | .[$KEEP:] | .[].id" \ 2>/dev/null) || { echo "list failed; skipping"; exit 0; } n=0 for id in $ids; do if gh api -X DELETE "repos/$REPO/actions/artifacts/$id" 2>/dev/null; then n=$((n + 1)); echo "pruned artifact $id" fi done echo "pruned $n old kernel artifact(s); kept newest $KEEP" exit 0 kernel-build: # Full hermetic build on a GitHub-hosted runner (ADR-0007; supersedes the # self-hosted half of ADR-0004: a self-hosted runner must never be reachable # from a public repo's workflows). Manual-dispatch by design: a full kernel # build is heavy; trigger via `gh workflow run ci.yml` / the Actions UI. if: github.event_name == 'workflow_dispatch' needs: [prune-artifacts] runs-on: ubuntu-latest timeout-minutes: 60 steps: - uses: actions/checkout@v4 - name: install cross toolchain + kernel build deps + qemu run: | sudo apt-get update -qq sudo apt-get install -y -qq gcc-arm-linux-gnueabihf qemu-system-arm \ cpio bc bison flex libssl-dev ccache - name: provision `python` (SDK quirk, the build calls bare python) run: | mkdir -p "$RUNNER_TEMP/bin" ln -sf "$(command -v python3)" "$RUNNER_TEMP/bin/python" echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" - name: cache pristine kernel tarball uses: actions/cache@v4 with: path: ~/linux-6.18.46.tar.xz key: linux-6.18.46-tarball # Ephemeral runners rebuild the whole tree every dispatch (~9 min of # compile); ccache recovers most of it for an unchanged/lightly-changed # series. Keyed on the config + patches so a real change misses cleanly. - name: cache ccache uses: actions/cache@v4 with: path: ~/.ccache key: kbuild-ccache-${{ hashFiles('build/warden_defconfig', 'patches/*.patch') }} restore-keys: kbuild-ccache- - name: build zImage + rv1106-warden.dtb env: # WORK must be OUTSIDE the repo checkout: build-kernel.sh applies the patch # series with `git apply`, which silently ignores out-of-subdir paths when # run inside another git repo (issue #1). $RUNNER_TEMP is outside the checkout. WORK: ${{ runner.temp }}/kbuild-out # The kernel is freestanding; the generic arm cross toolchain links it. CROSS_COMPILE: arm-linux-gnueabihf- WARDEN_CCACHE: 1 CCACHE_DIR: /home/runner/.ccache # Matched module set for the out-of-tree wifi/BT drivers (issue #4: # a stale 5.10 .ko was all the panel had). Full `make modules` runs # (zImage alone emits no Module.symvers); only these dirs' .ko files # are collected/uploaded. WARDEN_MODULES_COLLECT: drivers/net/wireless/aic8800 # KERNEL_TARBALL is exported from the SHELL so $HOME expands. A literal # `~` in a YAML env: value is never tilde-expanded and broke every # dispatch until caught in review. run: | export KERNEL_TARBALL="$HOME/linux-6.18.46.tar.xz" bash build/build-kernel.sh ccache -s | head -4 # Boot smoke under QEMU: the zImage this job just built must reach the # initramfs sentinel on -M virt (verified 2026-08-29: the canonical # config boots virt as-is). FAIL-CLOSED on a missing qemu-system-arm. - name: boot smoke (qemu-system-arm -M virt) run: | bash qemu/mkinitramfs.sh bash qemu/tests/boot-smoke.sh \ "$RUNNER_TEMP/kbuild-out/linux-6.18.46/arch/arm/boot/zImage" # Best-effort: the build IS the gate. Uploading the zImage/dtb to GitHub # artifact storage can fail on an account-wide storage-quota hit (recalculated # every 6-12h) that has nothing to do with this build. Don't red-X a good # kernel build over it. - uses: actions/upload-artifact@v4 continue-on-error: true with: name: kernel-rv1106 path: | ${{ runner.temp }}/kbuild-out/linux-6.18.46/arch/arm/boot/zImage ${{ runner.temp }}/kbuild-out/linux-6.18.46/arch/arm/boot/dts/rockchip/rv1106-warden.dtb ${{ runner.temp }}/kbuild-out/modules-out/*.ko # Short so kernel images self-expire instead of piling into the # account-wide storage quota; the prune-artifacts job above is the # active bound, this is the backstop. retention-days: 5 quality: # Self-hosted Codacy-style grade: a linter battery feeds # tools/quality/score.py (SQALE debt ratio + a separate security axis; # thresholds documented in the script). No external assessment service; # the badge is rendered and committed by the badges job. runs-on: ubuntu-latest timeout-minutes: 20 outputs: grade: ${{ steps.score.outputs.grade }} color: ${{ steps.score.outputs.color }} steps: - uses: actions/checkout@v4 - name: install analyzers run: | sudo apt-get update -qq && sudo apt-get install -y -qq cppcheck shellcheck pip install --quiet lizard ruff sudo npm install --silent -g jscpd curl -fsSL -o /tmp/scc.tar.gz \ https://github.com/boyter/scc/releases/download/v3.6.0/scc_Linux_x86_64.tar.gz sudo tar -C /usr/local/bin -xzf /tmp/scc.tar.gz scc rustup component add clippy - uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc # v2.86.7 with: tool: cargo-audit - name: collect linter outputs run: | Q="$RUNNER_TEMP/qual"; mkdir -p "$Q" scc --format json \ --exclude-dir .git,target,patches,kernel,docs/workflows,.github/badges \ . > "$Q/scc.json" : > "$Q/clippy.jsonl" for d in sim tools/config-lint qemu/rs485-bridge qemu/tests/clockprobe; do ( cd "$d" && cargo clippy --locked --all-targets --message-format=json \ 2>/dev/null >> "$Q/clippy.jsonl" ) ( cd "$d" && cargo audit --json -q > "$Q/audit-$(basename "$d").json" ) done shellcheck -f json1 qemu/*.sh qemu/tests/*.sh build/*.sh \ qemu/rootfs/init qemu/rootfs/etc/rc qemu/rootfs/sbin/init \ > "$Q/shellcheck.json" || true cppcheck --enable=warning,style,performance,portability --inline-suppr \ --xml drivers/ 2> "$Q/cppcheck.xml" lizard -C 10 --csv sim/src qemu/rs485-bridge/src tools/config-lint/src \ drivers/ tools/flowgen.py qemu/tests/clockprobe/src > "$Q/lizard.csv" ruff check --output-format=json tools/ qemu/ > "$Q/ruff.json" || true jscpd --silent --reporters json --output "$Q" \ --pattern '**/*.{rs,c,h,sh,py}' \ --ignore '**/target/**,**/patches/**,**/kernel/**' . - name: score id: score run: | python3 tools/quality/score.py "$RUNNER_TEMP/qual" \ --out "$RUNNER_TEMP/qual/quality.json" J="$RUNNER_TEMP/qual/quality.json" echo "grade=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['grade'])" "$J")" >> "$GITHUB_OUTPUT" echo "color=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['badge_color'])" "$J")" >> "$GITHUB_OUTPUT" - name: security gate run: python3 tools/quality/score.py "$RUNNER_TEMP/qual" --gate-security C - uses: actions/upload-artifact@v4 with: name: quality-report path: ${{ runner.temp }}/qual/quality.json retention-days: 30 badges: needs: [test, quality] if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: write steps: - uses: actions/checkout@v4 - name: install tools run: | sudo apt-get update -qq && sudo apt-get install -y -qq cloc pip install --quiet anybadge - name: render badges # Rendered locally with anybadge: the committed SVG must not depend # on any external service, at view time or at render time. env: PASSED: ${{ needs.test.outputs.passed }} COVERAGE: ${{ needs.test.outputs.coverage }} GRADE: ${{ needs.quality.outputs.grade }} QCOLOR: ${{ needs.quality.outputs.color }} run: | mkdir -p .github/badges loc=$(cloc --quiet --json --exclude-dir=target,build,build-target,patches,data,docs . \ | python3 -c 'import sys,json; print(json.load(sys.stdin)["SUM"]["code"])') col='#fe7d37'; [ "${COVERAGE:-0}" -ge 60 ] && col='#dfb317'; [ "${COVERAGE:-0}" -ge 80 ] && col='#4c1' anybadge --overwrite --label="lines of code" --value="$loc" --color='#007ec6' \ --file=.github/badges/loc.svg anybadge --overwrite --label=tests --value="${PASSED} passing" --color='#4c1' \ --file=.github/badges/tests.svg anybadge --overwrite --label=coverage --value="${COVERAGE}%" --color="$col" \ --file=.github/badges/coverage.svg anybadge --overwrite --label="code quality" --value="$GRADE" --color="$QCOLOR" \ --file=.github/badges/quality.svg - name: commit badges run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add .github/badges/*.svg if ! git diff --cached --quiet; then git commit -m "ci: update badges [skip ci]" git push fi