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//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 root; fold in a bare 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 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#]*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_.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."