name: Build
on:
workflow_dispatch:
push:
branches: [master]
pull_request:
release:
types: [ published ]
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.sha || github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
jobs:
# Check if the code changes and we need to run ci build
# Cannot use paths filter in the on-event since we want this workflow to run even when there are no code changes, to register the commit chain
check-paths:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
code_changed: ${{ steps.filter.outputs.code }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 2 # Needed for push commit comparison
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
code:
- 'src/**'
- 'examples/**'
- 'lib/**'
- 'hw/**'
- 'test/hil/**'
- 'tools/build.py'
- 'tools/build_utils.py'
- 'tools/ci_select.py'
- 'tools/get_deps.py'
- 'tools/metrics.py'
- '.github/actions/**'
- '.github/workflows/build.yml'
- '.github/workflows/build_util.yml'
- '.github/scripts/**'
set-matrix:
runs-on: ubuntu-latest
outputs:
json: ${{ steps.set-matrix-json.outputs.matrix }}
hil_json: ${{ steps.set-matrix-json.outputs.hil_matrix }}
example_map: ${{ steps.set-matrix-json.outputs.example_map }}
build_filtered: ${{ steps.set-matrix-json.outputs.build_filtered }}
build_families_regex: ${{ steps.set-matrix-json.outputs.build_families_regex }}
# one pair per rig job: hil-tinyusb (tinyusb.json minus esptool boards),
# hil-tinyusb-esp (esptool boards only), hil-tinyusb (hfp.json)
hil_args_tinyusb: ${{ steps.hil-select.outputs.args_tinyusb }}
hil_run_tinyusb: ${{ steps.hil-select.outputs.run_tinyusb }}
hil_args_tinyusb_esp: ${{ steps.hil-select.outputs.args_tinyusb_esp }}
hil_run_tinyusb_esp: ${{ steps.hil-select.outputs.run_tinyusb_esp }}
hil_args_hfp: ${{ steps.hil-select.outputs.args_hfp }}
hil_run_hfp: ${{ steps.hil-select.outputs.run_hfp }}
steps:
- name: Checkout TinyUSB
uses: actions/checkout@v6
with:
fetch-depth: 0
# The `ci-full` PR label turns the scoping off for one PR: no selection file is
# written, so both matrices and every rig job fall back to the unscoped behaviour.
# An escape hatch is the point - a selector bug under-selects SILENTLY, and without
# a label the only routes back to a full matrix are accidental (touch an
# unclassified path, or break the selector badly enough that it falls open).
- name: CI selection (PR only)
id: hil-select
if: github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'ci-full')
env:
BASE_REF: ${{ github.base_ref }}
run: |
# Best-effort by design: set-matrix gates cmake, hil-build and every rig job,
# so a missing origin/, a shallow-clone hiccup or a selector traceback
# must fall back to the FULL matrix (no --select, run=true, no args) instead
# of failing the job. Same fail-open shape as pr_comment.yml's `|| true`.
#
# The selector's own unit suite gates it (stdlib-only, seconds): a selector
# whose tests fail can still exit 0 with valid-but-WRONG JSON -- fail-open alone
# never catches that class, and the pre-commit hil-test hook is a separate,
# advisory workflow that nothing here can `needs:`. Test-failing selector =>
# full matrix, same as a crashing one.
SELECT_JSON=''
if ! python3 test/hil/test/test_ci_select.py; then
echo "::warning::ci_select unit suite failed - falling back to the full HIL matrix"
elif ! SELECT_JSON=$(python3 tools/ci_select.py --base "origin/$BASE_REF" test/hil/tinyusb.json test/hil/hfp.json); then
echo "::warning::ci_select failed - falling back to the full HIL matrix"
SELECT_JSON=''
fi
# The selection is handed on as a FILE in the workspace, never as a step
# output/env var: it is ~KBs normally but a mass-sweep PR reaches hundreds of
# KB, and an env var that big makes the consuming exec fail with E2BIG BEFORE
# any fallback in it can run. Written here, ahead of its first reader.
# No file (non-PR event, or any fallback) = full matrix.
rm -f ci_select_out.json
if [ -n "$SELECT_JSON" ]; then
printf '%s' "$SELECT_JSON" > ci_select_out.json
fi
# One args/run pair per rig job, split by flasher: a job whose own subset is
# empty skips explicitly instead of running a board filter that matches zero
# boards ("No tests were run." exits 0 and would read as a green HIL run).
OUT=''
if [ -s ci_select_out.json ]; then
OUT=$(python3 -c '
import json, re, sys
s = json.load(open("ci_select_out.json"))
# the same reading hil_ci_set_matrix.py applies: full false with no usable
# boards map is an UNUSABLE selection, not "nothing selected". Both must agree
# - one falling open to the whole roster while the other computes run=false
# buys a full 37-leg build and still zero hardware coverage.
if not s.get("full") and not isinstance(s.get("boards"), dict):
sys.exit("selection has full false but no usable boards map")
tin = s.get("args_flasher", {}).get("tinyusb.json", {})
legs = (("tinyusb", " ".join(a for f, a in sorted(tin.items()) if f != "esptool" and a)),
("tinyusb_esp", tin.get("esptool", "")),
("hfp", s.get("args", {}).get("hfp.json", "")))
for key, a in legs:
# roster board names reach $GITHUB_OUTPUT as bare NAME=VALUE lines; a
# newline in one would inject extra run_* lines and flip which rig jobs run.
# ":" and "," are part of the normal shape - a partial filter is
# `-bt :,` (ci_select._board_args)
if not re.fullmatch(r"[-A-Za-z0-9_/ .=+:,]*", a):
sys.exit("unexpected characters in the " + key + " board filter")
print("args_" + key + "=" + a)
print("run_" + key + "=" + ("true" if (s.get("full") or a) else "false"))
') || OUT=''
if [ -z "$OUT" ]; then
echo "::warning::ci_select output unusable - falling back to the full HIL matrix"
# the same unusable selection must not stay behind for the build axis
rm -f ci_select_out.json
fi
fi
if [ -z "$OUT" ]; then
OUT=$(for k in tinyusb tinyusb_esp hfp; do printf 'args_%s=\nrun_%s=true\n' "$k" "$k"; done)
fi
echo "$OUT"
echo "$OUT" >> $GITHUB_OUTPUT
- name: Generate matrix json
id: set-matrix-json
run: |
# Build matrix, scoped by the PR selection when one exists. Best-effort:
# ci_set_matrix falls back to the full matrix itself on unusable JSON,
# and a missing file (non-PR event, selector fallback) means no flags.
SELECT_FILE=ci_select_out.json
[ -s "$SELECT_FILE" ] || SELECT_FILE=''
BUILD_SELECT_FILE="$SELECT_FILE"
MATRIX_JSON=''
if [ -n "$SELECT_FILE" ]; then
# ci_set_matrix falls open on a selection it cannot use with rc 0 - it prints
# the full matrix and says UNSCOPED on stderr. The build extras below must
# not stay scoped when it did, or a nominally full build compiles 1 of 44
# examples per family and code-metrics compares that partial run against a
# full baseline. Only the BUILD axis is dropped: build.families being
# unusable says nothing about the boards map the HIL matrix reads.
MATRIX_JSON=$(python .github/scripts/ci_set_matrix.py --select-file "$SELECT_FILE" 2>ci_set_matrix.err) || MATRIX_JSON=''
cat ci_set_matrix.err >&2
if [ -z "$MATRIX_JSON" ] || grep -q 'ci_set_matrix: UNSCOPED' ci_set_matrix.err; then
BUILD_SELECT_FILE=''
fi
fi
[ -z "$MATRIX_JSON" ] && MATRIX_JSON=$(python .github/scripts/ci_set_matrix.py)
# Build-axis extras: the per-family example map rides as a side channel
# (a value inside matrix entries would break CircleCI's family parameter
# and multiply GHA matrix legs). These stay step outputs - they are small
# derived values, unlike the selection they are read from. NOTE jq's //
# treats false like null, so .build.full is compared explicitly.
EXAMPLE_MAP='{}'
BUILD_FILTERED='false'
FAM_REGEX=''
if [ -n "$BUILD_SELECT_FILE" ]; then
EXAMPLE_MAP=$(jq -c '.build.family_examples // {}' "$BUILD_SELECT_FILE") || EXAMPLE_MAP='{}'
BUILD_FILTERED=$(jq -r 'if (.build? | type) == "object" and .build.full == false then "true" else "false" end' "$BUILD_SELECT_FILE") || BUILD_FILTERED='false'
if [ "$BUILD_FILTERED" = "true" ]; then
FAM_REGEX=$(jq -r '.build.families | join("|")' "$BUILD_SELECT_FILE") || FAM_REGEX=''
# family names come from hw/bsp dir names, which rule 6 reads straight out
# of the PR's diff path - and this is interpolated raw into a
# `name_is_regexp` artifact pattern, so a regex metacharacter there would
# silently match another family's baseline
case "$FAM_REGEX" in
*[!-A-Za-z0-9_\|]*)
echo "::warning::unexpected characters in the family list - dropping the scoping"
FAM_REGEX='' ;;
esac
if [ -z "$FAM_REGEX" ]; then
# all three drop together, as CircleCI's fall-open does. Resetting only
# build_filtered leaves the build scoped while code-metrics takes the
# UNSCOPED branch, diffing a 1-family run against the full averaged
# baseline and publishing that as the PR's code-size impact.
BUILD_FILTERED='false'
EXAMPLE_MAP='{}'
MATRIX_JSON=$(python .github/scripts/ci_set_matrix.py)
fi
fi
fi
# emitted once, after every path that can still change it
echo "matrix=$MATRIX_JSON"
echo "matrix=$MATRIX_JSON" >> $GITHUB_OUTPUT
echo "example_map=$EXAMPLE_MAP" >> $GITHUB_OUTPUT
echo "build_filtered=$BUILD_FILTERED" >> $GITHUB_OUTPUT
echo "build_families_regex=$FAM_REGEX" >> $GITHUB_OUTPUT
# HIL matrix (merged from tinyusb + hifiphile configs), scoped on PRs.
# Scoping is best-effort too: fall back to the unscoped (full) matrix.
HIL_MATRIX_JSON=''
if [ -n "$SELECT_FILE" ]; then
HIL_MATRIX_JSON=$(python .github/scripts/hil_ci_set_matrix.py --select-file "$SELECT_FILE" test/hil/tinyusb.json test/hil/hfp.json) || HIL_MATRIX_JSON=''
if [ -z "$HIL_MATRIX_JSON" ]; then
echo "::warning::scoped HIL matrix failed - falling back to the full HIL matrix"
fi
fi
if [ -z "$HIL_MATRIX_JSON" ]; then
HIL_MATRIX_JSON=$(python .github/scripts/hil_ci_set_matrix.py test/hil/tinyusb.json test/hil/hfp.json)
fi
echo "hil_matrix=$HIL_MATRIX_JSON"
echo "hil_matrix=$HIL_MATRIX_JSON" >> $GITHUB_OUTPUT
# ------------------------------------------------------------------------------
# CMake build: only one board per family (first alphabetically). Full build is done by CircleCI in PR
# Note:
# For Make and IAR build: will be done on CircleCI only (one random per family as well)
# ------------------------------------------------------------------------------
cmake:
needs: [ check-paths, set-matrix ]
uses: ./.github/workflows/build_util.yml
strategy:
fail-fast: false
matrix:
toolchain:
- 'aarch64-gcc'
#- 'arm-clang'
- 'arm-gcc'
#- 'esp-idf'
- 'ft9xx-gcc'
- 'msp430-gcc'
- 'riscv-gcc'
with:
build-system: 'cmake'
toolchain: ${{ matrix.toolchain }}
build-args: ${{ toJSON(fromJSON(needs.set-matrix.outputs.json)[matrix.toolchain]) }}
build-options: '--one-first'
example-map: ${{ needs.set-matrix.outputs.example_map }}
upload-metrics: true
upload-artifacts: false
upload-membrowse: true
code-changed: ${{ needs.check-paths.outputs.code_changed == 'true' }}
secrets: inherit
code-metrics:
needs: [ check-paths, cmake, set-matrix ]
# A scoped selection can empty every cmake toolchain (a test/hil-only PR). This
# job must still run then: skipping it leaves the sticky comment showing the
# PREVIOUS push's size table as if it were current. set-matrix must have
# SUCCEEDED though: !cancelled() alone let a failed set-matrix through, and this
# job would then overwrite the sticky comment with a wrong "built no families"
# diagnosis while reporting itself green.
if: |
!cancelled() && needs.check-paths.outputs.code_changed == 'true' &&
needs.set-matrix.result == 'success' &&
(needs.cmake.result == 'success' || needs.cmake.result == 'skipped')
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: write
steps:
- name: Checkout TinyUSB
uses: actions/checkout@v6
with:
fetch-tags: ${{ github.event_name == 'release' }}
- name: Download Artifacts
uses: actions/download-artifact@v5
with:
pattern: metrics-*
path: cmake-build
merge-multiple: true
# download-artifact does not fail on a pattern that matches nothing, so a
# scoped PR that built no family simply lands here with an empty dir
- name: Detect empty metrics set
run: |
# No metrics at all => nothing to aggregate or compare. Write the marker the
# sticky comment will carry, so the size section says "skipped" for THIS push
# instead of silently keeping the previous push's table.
if ! ls cmake-build/*/metrics.json >/dev/null 2>&1; then
echo "_Code-size comparison skipped: PR selection built no families on this push._" > metrics_compare.md
echo "NO_METRICS=true" >> $GITHUB_ENV
fi
- name: Aggregate Code Metrics
if: env.NO_METRICS != 'true'
run: |
python tools/get_deps.py
python tools/metrics.py combine -j -m -f tinyusb/src cmake-build/*/metrics.json
- name: Upload Metrics Artifact
if: github.event_name == 'push' || github.event_name == 'release'
uses: actions/upload-artifact@v7
with:
name: metrics-tinyusb
path: metrics.json
- name: Download Base Branch Metrics
if: env.NO_METRICS != 'true' && (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') && needs.set-matrix.outputs.build_filtered != 'true'
uses: dawidd6/action-download-artifact@v11
with:
workflow: build.yml
workflow_conclusion: ''
branch: ${{ github.base_ref }}
name: metrics-tinyusb
path: base-metrics
continue-on-error: true
- name: Download base per-family metrics (scoped PR)
if: env.NO_METRICS != 'true' && github.event_name == 'pull_request' && needs.set-matrix.outputs.build_filtered == 'true'
uses: dawidd6/action-download-artifact@v11
with:
workflow: build.yml
workflow_conclusion: ''
search_artifacts: true # a docs-only master push uploads no per-family artifacts
branch: ${{ github.base_ref }}
name: ^metrics-(${{ needs.set-matrix.outputs.build_families_regex }})$
name_is_regexp: true
path: base-family-metrics
continue-on-error: true
- name: Compare with Base Branch (scoped)
if: env.NO_METRICS != 'true' && github.event_name == 'pull_request' && needs.set-matrix.outputs.build_filtered == 'true'
run: |
# never fall back to the averaged metrics-tinyusb here: a scoped PR vs the
# 64-family/46-example average is exactly the mismatch this path prevents
python .github/scripts/metrics_pair_compare.py \
--base-dir base-family-metrics --new-dir cmake-build --out metrics_compare || \
echo "_Code-size comparison failed on the scoped path - see the code-metrics job log._" > metrics_compare.md
cat metrics_compare.md
- name: Download Previous Release Asset
if: github.event_name == 'release'
env:
GH_TOKEN: ${{ github.token }}
run: |
PREV_TAG=$(git tag --sort=-creatordate | head -n 2 | tail -n 1)
echo "Previous Release: $PREV_TAG"
echo "PREV_TAG=$PREV_TAG" >> $GITHUB_ENV
mkdir -p base-metrics
gh release download $PREV_TAG -p metrics.json -D base-metrics || echo "No metrics.json found in $PREV_TAG release"
- name: Compare with Base Branch
if: env.NO_METRICS != 'true' && github.event_name != 'push' && needs.set-matrix.outputs.build_filtered != 'true'
run: |
if [ -f base-metrics/metrics.json ]; then
python tools/metrics.py compare -m -f tinyusb/src base-metrics/metrics.json metrics.json
cat metrics_compare.md
else
echo "No base metrics found, skipping comparison"
cp metrics.md metrics_compare.md
fi
- name: Upload Release Assets
if: github.event_name == 'release'
env:
GH_TOKEN: ${{ github.token }}
run: |
CURR_TAG=${{ github.event.release.tag_name }}
COMPARE_FILE="metrics_compare_${CURR_TAG}-${PREV_TAG}.md"
mv metrics_compare.md $COMPARE_FILE
gh release upload $CURR_TAG metrics.json $COMPARE_FILE
- name: Upload Metrics Comment Artifact
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@v7
with:
name: metrics-comment
path: |
metrics_compare.md
metrics.json
# metrics.json is absent when the selection built no family; the marker
# in metrics_compare.md is still what the sticky comment needs
if-no-files-found: ignore
- name: Post Code Metrics as PR Comment
if: (github.event_name == 'workflow_dispatch') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false)
uses: marocchino/sticky-pull-request-comment@v2
with:
header: code-metrics
path: metrics_compare.md
# ---------------------------------------
# Keep the metrics baseline available on no-code-change pushes
# The code-metrics job only runs (and uploads metrics-tinyusb) when code changed, so a
# workflow/docs-only push to master would leave the latest run without a baseline for PRs
# to compare against. Carry the previous artifact forward so the baseline is never missing.
# ---------------------------------------
metrics-carry-forward:
needs: [ check-paths ]
if: github.event_name == 'push' && needs.check-paths.outputs.code_changed != 'true'
runs-on: ubuntu-latest
steps:
- name: Download previous metrics baseline from this branch
uses: dawidd6/action-download-artifact@v11
with:
workflow: build.yml
workflow_conclusion: '' # any conclusion, matching the PR-side baseline download
search_artifacts: true # scan back past runs that lack the artifact (e.g. earlier no-code pushes)
branch: ${{ github.ref_name }}
name: metrics-tinyusb
path: .
if_no_artifact_found: warn
continue-on-error: true # best-effort: never make a no-code push red
- name: Re-publish baseline so the latest run keeps it
if: hashFiles('metrics.json') != ''
uses: actions/upload-artifact@v7
with:
name: metrics-tinyusb
path: metrics.json
# ---------------------------------------
# Build Make/CMake on Windows/MacOS
# ---------------------------------------
build-os:
needs: [ check-paths ]
if: needs.check-paths.outputs.code_changed == 'true'
uses: ./.github/workflows/build_util.yml
strategy:
fail-fast: false
matrix:
os: [ windows-latest, macos-latest ]
build-system: [ 'make', 'cmake' ]
with:
os: ${{ matrix.os }}
build-system: ${{ matrix.build-system }}
toolchain: 'arm-gcc-${{ matrix.os }}'
build-args: '["stm32h7rs"]'
build-options: '--one-random'
# ---------------------------------------
# Zephyr
# ---------------------------------------
zephyr:
needs: [ check-paths ]
# skip zephyr build due to failed build, fix later
if: false
#if: needs.check-paths.outputs.code_changed == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout TinyUSB
uses: actions/checkout@v6
- name: Setup Zephyr project
uses: zephyrproject-rtos/action-zephyr-setup@v1
with:
app-path: examples
toolchains: arm-zephyr-eabi
- name: Build
run: |
west build -b nrf52840dk -d examples/device/cdc_msc/build examples/device/cdc_msc -- -DRTOS=zephyr
west build -b nrf52840dk -d examples/device/msc_dual_lun/build examples/device/msc_dual_lun -- -DRTOS=zephyr
# ---------------------------------------
# Hardware in the loop (HIL)
# Run on PR only (hil-tinyusb), hil-hfp-iar only run on non-forked PR
# ---------------------------------------
hil-build:
needs: [ check-paths, set-matrix ]
if: needs.check-paths.outputs.code_changed == 'true' && github.repository_owner == 'hathach'
uses: ./.github/workflows/build_util.yml
strategy:
fail-fast: false
matrix:
# These names are the bucket keys of .github/scripts/hil_ci_set_matrix.py: every
# non-esptool roster board must land in one of them (esptool boards go to
# 'esp-idf', built by hil-build-esp below). hil_ci_set_matrix.py rejects a
# board whose "toolchain" is not a bucket, so a new bucket must be added in
# both places.
#
# INVARIANT the PR-scoped skip cascade rests on: hil_run_tinyusb / hil_run_hfp
# true => hil-build has at least one non-empty leg. It holds because those
# flags count only non-esptool boards and every such board builds here. It
# would break if an esptool board were added to hfp.json, because
# hil_args_hfp is NOT flasher-split: the hfp leg of hil-tinyusb would want to
# run while hil-build (and therefore that leg) skipped.
toolchain:
- 'arm-gcc'
- 'riscv-gcc'
with:
build-system: 'cmake'
toolchain: ${{ matrix.toolchain }}
build-args: ${{ toJSON(fromJSON(needs.set-matrix.outputs.hil_json)[matrix.toolchain]) }}
upload-artifacts: true
# esp-idf builds are by far the slowest; keep them out of hil-build so the main
# hil-tinyusb run starts as soon as the fast toolchains finish (esp boards get
# their own hil-tinyusb-esp run gated only on this job)
hil-build-esp:
needs: [ check-paths, set-matrix ]
if: needs.check-paths.outputs.code_changed == 'true' && github.repository_owner == 'hathach'
uses: ./.github/workflows/build_util.yml
with:
build-system: 'cmake'
toolchain: 'esp-idf'
build-args: ${{ toJSON(fromJSON(needs.set-matrix.outputs.hil_json)['esp-idf']) }}
upload-artifacts: true
# ---------------------------------------
# Hardware in the loop (HIL)
# self-hosted on local VM, for attached hardware checkout HIL_JSON
# ---------------------------------------
hil-tinyusb:
needs: [ hil-build, set-matrix ]
name: hil-tinyusb (${{ matrix.display }})
# Above hil_test.py's pool guard (HIL_POOL_TIMEOUT, 60 min) so the guard fires first
# and still gets to write its report. The 30 min on top is what the job pays OUTSIDE
# the guard clock: workspace cleanup, checkout, the multi-board artifact merge and
# the D-state note before it; kill_worker_children, shutdown_pool's 30 s grace, the
# report write and the upload after it. On a multi-stray convoy that tail alone is
# minutes, and a ceiling below guard+tail cancels the job before hil_report.md exists
# -- the inversion this branch removes. Both legs share the script and the guard.
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
include:
- display: tinyusb.json
runner: [ self-hosted, X64, hathach, hardware-in-the-loop ]
hil_json: test/hil/tinyusb.json
# esptool-flashed (espressif) boards run in hil-tinyusb-esp,
# gated on the slow esp-idf build
test_args: '--exclude-flasher esptool'
- display: hfp.json
runner: [ self-hosted, Linux, X64, hifiphile ]
hil_json: test/hil/hfp.json
test_args: ''
runs-on: ${{ matrix.runner }}
env:
# HIL_POOL_TIMEOUT deliberately unset: hil_test.py's 60 min default is below every
# ceiling here, so ceiling > guard holds by construction. Pin it to SHORTEN a run
# only -- pinning it above a ceiling re-inverts the two.
HIL_JSON: ${{ matrix.hil_json }}
steps:
- name: Set HIL report dir (per run+job; persists across run attempts)
run: |
# one report dir per (run id, job): re-run attempts find their own report/spec,
# and interleaved runs of other PRs/jobs on the same runner cannot clobber them
BASE="$(dirname "$GITHUB_WORKSPACE")/hil-report"
# prune per-run dirs older than 2 weeks
find "$BASE" -mindepth 1 -maxdepth 1 -type d -mtime +14 -exec rm -rf {} + 2>/dev/null || true
echo "HIL_REPORT_DIR=$BASE/${GITHUB_RUN_ID}-$(basename "${{ matrix.display }}" .json)" >> "$GITHUB_ENV"
- name: Get re-run spec from previous attempt
if: github.run_attempt != '1'
run: |
# the report dir is keyed by run id, so a spec here can only have been
# written by an earlier attempt of THIS run
SPEC="$HIL_REPORT_DIR/$(basename "${{ env.HIL_JSON }}").failed"
RERUN_ARGS=""
[ -f "$SPEC" ] && RERUN_ARGS=$(cat "$SPEC")
echo "RERUN_ARGS=$RERUN_ARGS"
echo "RERUN_ARGS=$RERUN_ARGS" >> $GITHUB_ENV
- name: Clean workspace
run: |
echo "Cleaning up for the first run"
rm -rf "${{ github.workspace }}"
mkdir -p "${{ github.workspace }}"
- name: Checkout TinyUSB
uses: actions/checkout@v6
- name: Download Artifacts
uses: actions/download-artifact@v5
with:
pattern: binaries-*
path: cmake-build
merge-multiple: true
- name: Test on actual hardware
# Single attempt per test (--retry 1), no in-run second pass: a broken fixture
# fails fast instead of holding the runner (and other PRs' HIL jobs) for hours.
env:
# tinyusb.json minus the esptool boards (they run in hil-tinyusb-esp): each
# job gates on its own flasher subset, never on a rig-wide flag
SEL_ARGS_TINYUSB: ${{ needs.set-matrix.outputs.hil_args_tinyusb }}
SEL_RUN_TINYUSB: ${{ needs.set-matrix.outputs.hil_run_tinyusb }}
SEL_ARGS_HFP: ${{ needs.set-matrix.outputs.hil_args_hfp }}
SEL_RUN_HFP: ${{ needs.set-matrix.outputs.hil_run_hfp }}
run: |
case "$HIL_JSON" in
*tinyusb.json) SEL_ARGS="$SEL_ARGS_TINYUSB"; SEL_RUN="$SEL_RUN_TINYUSB" ;;
*hfp.json) SEL_ARGS="$SEL_ARGS_HFP"; SEL_RUN="$SEL_RUN_HFP" ;;
esac
if [ "$SEL_RUN" = "false" ]; then
echo "HIL skipped by PR selection (no affected boards on this rig)"
# leave a marker so the combined PR comment says so instead of dropping the
# section (and leaving a stale table from an earlier push in its place)
mkdir -p "$HIL_REPORT_DIR"
echo "_Skipped by PR selection: no affected boards on this rig._" > "$HIL_REPORT_DIR/hil_report.md"
exit 0
fi
# a re-run spec is already a subset of the selection (only the boards/tests
# that failed); -b/-bt accumulate, so keeping SEL_ARGS here would re-run the
# entire original selection instead of just what failed
if [ -n "$RERUN_ARGS" ]; then SEL_ARGS=''; fi
python3 test/hil/hil_test.py --retry 1 ${{ matrix.test_args }} $SEL_ARGS ${{ env.HIL_JSON }} $RERUN_ARGS
- name: Upload HIL report
if: always() && github.event_name == 'pull_request'
uses: actions/upload-artifact@v7
with:
name: hil-report-${{ matrix.display }}
path: ${{ env.HIL_REPORT_DIR }}/hil_report.md
if-no-files-found: ignore
overwrite: true
# ---------------------------------------
# Hardware in the loop (HIL) - espressif boards only
# Same rig as hil-tinyusb (tinyusb.json) but gated only on the slow esp-idf build,
# so the main run does not wait for it. Per-board flocks arbitrate the shared rig;
# the runner has a single job slot, so the two HIL jobs never overlap - adding a
# second slot would double the per-controller flash/usbtest budgets.
# ---------------------------------------
hil-tinyusb-esp:
needs: [ hil-build-esp, set-matrix ]
name: hil-tinyusb (tinyusb-esp.json)
runs-on: [ self-hosted, X64, hathach, hardware-in-the-loop ]
# above hil_test.py's pool guard (60 min) with room for the pre-pool checkout
# and the post-guard sweep + report upload, so its own guard still writes a report;
# only a job wedged past that (unkillable D-state worker) hits this ceiling, which
# must exist because the runner has one job slot and holds every queued job hostage
timeout-minutes: 90
env:
HIL_JSON: test/hil/tinyusb.json
TEST_ARGS: '--flasher esptool'
steps:
- name: Set HIL report dir (per run+job; persists across run attempts)
run: |
# one report dir per (run id, job): re-run attempts find their own report/spec,
# and interleaved runs of other PRs/jobs on the same runner cannot clobber them
BASE="$(dirname "$GITHUB_WORKSPACE")/hil-report"
# prune per-run dirs older than 2 weeks
find "$BASE" -mindepth 1 -maxdepth 1 -type d -mtime +14 -exec rm -rf {} + 2>/dev/null || true
echo "HIL_REPORT_DIR=$BASE/${GITHUB_RUN_ID}-tinyusb-esp" >> "$GITHUB_ENV"
- name: Get re-run spec from previous attempt
if: github.run_attempt != '1'
run: |
# the report dir is keyed by run id, so a spec here can only have been
# written by an earlier attempt of THIS run
SPEC="$HIL_REPORT_DIR/$(basename "${{ env.HIL_JSON }}").failed"
RERUN_ARGS=""
[ -f "$SPEC" ] && RERUN_ARGS=$(cat "$SPEC")
echo "RERUN_ARGS=$RERUN_ARGS"
echo "RERUN_ARGS=$RERUN_ARGS" >> $GITHUB_ENV
- name: Clean workspace
run: |
echo "Cleaning up for the first run"
rm -rf "${{ github.workspace }}"
mkdir -p "${{ github.workspace }}"
- name: Checkout TinyUSB
uses: actions/checkout@v6
- name: Download Artifacts
uses: actions/download-artifact@v5
with:
pattern: binaries-esp-idf-*
path: cmake-build
merge-multiple: true
- name: Test on actual hardware
env:
# esptool subset of tinyusb.json: this job must gate on its own boards, not
# on the rig-wide flag (which would run a filter matching zero boards)
SEL_ARGS: ${{ needs.set-matrix.outputs.hil_args_tinyusb_esp }}
SEL_RUN: ${{ needs.set-matrix.outputs.hil_run_tinyusb_esp }}
run: |
if [ "$SEL_RUN" = "false" ]; then
echo "HIL skipped by PR selection (no affected esptool boards)"
# leave a marker so the combined PR comment says so instead of dropping the
# section (and leaving a stale table from an earlier push in its place)
mkdir -p "$HIL_REPORT_DIR"
echo "_Skipped by PR selection: no affected esptool boards._" > "$HIL_REPORT_DIR/hil_report.md"
exit 0
fi
# a re-run spec is already a subset of the selection (only the boards/tests
# that failed); -b/-bt accumulate, so keeping SEL_ARGS here would re-run the
# entire original selection instead of just what failed
if [ -n "$RERUN_ARGS" ]; then SEL_ARGS=''; fi
python3 test/hil/hil_test.py --retry 1 $TEST_ARGS $SEL_ARGS ${{ env.HIL_JSON }} $RERUN_ARGS
- name: Upload HIL report
if: always() && github.event_name == 'pull_request'
uses: actions/upload-artifact@v7
with:
name: hil-report-tinyusb-esp.json
path: ${{ env.HIL_REPORT_DIR }}/hil_report.md
if-no-files-found: ignore
overwrite: true
# ---------------------------------------
# Hardware in the loop (HIL)
# self-hosted by HFP, build with IAR toolchain, for attached hardware checkout test/hil/hfp.json
# Since IAR Token secret is not passed to forked PR, only build non-forked PR
# ---------------------------------------
hil-hfp-iar:
needs: [ check-paths ]
if: |
needs.check-paths.outputs.code_changed == 'true' &&
github.repository_owner == 'hathach' &&
!(github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true)
runs-on: [ self-hosted, Linux, X64, hifiphile ]
# Unlike the hil-tinyusb jobs, this one BUILDS with IAR in the same job before running
# hil_test.py -- hfp.json's 3 boards, 4 variant entries, "up to 30 minutes" (see the
# comment above the selection step). The ceiling has to cover build + the 60 min pool
# guard + overhead, or GitHub cancels before the guard can write its report -- the
# inversion this branch removes. 30 + 60 = 90; the remaining 30 is the full-history
# checkout, get_deps, the post-guard sweep and the report upload.
timeout-minutes: 120
env:
IAR_LMS_BEARER_TOKEN: ${{ secrets.IAR_LMS_BEARER_TOKEN }}
PYTHONUNBUFFERED: '1'
steps:
- name: Clean workspace
run: |
echo "Cleaning up previous run"
rm -rf "${{ github.workspace }}"
mkdir -p "${{ github.workspace }}"
- name: Toolchain version
run: |
iccarm --version
- name: Checkout TinyUSB
uses: actions/checkout@v6
with:
# full history: the "HIL selection" step below needs
# merge-base(HEAD, origin/) for PR-scoped selection
fetch-depth: 0
# Computed BEFORE the build: the IAR build is four boards and up to 30 minutes on
# a runner that hil-tinyusb (hfp.json) also needs, so an unaffected PR must release
# it immediately instead of building everything and then skipping. The selection
# also narrows what gets built.
# This job has no needs: on set-matrix (it must run even if that unrelated job
# fails), so it computes its own selection instead of reading set-matrix's outputs.
- name: HIL selection (PR only)
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: |
# Best-effort: this job is deliberately decoupled from set-matrix so unrelated
# failures cannot kill hfp coverage - a selector failure here must likewise
# fall back to the full hfp matrix (no ci_select.json, no SEL_* vars), never
# fail the job.
if ! python3 test/hil/test/test_ci_select.py; then
echo "::warning::ci_select unit suite failed - running the full hfp matrix"
rm -f ci_select.json
exit 0
fi
if ! python3 tools/ci_select.py --base "origin/$BASE_REF" test/hil/hfp.json > ci_select.json; then
echo "::warning::ci_select failed - running the full hfp matrix"
rm -f ci_select.json
exit 0
fi
# ci_select.json is passed to hil_ci_set_matrix.py --select below to scope the
# build; it already honours full=true by ignoring the board list.
# The hil_test.py args go to a file, never to $GITHUB_ENV: they are derived
# from roster board names, which a PR can edit. Only SEL_RUN (a literal
# true/false computed here, needed by the step-level `if:`) goes to the env.
if ! SEL_RUN=$(python3 -c '
import json
s = json.load(open("ci_select.json"))
a = s["args"]["hfp.json"]
open("hil_sel_args.txt", "w").write(a)
print("true" if (s["full"] or a) else "false")
'); then
echo "::warning::ci_select output unusable - running the full hfp matrix"
rm -f ci_select.json hil_sel_args.txt
exit 0
fi
echo "SEL_RUN=$SEL_RUN"
echo "SEL_RUN=$SEL_RUN" >> $GITHUB_ENV
- name: Get build boards
if: env.SEL_RUN != 'false'
run: |
# --select-file, never --select "$(cat ...)": a whole selection as one argv
# can exceed MAX_ARG_STRLEN on a big diff, and this job's design is to fall
# back to the full hfp matrix on any selector trouble, not to fail the step.
MATRIX_JSON=''
if [ -f ci_select.json ]; then
MATRIX_JSON=$(python .github/scripts/hil_ci_set_matrix.py --select-file ci_select.json test/hil/hfp.json) || MATRIX_JSON=''
if [ -z "$MATRIX_JSON" ]; then
echo "::warning::scoped hfp matrix failed - building the full hfp matrix"
fi
fi
if [ -z "$MATRIX_JSON" ]; then
MATRIX_JSON=$(python .github/scripts/hil_ci_set_matrix.py test/hil/hfp.json)
fi
# Each variant carries its own --build-name/--cflag, which are global to a
# single build.py invocation — so keep one matrix entry per line and build
# them one at a time (joining would leak a variant's flags onto every board).
echo "$MATRIX_JSON" | jq -r '.["arm-gcc"][]' > hil_build_entries.txt
cat hil_build_entries.txt
BUILD_ARGS=$(echo "$MATRIX_JSON" | jq -r '.["arm-gcc"] | join(" ")')
# board and example names are roster data a PR can edit, and jq -r un-escapes
# them: a newline here writes extra NAME=VALUE lines into GITHUB_ENV for every
# later step of a job that holds the IAR token. Refuse rather than guess.
case "$BUILD_ARGS" in
*[!-A-Za-z0-9_/\ .=+]*)
echo "::error::unexpected characters in the hfp build args"; exit 1 ;;
esac
echo "BUILD_ARGS=$BUILD_ARGS"
echo "BUILD_ARGS=$BUILD_ARGS" >> $GITHUB_ENV
- name: Get Dependencies
if: env.SEL_RUN != 'false'
run: python3 tools/get_deps.py $BUILD_ARGS
- name: Build
if: env.SEL_RUN != 'false'
# Bounded SEPARATELY from the job. This is the only HIL job that builds inline
# (hil-tinyusb downloads artifacts), and the job ceiling went 30 -> 120 to give the
# HIL step room -- which would hand a stalled IAR build the whole two hours on the
# shared self-hosted runner, never reaching hil_test.py or the report upload. That
# is the stranded-runner-with-no-report failure this branch exists to prevent.
# Typical full build here is a few minutes; 30 leaves generous headroom.
timeout-minutes: 30
run: |
readarray -t ENTRIES < hil_build_entries.txt
for entry in "${ENTRIES[@]}"; do
echo "+ tools/build.py --toolchain iar $entry"
python3 tools/build.py --toolchain iar $entry
done
- name: Test on actual hardware (hardware in the loop)
run: |
if [ "$SEL_RUN" = "false" ]; then
echo "HIL skipped by PR selection (no affected boards on this rig)"
# leave a marker so the combined PR comment says so instead of dropping
# the section (and leaving a stale table from an earlier push)
echo "_Skipped by PR selection: no affected boards on this rig._" > hil_report.md
exit 0
fi
# empty/absent on a non-PR event or a selector fallback -> full hfp matrix
SEL_ARGS=$(cat hil_sel_args.txt 2>/dev/null || true)
# --retry 1, like the other two HIL legs. The pool guard is a FLAT 3600s and
# does NOT scale with max_retry, so argparse's default of 3 would multiply the
# serialized usbtest tail (hfp.json runs four batteries) by three against an
# unchanged guard -- on a runner with a single job slot that queues every other
# job behind it.
python3 test/hil/hil_test.py --retry 1 $SEL_ARGS hfp.json
- name: Upload HIL report
if: always() && github.event_name == 'pull_request'
uses: actions/upload-artifact@v7
with:
name: hil-report-hfp-iar
path: hil_report.md
if-no-files-found: ignore
overwrite: true