Coverage of lib/ by the host tests, and badges in the README
CI / build (push) Successful in 9m9s

scripts/coverage.sh builds the host tests with coverage counters and
reports with gcovr: 94.6% of the 3,193 lines of lib/ today (lib/SD and
src/ have no host tests and aren't counted). CI runs it on every push,
puts the figure in the job's summary, and on main publishes a coverage
badge and a latest-release badge to the branch 'badges'. The README shows
them next to Gitea's own badge for the workflow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-06 13:56:08 +02:00
co-authored by Claude Opus 5.5
parent edc140e30c
commit 5ecd5d003f
7 changed files with 110 additions and 4 deletions
+23 -3
View File
@@ -1,5 +1,6 @@
# CI and releases (docs/milestones/R1.md). # CI and releases (docs/milestones/R1.md).
# Any push: host tests, then the release firmware and the Debug Build. # Any push: host tests, the release firmware and the Debug Build, then the tests'
# coverage of lib/; on main, its badge is published to the branch `badges`.
# A tag v*: the same, then a Gitea release with the signed Update File. # A tag v*: the same, then a Gitea release with the signed Update File.
# Run by hand: the release of a tag that exists already (the ones from before CI). # Run by hand: the release of a tag that exists already (the ones from before CI).
# #
@@ -9,7 +10,7 @@
name: CI name: CI
on: on:
push: push:
branches: ['**'] branches: ['**', '!badges'] # badges holds what CI itself publishes: the coverage badge
tags: ['v*'] tags: ['v*']
workflow_dispatch: workflow_dispatch:
inputs: inputs:
@@ -32,7 +33,7 @@ jobs:
run: | run: |
apt-get update -qq apt-get update -qq
apt-get install -y -qq --no-install-recommends git build-essential openssl >/dev/null apt-get install -y -qq --no-install-recommends git build-essential openssl >/dev/null
pip install -q --no-cache-dir --root-user-action=ignore platformio pip install -q --no-cache-dir --root-user-action=ignore platformio gcovr
pio --version; df -h /pio | tail -1; ls /pio | head pio --version; df -h /pio | tail -1; ls /pio | head
- name: Check out - name: Check out
@@ -49,6 +50,25 @@ jobs:
if: github.event_name == 'push' if: github.event_name == 'push'
run: scripts/ci.sh run: scripts/ci.sh
- name: Coverage of lib/ by the host tests
if: github.event_name == 'push'
run: scripts/coverage.sh
# The README's badges are files on a branch of their own, replaced at each push to main.
- name: Publish the badges
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/coverage')
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
rm -rf /tmp/badges && mkdir /tmp/badges
cp .pio/coverage/coverage.svg .pio/coverage/summary.json /tmp/badges/
scripts/coverage_badge.py --plain release "$(git describe --tags --abbrev=0)" /tmp/badges/release.svg
cd /tmp/badges
git init -q -b badges .
git add .
git -c user.name="roro9stack CI" -c user.email="ci@git.twis.la" commit -q -m "Coverage of ${{ github.ref_name }} at ${{ github.sha }}"
git push -q --force "$(echo "${{ github.server_url }}" | sed "s#://#://ci:${GITEA_TOKEN}@#")/${{ github.repository }}.git" badges
- name: Which release - name: Which release
id: release id: release
run: | run: |
+4
View File
@@ -1,5 +1,7 @@
# roro9stack # roro9stack
[![CI](https://git.twis.la/twisla/roro9stack/actions/workflows/ci.yml/badge.svg?branch=main)](https://git.twis.la/twisla/roro9stack/actions?workflow=ci.yml) [![Coverage of lib/ by the host tests](https://git.twis.la/twisla/roro9stack/raw/branch/badges/coverage.svg)](#build-and-test-local-ci) [![Latest release](https://git.twis.la/twisla/roro9stack/raw/branch/badges/release.svg)](https://git.twis.la/twisla/roro9stack/releases/latest)
A multi-app firmware for the **M5Stack Cardputer ADV** with the **Cap LoRa-1262**. It's a Meshtastic-compatible mesh messenger, plus Wi-Fi tools, IRC, GNSS and more. Licensed GPL-3.0. A multi-app firmware for the **M5Stack Cardputer ADV** with the **Cap LoRa-1262**. It's a Meshtastic-compatible mesh messenger, plus Wi-Fi tools, IRC, GNSS and more. Licensed GPL-3.0.
- Domain language: [CONTEXT.md](CONTEXT.md) - Domain language: [CONTEXT.md](CONTEXT.md)
@@ -18,6 +20,8 @@ scripts/ci.sh
This runs the host-side unit tests (`test/`, `native` environment), then builds the firmware. The output is `.pio/build/cardputer-adv/firmware.factory.bin`. This runs the host-side unit tests (`test/`, `native` environment), then builds the firmware. The output is `.pio/build/cardputer-adv/firmware.factory.bin`.
`scripts/coverage.sh` runs the same tests with coverage counters and writes a line-by-line report to `.pio/coverage/index.html`. The badge above is its figure for `main`: the share of the lines of `lib/` that the host tests run. `lib/` is the logic that compiles on a PC; `lib/SD` (the card's driver) and `src/` (the Apps, the Services, everything that needs the device) have no host tests and aren't in that figure.
The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkconfig`, ADR 0006), so the first build after a fresh checkout takes about 4 minutes; later builds take under a minute. The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkconfig`, ADR 0006), so the first build after a fresh checkout takes about 4 minutes; later builds take under a minute.
## CI and releases ## CI and releases
+1 -1
View File
@@ -5,7 +5,7 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends git build-essential \ && apt-get install -y --no-install-recommends git build-essential \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir platformio RUN pip install --no-cache-dir platformio gcovr
# Toolchains and libraries are cached in a named volume mounted here. # Toolchains and libraries are cached in a named volume mounted here.
RUN mkdir -p /pio && chmod 777 /pio RUN mkdir -p /pio && chmod 777 /pio
+11
View File
@@ -46,3 +46,14 @@ build_flags =
platform = native platform = native
lib_ldf_mode = deep+ lib_ldf_mode = deep+
build_flags = -std=gnu++17 build_flags = -std=gnu++17
; The same tests, built to count which lines of lib/ they run (scripts/coverage.sh).
[env:native-coverage]
extends = env:native
build_flags =
${env:native.build_flags}
--coverage
-O0
extra_scripts =
${env.extra_scripts}
pre:scripts/coverage_link.py
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# How much of lib/ the host tests run: builds them with coverage counters, runs them, and reports.
# Usage: scripts/coverage.sh [out folder, default .pio/coverage]
# Out: summary.json (gcovr), coverage.svg (the README's badge), index.html (line by line).
# What it measures: the lines of lib/ that compile on a PC. Not lib/SD (the card's driver) and not
# src/ (the Apps, the Services, everything that needs the device): those have no host tests.
set -euo pipefail
source "$(dirname "$0")/_docker.sh"
DOCKER_EXTRA=()
OUT="${1:-.pio/coverage}"
run_in_container bash -c '
set -e
git config --global --add safe.directory "$PWD"
rm -rf .pio/build/native-coverage "'"$OUT"'"
mkdir -p "'"$OUT"'"
pio test -e native-coverage | tail -1
gcovr -r . --filter "lib/" --object-directory .pio/build/native-coverage \
--json-summary "'"$OUT"'/summary.json" --html-details "'"$OUT"'/index.html" --print-summary | tail -4
python3 scripts/coverage_badge.py "'"$OUT"'/summary.json" "'"$OUT"'/coverage.svg"
'
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Draws the README's coverage badge from gcovr's summary.
Usage: scripts/coverage_badge.py <summary.json> <out.svg>
scripts/coverage_badge.py --plain <label> <value> <out.svg> any other badge, in blue
Also prints one line, and appends a short table to $GITHUB_STEP_SUMMARY when CI sets it.
"""
import json
import os
import sys
def badge(label, value, color, title):
left, right = 6 * len(label) + 12, 7 * len(value) + 12
return f'''<svg xmlns="http://www.w3.org/2000/svg" width="{left + right}" height="20" role="img" aria-label="{label}: {value}">
<title>{title}</title>
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
<clipPath id="r"><rect width="{left + right}" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)"><rect width="{left}" height="20" fill="#555"/><rect x="{left}" width="{right}" height="20" fill="{color}"/><rect width="{left + right}" height="20" fill="url(#s)"/></g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text x="{left / 2}" y="14">{label}</text><text x="{left + right / 2}" y="14">{value}</text></g></svg>
'''
def main():
if sys.argv[1] == "--plain": # any other badge: --plain <label> <value> <out.svg>
label, value, out = sys.argv[2:5]
open(out, "w").write(badge(label, value, "#007ec6", f"{label}: {value}"))
return
summary = json.load(open(sys.argv[1]))
lines, branches = summary["line_percent"], summary["branch_percent"]
label, value = "lib coverage", f"{lines:.0f}%"
color = "#4c1" if lines >= 90 else "#a4a61d" if lines >= 75 else "#dfb317" if lines >= 60 else "#e05d44"
svg = badge(label, value, color, f"{label}: {value} of the lines of lib/ are run by the host tests")
open(sys.argv[2], "w").write(svg)
print(f"coverage of lib/: {lines:.1f}% of {summary['line_total']} lines, {branches:.1f}% of branches, {len(summary['files'])} files")
step = os.environ.get("GITHUB_STEP_SUMMARY")
if step:
worst = sorted((f["line_percent"], f["filename"]) for f in summary["files"] if f["line_total"] >= 10)[:5]
with open(step, "a") as out:
out.write(f"### Coverage of `lib/` by the host tests\n\n**{lines:.1f}%** of {summary['line_total']} lines, {branches:.1f}% of branches.\n\n")
out.write("| Least covered | Lines |\n|---|---|\n" + "".join(f"| `{name}` | {pct:.0f}% |\n" for pct, name in worst))
if __name__ == "__main__":
main()
+4
View File
@@ -0,0 +1,4 @@
# The coverage build must also link with --coverage (build_flags only reaches the compiler).
Import("env") # noqa: F821 (provided by PlatformIO)
env.Append(LINKFLAGS=["--coverage"]) # noqa: F821