mirror of
https://github.com/meshtastic/firmware.git
synced 2026-09-16 08:30:04 -04:00
* ci(test): shard the native test suite across a matrix Replace the single sequential runner with a matrix populated by bin/test-shards.py from the test/ tree: areas over --max-suites are split, smaller ones packed, and a --max-shards budget bounds the fan-out. A collector job merges the per-shard JUnit reports, checks the union against the canonical suite set, and states the verdict. Native PlatformIO Tests remains as the single required check over the matrix. Drop the --without-testing warm build. PlatformIO links every native test program to the same $BUILD_DIR/$PROGNAME, so the area run relinked each suite regardless. ccache carries the shared src objects between shards instead; one shard is flagged cache_writer so a single entry is saved. The coverage-event-policy and coverage-channel-table envs and the attribution canary move into their own matrix rows and job. Harden the new paths: bound the matrix row count so a branch cannot size the fan-out, reject multi-line or empty $GITHUB_OUTPUT values, fail the whole-run attribution gate on an empty expected set, upload exact report and tracefile names instead of globs, and pass the repo path to bin/lib/shuffle.sh as an argument rather than into bash -c source text. 12 shards, largest 9 suites. * ci(test): minimal test toolchain, cap shard runtime, fix pack overflow Add .github/actions/setup-native-test, used by the shard and canary jobs in place of setup-native. It drops the redundant second checkout, both submodules (src/mesh/generated is tracked, meshtestic is the hardware harness), cppcheck, and the adafruit-nrfutil, poetry and meshtastic pip installs, and folds in ccache and lcov. setup-base and setup-native are unchanged, so the firmware matrix and every other consumer keep theirs. Cap the shard job at 30 minutes. A lost runner held one for 48 of the 360 GitHub allows by default, and there are twelve of them. pack() could exceed --max-suites: ceil(total / cap) is a lower bound and whole areas do not divide, so three areas of 6 at cap 10 put 12 in one of two bins. Grow the bin count until every bin fits. Validate the fixed-env test_filter tokens against SUITE_RE. PlatformIO accepts globs there, and those tokens reach the same word-split and the same attribution gate as discovered names. Split with read -ra so a token cannot glob against the workspace either. Report the suite count rather than the length of the -f argument array, which counted every name twice. Trim comments to the one or two lines AGENTS.md asks for. * ci(test): quote the $GITHUB_OUTPUT redirects Applied to all five, including the three that predate this branch, so the file is consistent rather than half-converted.
696 lines
32 KiB
YAML
696 lines
32 KiB
YAML
name: Run Tests on Native platform
|
|
|
|
on:
|
|
workflow_call:
|
|
inputs:
|
|
suite_order_seed:
|
|
description: >-
|
|
Seed varying which suites share a shard. Empty (the default) means: the fixed declared
|
|
arrangement on pull_request, so a contributor's PR never turns red because of a pairing
|
|
they did not choose; commit-SHA-derived elsewhere. Set a number to force that exact
|
|
arrangement anywhere - that is how you replay a shuffled failure.
|
|
type: string
|
|
required: false
|
|
default: ""
|
|
max_suites_per_shard:
|
|
description: >-
|
|
Largest shard, in suites. Lower splits the matrix further: faster wall clock, more
|
|
runners. The floor per shard is checkout + toolchain + one src build, so below about 8
|
|
the fixed cost starts to dominate what is being parallelised.
|
|
type: number
|
|
required: false
|
|
default: 10
|
|
workflow_dispatch:
|
|
|
|
permissions: {}
|
|
|
|
env:
|
|
# Only pushes to the default branch (develop) populate the caches; PR / merge_group runs
|
|
# restore it but never save, so they stop filling up the repo's Actions cache storage.
|
|
SAVE_CACHE: ${{ github.event_name == 'push' && github.ref_name == github.event.repository.default_branch }}
|
|
# No --directory: callers add it, since shards capture from .pio/build/<env>/src. Keeping the
|
|
# include/exclude filters shared is what stops a shard capturing a different file set.
|
|
LCOV_CAPTURE_FLAGS: --quiet --capture --include "${PWD}/src/*" --exclude '*/src/mesh/generated/*' --base-directory "${PWD}"
|
|
|
|
jobs:
|
|
# Tripwire against the native suite set shrinking by accident. `platformio test` discovers and
|
|
# runs whatever test_* directories exist, and bin/run-tests.sh derives its expected count from
|
|
# the same walk - so a suite directory lost in a bad rebase or an overzealous cleanup just means
|
|
# fewer suites run, and every remaining check stays green. Compare the test_* directory list
|
|
# against the PR's merge base and fail when a suite vanished without the PR saying so: a removed
|
|
# suite's name must appear in the PR title, the PR body, or a commit message in the PR's range.
|
|
# A deliberate removal satisfies that by stating what it removes; an accidental loss cannot.
|
|
# Only pull_request runs have a base to compare against (and PRs are where accidents arrive);
|
|
# every other event skips. No job depends on this one: a skipped job would skip its dependents,
|
|
# and the expensive jobs should not wait on a full-history clone.
|
|
suite-shrinkage-check:
|
|
name: Native Suite Shrinkage
|
|
if: github.event_name == 'pull_request'
|
|
runs-on: ubuntu-slim
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
with:
|
|
persist-credentials: false
|
|
# Full history: the merge base must be computed, not guessed from a possibly stale
|
|
# event payload, and the acknowledgment scan reads every commit message in the range.
|
|
fetch-depth: 0
|
|
|
|
- name: Fail if a test_* suite vanished unacknowledged
|
|
shell: bash
|
|
# PR title/body are attacker-controlled text; they reach the script through env: only,
|
|
# never spliced into the shell source (same rule as the suite-order seed below).
|
|
env:
|
|
BASE_REF: ${{ github.base_ref }}
|
|
PR_TITLE: ${{ github.event.pull_request.title }}
|
|
PR_BODY: ${{ github.event.pull_request.body }}
|
|
run: |
|
|
set -euo pipefail
|
|
git fetch --quiet origin "$BASE_REF"
|
|
base=$(git merge-base FETCH_HEAD HEAD)
|
|
# Same canonical set every other consumer derives: directories named test_* directly
|
|
# under test/, read from the git trees so the comparison is exact at both endpoints.
|
|
list_suites() { git ls-tree -d --name-only "$1" test/ | sed 's#^test/##' | grep '^test_' | sort; }
|
|
removed=$(comm -23 <(list_suites "$base") <(list_suites HEAD))
|
|
if [[ -z $removed ]]; then
|
|
echo "No suite removed: $(list_suites HEAD | wc -l) test_* directories, none lost since merge base ${base:0:8}."
|
|
exit 0
|
|
fi
|
|
messages=$(git log --format=%B "$base..HEAD")
|
|
fail=0
|
|
while IFS= read -r suite; do
|
|
if printf '%s\n%s\n%s\n' "$PR_TITLE" "$PR_BODY" "$messages" | grep -qF "$suite"; then
|
|
echo "Removed suite $suite is named in the PR title/body or a commit message - acknowledged."
|
|
else
|
|
echo "::error title=Native suite vanished::test/$suite exists on the merge base but is gone from this PR, and nothing in the PR title, body, or commit messages mentions it. If the removal is deliberate, name $suite in the PR description or a commit message; if not, restore the directory - platformio test would silently run without it."
|
|
fail=1
|
|
fi
|
|
done <<<"$removed"
|
|
exit $fail
|
|
|
|
# Reject naive deadline comparisons against the 32-bit uptime clocks. `millis() > deadline` and
|
|
# `deadline < millis()` invert while the deadline sits on the far side of the 32-bit wrap: the
|
|
# action fires immediately, or blocks for about the interval it should have waited. The correct
|
|
# forms are
|
|
# Throttle::isWithinTimespanMs / hasElapsed (elapsed since a stored event) and
|
|
# Throttle::deadlinePassed (an absolute deadline). See .github/copilot-instructions.md.
|
|
millis-deadline-check:
|
|
# Name is load-bearing: upstream branch protection matches the check by name. Widen the guard,
|
|
# not this string.
|
|
name: Naive millis() Deadline Compare
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Reject 32-bit uptime clocks used directly in a deadline comparison
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
allowlist=".github/millis-deadline-allowlist.txt"
|
|
|
|
# Flag millis() or its Time::getMillis() wrapper directly adjacent to a comparison
|
|
# operator, in either order. The correct idioms subtract first, so they are not matched.
|
|
#
|
|
# Line comments are stripped before matching, so prose may name the broken idiom (this
|
|
# guard's own documentation does). Block comments are not stripped; keep `millis() >` out
|
|
# of /* */ blocks. mawk-compatible - ubuntu-latest has no gawk.
|
|
find src -type f \( -name '*.cpp' -o -name '*.h' -o -name '*.hpp' -o -name '*.ino' \) \
|
|
! -path 'src/mesh/generated/*' -print0 |
|
|
xargs -0 awk '
|
|
{
|
|
line = $0
|
|
sub(/\/\/.*/, "", line)
|
|
if (line ~ /((millis|getMillis)\(\)[ \t]*[<>]=?)|([<>]=?[ \t]*(millis|getMillis)\(\))/) {
|
|
code = line
|
|
sub(/^[ \t]+/, "", code); sub(/[ \t]+$/, "", code)
|
|
printf "%s\t%s\t%s\n", FILENAME, FNR, code
|
|
}
|
|
}' > /tmp/millis-hits.tsv
|
|
|
|
# Allowlisted entries are keyed on file + exact source text, deliberately without a line
|
|
# number, so unrelated edits above them do not invalidate the entry.
|
|
: > /tmp/millis-allowed.tsv
|
|
if [[ -f $allowlist ]]; then
|
|
grep -vE '^[[:space:]]*(#|$)' "$allowlist" > /tmp/millis-allowed.tsv || true
|
|
fi
|
|
|
|
violations=0
|
|
while IFS=$'\t' read -r file line code; do
|
|
[[ -n ${file:-} ]] || continue
|
|
if grep -qxF "$(printf '%s\t%s' "$file" "$code")" /tmp/millis-allowed.tsv; then
|
|
continue
|
|
fi
|
|
echo "$file:$line: $code"
|
|
violations=$((violations + 1))
|
|
done < /tmp/millis-hits.tsv
|
|
|
|
if [[ $violations -gt 0 ]]; then
|
|
echo "::error title=Naive uptime deadline compare::$violations line(s) compare a 32-bit uptime clock directly, which inverts while the deadline is on the far side of the 32-bit wrap - the action fires immediately, or blocks for about the interval it should have waited. Use Throttle::deadlinePassed(deadline) for a stored absolute deadline, or Throttle::hasElapsed(lastEvent, intervalMs) for an interval. If a match genuinely is not a deadline test (an uptime threshold, say), add it to $allowlist with a reason."
|
|
exit 1
|
|
fi
|
|
echo "No naive 32-bit uptime deadline comparisons in src/ (allowlist: $(wc -l < /tmp/millis-allowed.tsv) entr(y/ies))."
|
|
|
|
simulator-tests:
|
|
name: Native Simulator Tests
|
|
runs-on: ubuntu-24.04-arm
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
with:
|
|
submodules: recursive
|
|
|
|
- name: Setup native build
|
|
id: base
|
|
uses: ./.github/actions/setup-native
|
|
|
|
- name: Install simulator dependencies
|
|
run: pip install -U dotmap
|
|
|
|
- name: Restore PlatformIO cache
|
|
id: pio-cache
|
|
uses: actions/cache/restore@v6
|
|
with:
|
|
path: ~/.platformio/.cache
|
|
key: pio-simulator-tests-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }}
|
|
restore-keys: |
|
|
pio-simulator-tests-
|
|
|
|
# We now run integration test before other build steps (to quickly see runtime failures)
|
|
- name: Build for native/coverage
|
|
run: platformio run -e coverage
|
|
|
|
- name: Save PlatformIO cache
|
|
if: env.SAVE_CACHE == 'true' && steps.pio-cache.outputs.cache-hit != 'true'
|
|
uses: actions/cache/save@v6
|
|
with:
|
|
path: ~/.platformio/.cache
|
|
key: pio-simulator-tests-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }}
|
|
|
|
- name: Capture initial coverage information
|
|
shell: bash
|
|
run: |
|
|
sudo apt-get install -y lcov
|
|
lcov ${{ env.LCOV_CAPTURE_FLAGS }} --directory .pio/build/coverage/src --initial --output-file coverage_base.info
|
|
sed -i -e "s#${PWD}#.#" coverage_base.info # Make paths relative.
|
|
|
|
- name: Config check tests
|
|
# Drives the same binary against test/fixtures/portduino-config: asserts that
|
|
# `--check` reports each planted fault, and that a normal run still refuses the
|
|
# configs it should. Runs before the simulator test because it is seconds long
|
|
# and a failure here explains a lot of downstream weirdness.
|
|
timeout-minutes: 5
|
|
run: ./bin/test-config-check.sh .pio/build/coverage/meshtasticd
|
|
|
|
- name: Shared-state checker self-test
|
|
# Fixtures that write nothing / exactly what they declare / something undeclared /
|
|
# a declared write they never make, asserting CLEAN / CLEAN / DIRTY / MISSING. A
|
|
# checker that has silently stopped matching looks identical to a clean codebase.
|
|
timeout-minutes: 5
|
|
run: ./bin/test-state-check.sh
|
|
|
|
- name: Integration test
|
|
# Cap the whole step: if the simulator ever fails to exit (e.g. the
|
|
# exit_simulator admin path regresses again) the job must fail fast,
|
|
# not run to GitHub's 6-hour limit.
|
|
timeout-minutes: 5
|
|
run: |
|
|
.pio/build/coverage/meshtasticd -s &
|
|
PID=$!
|
|
trap 'kill "$PID" 2>/dev/null || true' EXIT
|
|
timeout 20 bash -c "until ls -al /proc/$PID/fd | grep socket; do sleep 1; done"
|
|
echo "Simulator started, launching python test..."
|
|
python3 -c 'from meshtastic.test import testSimulator; testSimulator()'
|
|
# The Python harness sends exit_simulator and exits; the simulator is
|
|
# expected to terminate on its own. Give it a moment, then verify.
|
|
# If it is still alive the exit handshake is broken - fail loudly and
|
|
# do NOT fall through to `wait`, which would otherwise block until the
|
|
# job's hard timeout.
|
|
for i in $(seq 1 10); do
|
|
kill -0 "$PID" 2>/dev/null || break
|
|
sleep 1
|
|
done
|
|
if kill -0 "$PID" 2>/dev/null; then
|
|
echo "::error title=Simulator did not exit::meshtasticd ignored exit_simulator and is still running after the integration test. The exit_simulator admin path is broken (see AdminModule::handleReceivedProtobuf, ARCH_PORTDUINO bypass). Killing it to avoid a 6-hour CI overrun."
|
|
kill -9 "$PID" 2>/dev/null || true
|
|
wait "$PID" 2>/dev/null || true
|
|
exit 1
|
|
fi
|
|
wait "$PID" 2>/dev/null || true
|
|
|
|
- name: Capture coverage information
|
|
if: always() # run this step even if previous step failed
|
|
run: |
|
|
lcov ${{ env.LCOV_CAPTURE_FLAGS }} --directory .pio/build/coverage/src --test-name integration --output-file coverage_integration.info
|
|
sed -i -e "s#${PWD}#.#" coverage_integration.info # Make paths relative.
|
|
|
|
- name: Get release version string
|
|
if: always() # run this step even if previous step failed
|
|
run: echo "long=$(./bin/buildinfo.py long)" >> "$GITHUB_OUTPUT"
|
|
id: version
|
|
|
|
- name: Save coverage information
|
|
uses: actions/upload-artifact@v7
|
|
if: always() # run this step even if previous step failed
|
|
with:
|
|
name: lcov-coverage-info-native-simulator-test-${{ steps.version.outputs.long }}
|
|
overwrite: true
|
|
path: ./coverage_*.info
|
|
|
|
# bin/test-shards.py derives the matrix from test/, so adding a suite needs no CI change.
|
|
# Cheap by design: a checkout and a python run, sitting on every shard critical path.
|
|
discover:
|
|
name: Native Test Shards
|
|
# ubuntu-latest, not the slim image: this needs a python3 to run bin/test-shards.py, and it is
|
|
# on the critical path of every shard, so it must not have to install one.
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
outputs:
|
|
matrix: ${{ steps.shards.outputs.matrix }}
|
|
suites: ${{ steps.shards.outputs.suites }}
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Build the shard matrix
|
|
id: shards
|
|
shell: bash
|
|
# Both inputs reach the script through env: rather than ${{ }} inside run:, so nothing from
|
|
# the event payload is ever spliced into the shell text.
|
|
env:
|
|
SUITE_ORDER_SEED: ${{ inputs.suite_order_seed }}
|
|
MAX_SUITES: ${{ inputs.max_suites_per_shard || 10 }}
|
|
EVENT_NAME: ${{ github.event_name }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
# pull_request keeps the declared arrangement so a PR never reds for a pairing its author did not
|
|
# choose; elsewhere the SHA seeds it. Varies co-location, not order within a shard.
|
|
if [ -n "${SUITE_ORDER_SEED:-}" ]; then
|
|
seed="$SUITE_ORDER_SEED"
|
|
echo "shard arrangement: shuffled with explicitly supplied seed $seed"
|
|
elif [ "${EVENT_NAME:-}" = "pull_request" ]; then
|
|
seed=""
|
|
echo "shard arrangement: declared order (pull_request)"
|
|
echo " to exercise a different arrangement, re-run this workflow with a suite_order_seed input"
|
|
else
|
|
seed=$((16#${GITHUB_SHA:0:8}))
|
|
echo "shard arrangement: shuffled with seed $seed (from ${GITHUB_SHA:0:8})"
|
|
fi
|
|
|
|
matrix=$(./bin/test-shards.py --max-suites "$MAX_SUITES" --seed "$seed" --summary)
|
|
# The canonical suite set, passed to the collector so its whole-run gate checks against
|
|
# the same walk this matrix was built from rather than a second one.
|
|
suites=$(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | sort | tr '\n' ' ')
|
|
|
|
# A newline in a value writes a second entry, setting outputs this step never declared. Both feed
|
|
# control flow, so assert single-line rather than assume it.
|
|
for value in "$matrix" "$suites"; do
|
|
if [ "$value" != "${value%%$'\n'*}" ]; then
|
|
echo "::error title=Multi-line step output::bin/test-shards.py or the suite walk produced a value spanning lines. Refusing to write it to \$GITHUB_OUTPUT - a newline there sets outputs this step did not declare."
|
|
exit 1
|
|
fi
|
|
done
|
|
[ -n "$matrix" ] && [ -n "$suites" ] || {
|
|
echo "::error title=Empty shard matrix::the matrix or the suite list came out empty; a downstream gate that expects nothing passes on anything."
|
|
exit 1
|
|
}
|
|
|
|
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
|
|
echo "suites=$suites" >> "$GITHUB_OUTPUT"
|
|
|
|
# No build-then-run split: PlatformIO relinks each suite regardless, so the warm build bought a
|
|
# shared src build and ~75 throwaway links. ccache carries src objects between shards instead.
|
|
platformio-tests:
|
|
name: Suites (${{ matrix.shard }})
|
|
needs: discover
|
|
runs-on: ubuntu-24.04-arm
|
|
# Measured cold-cache shards run 5-12 minutes. Without this a hung suite, or a runner that
|
|
# stops reporting, holds a runner until GitHub's 6-hour default - times twelve shards.
|
|
timeout-minutes: 30
|
|
permissions:
|
|
contents: read
|
|
# fail-fast off: a cancelled sibling stops the collector telling "failed" from "never ran".
|
|
strategy:
|
|
fail-fast: false
|
|
matrix: ${{ fromJSON(needs.discover.outputs.matrix) }}
|
|
steps:
|
|
# No submodules: src/mesh/generated is tracked and meshtestic is the hardware harness, so neither
|
|
# is reachable from .
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Setup native test build
|
|
id: base
|
|
uses: ./.github/actions/setup-native-test
|
|
|
|
- name: Get release version string
|
|
run: echo "long=$(./bin/buildinfo.py long)" >> "$GITHUB_OUTPUT"
|
|
id: version
|
|
|
|
# Disable (comment-out) BUILD_EPOCH. It forces a full rebuild between tests, resets coverage each
|
|
# time, and would put a fresh timestamp in every TU, which is also what would defeat ccache.
|
|
- name: Disable BUILD_EPOCH
|
|
run: sed -i 's/-DBUILD_EPOCH=$UNIX_TIME/#-DBUILD_EPOCH=$UNIX_TIME/' platformio.ini
|
|
|
|
- name: Restore PlatformIO cache
|
|
id: pio-cache
|
|
uses: actions/cache/restore@v6
|
|
with:
|
|
path: ~/.platformio/.cache
|
|
key: pio-coverage-tests-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }}
|
|
restore-keys: |
|
|
pio-coverage-tests-
|
|
|
|
# Every shard compiles the same ~450 src TUs; unshared, that is the cost of fanning out.
|
|
- name: Restore ccache
|
|
id: ccache-restore
|
|
uses: actions/cache/restore@v6
|
|
with:
|
|
path: ~/.ccache
|
|
# One lineage for all shards: src objects dominate and are identical. run_id only makes each save
|
|
# a fresh entry; the prefix restore-key selects the newest.
|
|
key: ccache-native-tests-${{ github.run_id }}
|
|
restore-keys: |
|
|
ccache-native-tests-
|
|
|
|
- name: Run this shard's suites
|
|
id: run
|
|
shell: bash
|
|
# Suite names come from bin/test-shards.py, which refuses any name outside
|
|
# ^test_[A-Za-z0-9_]+$ - so the word-split below cannot pick up shell metacharacters.
|
|
env:
|
|
PIO_ENV: ${{ matrix.env }}
|
|
SHARD: ${{ matrix.shard }}
|
|
SUITES: ${{ matrix.suites }}
|
|
run: |
|
|
set -uo pipefail
|
|
# read -ra, not an unquoted expansion: word-splits without letting a token glob against
|
|
# the workspace. bin/test-shards.py holds every name to ^test_[A-Za-z0-9_]+$ as well.
|
|
read -ra suites <<<"$SUITES"
|
|
filters=()
|
|
for suite in "${suites[@]}"; do filters+=(-f "$suite"); done
|
|
echo "shard $SHARD: ${#suites[@]} suite(s) under [env:$PIO_ENV] -> $SUITES"
|
|
|
|
# Log to a file for platformio real exit status, then drop the per-variant SKIPPED rows: suites
|
|
# outside this shard are reported SKIPPED by design.
|
|
rc=0
|
|
platformio test -e "$PIO_ENV" -v "${filters[@]}" \
|
|
--junit-output-path "testreport-$SHARD.xml" > shard.log 2>&1 || rc=$?
|
|
grep -v "[[:space:]]SKIPPED$" shard.log || true
|
|
exit $rc
|
|
|
|
- name: Verify this shard ran its own tests
|
|
# Not conditional on the run passing: a suite that reported another suite's test cases is a
|
|
# different, worse finding than a failing assertion, and it must not be hidden behind one.
|
|
if: always()
|
|
env:
|
|
SHARD: ${{ matrix.shard }}
|
|
SUITES: ${{ matrix.suites }}
|
|
run: ./bin/check-test-attribution.py --label "shard $SHARD" --expect "$SUITES" "testreport-$SHARD.xml"
|
|
|
|
- name: Capture coverage information
|
|
if: always() # run this step even if previous step failed
|
|
env:
|
|
PIO_ENV: ${{ matrix.env }}
|
|
SHARD: ${{ matrix.shard }}
|
|
run: |
|
|
sudo apt-get install -y lcov
|
|
# One tracefile per shard; the collector sums them with --add-tracefile into the union.
|
|
lcov ${{ env.LCOV_CAPTURE_FLAGS }} --directory ".pio/build/$PIO_ENV/src" \
|
|
--test-name "$SHARD" --output-file "coverage_tests_$SHARD.info"
|
|
sed -i -e "s#${PWD}#.#" "coverage_tests_$SHARD.info" # Make paths relative.
|
|
|
|
- name: ccache statistics
|
|
# Printed, not asserted. A cache that has silently stopped hitting shows up here as the
|
|
# shards getting slower, which is the symptom worth being able to explain.
|
|
if: always()
|
|
run: ccache --show-stats || true
|
|
|
|
- name: Save ccache
|
|
# Exactly one shard saves (cache_writer): every shard needs the same src objects, and letting all
|
|
# of them save would race for the key.
|
|
if: always() && env.SAVE_CACHE == 'true' && matrix.cache_writer
|
|
uses: actions/cache/save@v6
|
|
with:
|
|
path: ~/.ccache
|
|
key: ccache-native-tests-${{ github.run_id }}
|
|
|
|
- name: Save PlatformIO cache
|
|
if: env.SAVE_CACHE == 'true' && matrix.cache_writer && steps.pio-cache.outputs.cache-hit != 'true'
|
|
uses: actions/cache/save@v6
|
|
with:
|
|
path: ~/.platformio/.cache
|
|
key: pio-coverage-tests-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }}
|
|
|
|
- name: Save test results
|
|
if: always() # run this step even if previous step failed
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: platformio-test-report-${{ matrix.shard }}-${{ steps.version.outputs.long }}
|
|
overwrite: true
|
|
# Named, not globbed: a test suite can write to the workspace, and the collector merges whatever
|
|
# arrives into the report its gate reads.
|
|
path: ./testreport-${{ matrix.shard }}.xml
|
|
|
|
- name: Save coverage information
|
|
if: always() # run this step even if previous step failed
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: lcov-coverage-info-native-shard-${{ matrix.shard }}-${{ steps.version.outputs.long }}
|
|
overwrite: true
|
|
# Named exactly, for the same reason as the report above: everything uploaded here is
|
|
# merged into the published coverage report.
|
|
path: ./coverage_tests_${{ matrix.shard }}.info
|
|
|
|
# Reproduces the false green on purpose (--without-building) and requires the checker to catch it.
|
|
# Its own job because it relinks $BUILD_DIR/$PROGNAME, which no shard build dir can survive.
|
|
attribution-canary:
|
|
name: Attribution Canary
|
|
runs-on: ubuntu-24.04-arm
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Setup native test build
|
|
uses: ./.github/actions/setup-native-test
|
|
|
|
- name: Disable BUILD_EPOCH
|
|
run: sed -i 's/-DBUILD_EPOCH=$UNIX_TIME/#-DBUILD_EPOCH=$UNIX_TIME/' platformio.ini
|
|
|
|
- name: Restore PlatformIO cache
|
|
uses: actions/cache/restore@v6
|
|
with:
|
|
path: ~/.platformio/.cache
|
|
key: pio-coverage-tests-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }}
|
|
restore-keys: |
|
|
pio-coverage-tests-
|
|
|
|
- name: Restore ccache
|
|
uses: actions/cache/restore@v6
|
|
with:
|
|
path: ~/.ccache
|
|
key: ccache-native-tests-${{ github.run_id }}
|
|
restore-keys: |
|
|
ccache-native-tests-
|
|
|
|
- name: Attribution canary
|
|
timeout-minutes: 15
|
|
run: ./bin/test-attribution-canary.sh -e coverage
|
|
|
|
# Load-bearing name: branch protection matches it, and matrix rows are named per shard so none of
|
|
# them can carry it.
|
|
platformio-tests-gate:
|
|
name: Native PlatformIO Tests
|
|
needs: platformio-tests
|
|
if: ${{ !cancelled() }}
|
|
runs-on: ubuntu-slim
|
|
steps:
|
|
- name: Report the matrix result
|
|
env:
|
|
RESULT: ${{ needs.platformio-tests.result }}
|
|
run: |
|
|
set -euo pipefail
|
|
echo "shard matrix: $RESULT"
|
|
[ "$RESULT" = "success" ]
|
|
|
|
# The collector. A shard knows only its own suites and one that never started reports nothing, so
|
|
# only here can the union be checked against the canonical set.
|
|
generate-reports:
|
|
name: Generate Test Reports
|
|
runs-on: ubuntu-latest
|
|
permissions: # Needed for dorny/test-reporter.
|
|
contents: read
|
|
actions: read
|
|
checks: write
|
|
needs:
|
|
- discover
|
|
- simulator-tests
|
|
- platformio-tests
|
|
- attribution-canary
|
|
# Run this job even if the previous jobs failed, but skip if the workflow was cancelled.
|
|
if: ${{ !cancelled() }}
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
|
|
- name: Get release version string
|
|
run: echo "long=$(./bin/buildinfo.py long)" >> "$GITHUB_OUTPUT"
|
|
id: version
|
|
|
|
- name: Download per-shard test artifacts
|
|
uses: actions/download-artifact@v8
|
|
with:
|
|
pattern: platformio-test-report-*-${{ steps.version.outputs.long }}
|
|
merge-multiple: true
|
|
|
|
- name: Merge the shard reports into testreport.xml
|
|
# Preserve the single-file JUnit contract downstream consumers rely on (pr_tests.yml summary, and
|
|
# the Test Report below). The split is only how the run executes; the report stays consolidated.
|
|
if: always() # run even when a shard failed, so the report captures the failures
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
python3 - <<'PY'
|
|
import glob, xml.etree.ElementTree as ET
|
|
out = ET.Element('testsuites')
|
|
files = sorted(glob.glob('testreport-*.xml'))
|
|
for f in files:
|
|
try:
|
|
root = ET.parse(f).getroot()
|
|
except ET.ParseError:
|
|
print(f"WARNING: {f} is not parseable, skipping")
|
|
continue
|
|
# PlatformIO writes a <testsuites> root; fold in a bare <testsuite> too, just in case.
|
|
out.extend(root.findall('testsuite') if root.tag == 'testsuites' else [root])
|
|
ET.ElementTree(out).write('testreport.xml', encoding='utf-8', xml_declaration=True)
|
|
print(f"merged {len(files)} shard report(s) into testreport.xml")
|
|
PY
|
|
|
|
- name: Verdict - every suite ran, and ran its own tests
|
|
# Only here is the union compared against the canonical test_* set, which is what catches a shard
|
|
# that failed to start or was cancelled.
|
|
if: always() # a suite going missing is the finding; do not hide it behind an earlier failure
|
|
env:
|
|
SUITES: ${{ needs.discover.outputs.suites }}
|
|
run: |
|
|
set -euo pipefail
|
|
# --expect "" passes over anything, and it is empty exactly when discover failed, which is one of
|
|
# the situations this gate exists to catch.
|
|
if [ -z "${SUITES// /}" ]; then
|
|
echo "::error title=No expected suite set::the discover job produced no suite list, so the whole-run attribution gate has nothing to check against. Treating that as a failure - a gate with an empty expectation passes vacuously."
|
|
exit 1
|
|
fi
|
|
./bin/check-test-attribution.py --label "all shards" --expect "$SUITES" testreport.xml
|
|
|
|
- name: Save merged test results
|
|
# Same artifact name the single-runner job used to publish, so pr_tests.yml's summary keeps
|
|
# finding it.
|
|
if: always()
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: platformio-test-report-${{ steps.version.outputs.long }}
|
|
overwrite: true
|
|
path: ./testreport.xml
|
|
|
|
- name: Drop no-status testsuites from the report
|
|
# PlatformIO emits a self-closing <testsuite tests="0"/> row for every test_* dir
|
|
# crossed with every hardware variant it cannot run on the native host (~4900 rows).
|
|
# They carry no pass/fail/skip status and bury the suites that actually ran. Strip
|
|
# them so the Test Report lists only suites with a real status. Only the copy the
|
|
# reporter renders is trimmed; the uploaded artifact keeps the full XML.
|
|
if: always()
|
|
run: sed -i -E 's#<testsuite [^>]*tests="0"[^>]*/>##g' testreport.xml
|
|
|
|
- name: Test Report
|
|
if: always()
|
|
uses: dorny/test-reporter@v3.0.0
|
|
with:
|
|
name: PlatformIO Tests
|
|
path: testreport.xml
|
|
reporter: java-junit
|
|
|
|
- name: Download coverage artifacts
|
|
if: always()
|
|
uses: actions/download-artifact@v8
|
|
with:
|
|
pattern: lcov-coverage-info-native-*-${{ steps.version.outputs.long }}
|
|
path: code-coverage-report
|
|
merge-multiple: true
|
|
|
|
- name: Generate Code Coverage Report
|
|
# Merge every tracefile the jobs produced: coverage_base.info (zeroed baseline),
|
|
# coverage_integration.info, and one coverage_tests_<shard>.info per shard. lcov
|
|
# sums hit counts across them, so the merged report is the union of all shards -
|
|
# identical to running the whole suite in one job.
|
|
if: always()
|
|
run: |
|
|
sudo apt-get install -y lcov
|
|
args=()
|
|
for f in code-coverage-report/coverage_*.info; do
|
|
args+=(--add-tracefile "$f")
|
|
done
|
|
lcov --quiet "${args[@]}" --output-file code-coverage-report/coverage_src.info
|
|
genhtml --quiet --legend --prefix "${PWD}" code-coverage-report/coverage_src.info --output-directory code-coverage-report
|
|
|
|
- name: Save Code Coverage Report
|
|
if: always()
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: code-coverage-report-${{ steps.version.outputs.long }}
|
|
path: code-coverage-report
|
|
|
|
- name: Final verdict
|
|
# States the run result in one place, rather than leaving it reconstructed from a dozen shard logs.
|
|
if: always()
|
|
env:
|
|
SHARDS: ${{ needs.platformio-tests.result }}
|
|
SIMULATOR: ${{ needs.simulator-tests.result }}
|
|
CANARY: ${{ needs.attribution-canary.result }}
|
|
run: |
|
|
set -uo pipefail
|
|
{
|
|
echo "## Native tests"
|
|
echo ""
|
|
echo "| Part | Result |"
|
|
echo "| --- | --- |"
|
|
echo "| Suite shards | \`$SHARDS\` |"
|
|
echo "| Simulator | \`$SIMULATOR\` |"
|
|
echo "| Attribution canary | \`$CANARY\` |"
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
python3 - <<'PY' >> "$GITHUB_STEP_SUMMARY"
|
|
import xml.etree.ElementTree as ET
|
|
cases = fails = skips = 0
|
|
failed = []
|
|
for suite in ET.parse('testreport.xml').getroot().iter('testsuite'):
|
|
n = int(suite.get('tests', '0'))
|
|
bad = int(suite.get('failures', '0')) + int(suite.get('errors', '0'))
|
|
cases += n
|
|
fails += bad
|
|
skips += int(suite.get('skipped', '0'))
|
|
if bad:
|
|
failed.append(f"{suite.get('name', '?')} ({bad})")
|
|
print("")
|
|
print(f"{cases} test case(s), {fails} failed, {skips} skipped.")
|
|
if failed:
|
|
print("")
|
|
print("Failing suites: " + ", ".join(sorted(failed)))
|
|
PY
|
|
|
|
for result in "$SHARDS" "$SIMULATOR" "$CANARY"; do
|
|
[ "$result" = "success" ] || {
|
|
echo "::error title=Native tests failed::shards=$SHARDS simulator=$SIMULATOR canary=$CANARY - see the job summary."
|
|
exit 1
|
|
}
|
|
done
|
|
echo "RESULT: GREEN - every shard, the simulator, and the canary passed."
|