Files
BFE Engineering 249c311953 ci: self-hosted code quality grade and badge
A new quality job runs the linter battery (clippy, cppcheck, shellcheck,
ruff, lizard, jscpd, cargo-audit per crate, scc for the LOC denominator)
and feeds tools/quality/score.py: findings convert to SQALE remediation
minutes, debt ratio grades A-F on SonarQube's published grid, and a
separate worst-of security axis can only drag the overall grade down.
The job uploads the full quality.json breakdown and fails when the
security grade is worse than C.

The badges job now renders all four SVGs in-runner with anybadge
(shields hex palette); the previous img.shields.io curls were the one
external-service dependency left in the pipeline. quality.svg is seeded
at the current locally-computed grade (A, debt ratio 0.42%).

Also fixes the two ruff findings the battery surfaced in flowgen.py.
2026-08-31 16:18:38 -06:00

366 lines
16 KiB
YAML

# 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
- 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