Skip to content

ci: an empty selection must build nothing, plus selector follow-ups - #3845

Merged
hathach merged 9 commits into
masterfrom
claude/ci-empty-families
Aug 25, 2026
Merged

ci: an empty selection must build nothing, plus selector follow-ups#3845
hathach merged 9 commits into
masterfrom
claude/ci-empty-families

Conversation

@hathach

@hathach hathach commented Aug 24, 2026

Copy link
Copy Markdown
Owner

A PR whose build axis legitimately selects nothing was rebuilding everything.
Both merged-since PRs show it:

PR changed selector said actually ran
#3842 a skill, README.rst, .gitignore no families 74 cmake legs
#3840 test/hil/** + docs no families (rule 2) 74 cmake legs

The classifier was right both times. From #3842's own set-matrix log:

ci_select[build]: .gitignore: non-code, no build contribution
ci_select[build]: README.rst: non-code, no build contribution
ci_select[build]: .claude/skills/update-sponsor/*: non-code, no build contribution

The bug

.build.families is read twice in the build-extras block: as a |-joined regex for the
metrics artifact pattern, and implicitly as "is anything selected". An empty list and
one rejected by the charset guard both leave the regex empty and mean opposite
things — but the branch tested only -z "$FAMILY_REGEX":

FAMILY_REGEX=$(jq -r '.build.families | join("|")')   #  []  ->  ""
case "$FAMILY_REGEX" in *[!-A-Za-z0-9_|]*) FAMILY_REGEX='' ;; esac
if [ -z "$FAMILY_REGEX" ]; then                       #  fires for BOTH
  MATRIX_JSON=$(python .github/scripts/ci_set_matrix.py)   # <- discards the correct
fi                                                         #    all-empty matrix

MATRIX_JSON was already right — ci_set_matrix --select-file had returned the all-empty
matrix. The fall-open branch replaced it with the unscoped one, so every toolchain got its
full family list.

Two things made this hard to see from the outside: the HIL axis never passes through this
block, so it stayed correct (rig jobs skipped on #3842, full rig on #3840 per rule 2); and
no ci_set_matrix: UNSCOPED marker appears anywhere, because ci_set_matrix behaved
perfectly and the override happens in the shell afterwards. It looked like a
classification failure when it was not.

Introduced in the #3841 follow-up that made FAMILY_REGEX/EXAMPLE_MAP/BUILD_FILTERED
drop together — correct for the rejected case, wrong for the empty one.

Branching on which case it is:

selection matrix legs build_filtered regex
families: [] — nothing selected 0 false ''
families: [stm32f4, rp2040] 4 true stm32f4|rp2040
families: [stm32f4.*] — rejected 129 false ''

An empty list keeps the all-empty matrix so every leg skips, and sets
build_filtered=false because nothing was built and there is no baseline to compare
against. A rejected list falls open exactly as before. FAM_* is renamed to FAMILY_*
along the way: FAM meant hw/bsp board family, and the abbreviation was ambiguous in a
file that also says "family" for the per-family example map and for get_deps' tokens.

This block had no test at all, which is how the bug shipped through two merges and a
max-effort review. test_ci_metrics.py now extracts it from build.yml and executes it
for real against all three shapes. Verified against master it fails with 129 != 0 on the
empty case and '' != 'stm32f4|rp2040' on the scoped one. Writing it surfaced two traps
worth recording: the block prints ::warning:: to stdout (so the probe needs a
sentinel), and | cannot be the field separator because the regex contains one.

Also in here

Follow-ups to the same machinery, each a self-contained commit.

ci_select: fail-open on an odd checkout path. Five glob.glob sites interpolated
the repo root raw, so a checkout under a path containing [, * or ? matched nothing
and the selector failed closed — silently selecting less rather than more. An _rg()
helper applies glob.escape to the root at all five.

The ci-full label is removed. It never worked: the label is read in a job that has
already computed the matrix from the selection, so setting it changed nothing. Rather than
repair an escape hatch nobody has needed, drop it — a PR that wants the full matrix can
touch any rule-17 path, and the selector falls open on every exception already.

Dead skip tokens. 13 mcu:MKL25ZXX / mcu:SAME5X tokens across 12 skip.txt files
name MCUs no board reports, so they excluded nothing. They read as deliberate coverage
gaps and are not.

The rule table now lives in the module docstring as a carbon copy of the spec's 23
rows, guarded by a test that fails when the two drift. Contributors editing ci_select.py
see the binding table without leaving the file.

Six selection behaviours had no test — a mutation pass broke each one and the whole
suite stayed green. Every one is a small expression whose removal silently narrows the
selection, which is the direction that merges a regression rather than wasting a runner.

Performance

Four hot spots, measured before and after. A 260-path differential over every rule shape
reports 0 divergences, and the whole run is 1.8× faster (26.0s → 14.6s).

fix before after
_prune_buildable asks skip_example per example, short-circuiting 6,883 calls 1,889
reasons out of the JSON — stderr only, no consumer reads them back 453 KB 12 KB
tusb_config.h text cached (diff touching all of src/class) 4,240 reads 46
lib_examples globs the two filenames, not **/* 489 entries 107

_prune_buildable alone was 0.63s of 0.64s: it materialised every family's full buildable
list to answer two yes/no questions, probing 46 examples × every board whatever the diff
had selected.

Its one subtlety is preserved and now pinned. A family whose selection is already
everything it can build carries no -e list, because build.py applies the same
skip_example the pruner just did. Dropping that check compiled identical firmware and
only inflated the payload — which is exactly why 216 tests stayed green through it while I
had it wrong.

Membrowse upload is no longer scoped by the PR filter

The upload step passed $EX_ARGS, which never scoped its targets —
examples-membrowse-upload is not all, so resolve_example_target_groups passes it
through as the aggregate — but it did move the board, because --one-first picks one
that can build the -e set.

Dropping it keeps the size history keyed on each family's preferred board whatever a PR
touches. The accepted cost: where that board is not the one the Build step compiled, there
is no elf and every example uploads --identical, so the leg contributes no real data.
Eleven families are in that position (imxrt lpc11 lpc18 lpc54 mcx rp2040 rx samd11 stm32l0 stm32l4 tm4c); the rest are unaffected. A test derives that set from build.py
and pins it, so the cost stays measured — it already caught an undercount of four.

Validation

pre-commit run --all-files green. Suites: test_ci_select 218, test_ci_metrics 26,
test_hil_bounded 118. End-to-end, both PR shapes above now yield legs=0; a
dcd_dwc2.c diff yields 22 families / 8 boards / 37 legs.

Copilot AI lite review requested due to automatic review settings August 24, 2026 06:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new test helper emits unescaped shell variable assignments, which can make the test environment-dependent (e.g., TMPDIR paths with spaces) and should be fixed to avoid flaky failures.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR fixes a CI workflow edge case where a legitimate empty .build.families: [] selection (meaning “build nothing”) was incorrectly treated as “unusable,” causing the workflow to fall open and rebuild all CMake legs. It also adds targeted unit tests that execute the build.yml “build-axis extras” shell block to prevent regressions across the empty/scoped/rejected cases.

Changes:

  • Update .github/workflows/build.yml to distinguish between an empty family list (keep the all-empty matrix; skip builds) and a rejected/unusable family regex (fall open to full matrix).
  • Rename FAM_* variables to FAMILY_* for clarity in the workflow block.
  • Add tests in test_ci_metrics.py that extract and run the workflow shell block against representative selection shapes.
File summaries
File Description
.github/workflows/build.yml Correctly branches between empty vs rejected family selections to avoid unintended full rebuilds.
test/hil/test/test_ci_metrics.py Adds execution-based tests to validate the workflow’s build-extras block behavior for empty/scoped/rejected selections.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +468 to +471
with open(sh, 'w') as fh:
fh.write('BUILD_SELECT_FILE=' + selp + '\n')
fh.write("MATRIX_JSON='" + matrix + "'\n")
fh.write(block)
@github-actions

Copy link
Copy Markdown

Size Difference Report

Because TinyUSB code size varies by port and configuration, the metrics below represent the averaged totals across all example builds.

Note: If there is no change, only one value is shown.

Changes >1% in size

No entries.

Changes <1% in size

No entries.

No changes
file .text .rodata .data .bss size % diff
audio_device.c 2885 0 1252 1621 4501 +0.0%
cdc_device.c 1235 16 1092 722 1955 +0.0%
cdc_host.c 6475 487 15 926 7607 +0.0%
dcd_ch32_usbfs.c 1708 0 0 1364 3072 +0.0%
dcd_ch32_usbhs.c 1922 0 0 481 2404 +0.0%
dcd_ci_fs.c 1955 0 0 1290 3245 +0.0%
dcd_ci_hs.c 1941 0 0 779 2721 +0.0%
dcd_da146xx.c 3067 0 0 144 3211 +0.0%
dcd_dwc2.c 4230 19 0 265 4514 +0.0%
dcd_eptri.c 2281 0 0 259 2540 +0.0%
dcd_ft9xx.c 3284 0 0 172 3456 +0.0%
dcd_lpc17_40.c 1842 0 0 792 2238 +0.0%
dcd_lpc_ip3511.c 1580 0 0 264 1800 +0.0%
dcd_mm32f327x_otg.c 1474 0 0 1290 2764 +0.0%
dcd_msp430x5xx.c 1801 0 0 176 1977 +0.0%
dcd_musb.c 2667 0 0 179 2846 +0.0%
dcd_nrf5x.c 2974 0 0 292 3266 +0.0%
dcd_nuc120.c 1096 0 0 78 1174 +0.0%
dcd_nuc121.c 1170 0 0 101 1271 +0.0%
dcd_nuc505.c 0 0 1533 157 1690 +0.0%
dcd_rp2040.c 1004 0 764 653 2420 +0.0%
dcd_rusb2.c 3346 0 0 156 3502 +0.0%
dcd_samd.c 1071 0 0 266 1337 +0.0%
dcd_samg.c 1326 0 0 72 1398 +0.0%
dcd_stm32_fsdev.c 2568 0 0 291 2859 +0.0%
dfu_device.c 776 28 712 134 910 +0.0%
dfu_rt_device.c 157 0 134 0 157 +0.0%
dwc2_common.c 603 22 0 0 615 +0.0%
ecm_rndis_device.c 1067 0 1 2759 3826 +0.0%
ehci.c 2763 0 0 6274 7783 +0.0%
fsdev_common.c 182 0 0 0 182 +0.0%
hcd_ch32_usbfs.c 2491 0 0 502 2993 +0.0%
hcd_ci_fs.c 2466 0 0 469 2936 +0.0%
hcd_ci_hs.c 186 0 0 0 186 +0.0%
hcd_dwc2.c 5071 25 1 545 5642 +0.0%
hcd_musb.c 3071 0 0 157 3228 +0.0%
hcd_pio_usb.c 262 0 240 0 502 +0.0%
hcd_rp2040.c 1996 17 4 321 2338 +0.0%
hcd_rusb2.c 2951 0 0 245 3196 +0.0%
hcd_samd.c 2168 0 0 324 2492 +0.0%
hcd_stm32_fsdev.c 3248 0 1 420 3670 +0.0%
hid_device.c 1121 44 997 119 1240 +0.0%
hid_host.c 1244 0 0 1287 2531 +0.0%
hub.c 1380 8 8 30 1414 +0.0%
midi2_device.c 3608 34 1600 563 4183 +0.0%
midi2_host.c 1798 0 0 5876 7674 +0.0%
midi_device.c 1148 0 1007 614 1759 +0.0%
midi_host.c 1338 7 7 3449 4791 +0.0%
msc_device.c 2522 108 2293 802 3325 +0.0%
msc_host.c 1620 0 0 395 2015 +0.0%
mtp_device.c 1781 22 771 589 2378 +0.0%
ncm_device.c 1791 28 833 4393 6197 +0.0%
ohci.c 2157 0 0 2503 4660 +0.0%
printer_device.c 827 0 706 555 1380 +0.0%
rp2040_usb.c 386 35 632 11 1065 +0.0%
rusb2_common.c 160 0 16 0 176 +0.0%
tusb.c 449 0 389 3 451 +0.0%
tusb_fifo.c 855 0 486 0 850 +0.0%
typec_stm32.c 1230 8 2 19 1255 +0.0%
usbc.c 500 2 20 166 688 +0.0%
usbd.c 3708 57 92 354 4126 +0.0%
usbh.c 4971 57 81 1174 6244 +0.0%
usbtmc_device.c 2283 24 69 311 2625 +0.0%
vendor_device.c 1132 0 538 1561 2685 +0.0%
video_device.c 4477 5 1245 480 4949 +0.0%
TOTAL 126846 1053 17541 50194 179085 +0.0%

@claude

claude Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

Hardware-in-the-loop (HIL) Test Report

hfp-iar

✅ 56 passed · ❌ 0 failed · ⚪ 0 skipped · blank not run

Board usbtest cdc_msc_throughput audio_test_freertos cdc_dual_ports cdc_msc cdc_msc_freertos dfu dfu_runtime hid_boot_interface hid_generic_inout midi_test msc_dual_lun mtp printer_to_cdc duration
stm32l412nucleo ✅ 30/30 ✅ C 500/497k M 511/511k 125s
lpcxpresso43s67 ✅ 30/30 ✅ C 10.7/8.7M M 32/31.4M 130s
stm32f746disco ✅ 30/30 ✅ C 13/14.3M M 24.2/30.4M 117s
stm32f746disco-DMA ✅ 30/30 ✅ C 13.5/12.7M M 26.1/33.3M 86s

hfp.json

✅ 56 passed · ❌ 0 failed · ⚪ 0 skipped · blank not run

Board usbtest cdc_msc_throughput audio_test_freertos cdc_dual_ports cdc_msc cdc_msc_freertos dfu dfu_runtime hid_boot_interface hid_generic_inout midi_test msc_dual_lun mtp printer_to_cdc duration
stm32l412nucleo ✅ 30/30 ✅ C 507/509k M 512/511k 127s
lpcxpresso43s67 ✅ 30/30 ✅ C 12/10.4M M 32/30.8M 157s
stm32f746disco ✅ 30/30 ✅ C 13.6/13M M 24.6/30.4M 105s
stm32f746disco-DMA ✅ 30/30 ✅ C 15/13.3M M 25.4/32.8M 89s

tinyusb-esp.json

✅ 21 passed · ❌ 3 failed · ⚪ 0 skipped · blank not run

Board usbtest msc_file_explorer_freertos audio_test_freertos cdc_msc_freertos device_info hid_composite_freertos duration
espressif_p4_function_ev ✅ 30/30 409 KB/s 118s
espressif_p4_function_ev-DMA ✅ 30/30 409 KB/s 119s
espressif_s3_devkitm ✅ 30/30 409 KB/s 139s
espressif_s3_devkitm-DMA ✅ 30/30 230s

tinyusb.json

Not all verdicts are evidence. 2 board(s) ran on a worker that went blind on sysfs -- too many bounded reads stranded on a wedged device -- so "not found" from them means "could not tell": nanoch32v203, stm32h743nucleo. See the usb-kernel-recover skill.

✅ 345 passed · ❌ 36 failed · ⚪ 17 skipped · blank not run

Board usbtest cdc_msc_throughput msc_file_explorer msc_file_explorer_freertos audio_test_freertos cdc_dual_ports cdc_msc cdc_msc_freertos cdc_msc_hid device_info dfu dfu_runtime hid_boot_interface hid_generic_inout host_info_to_device_cdc midi_test msc_dual_lun mtp printer_to_cdc duration
frdm_k64f 23s
raspberry_pi_pico_w 1103 KB/s 1022 KB/s 38s
raspberry_pi_pico2 79s
stm32g0b1nucleo ✅ 30/30 ✅ C 511/511k M 511/517k 170s
ek_tm4c123gxl ✅ 30/30 ✅ C 507/511k M 511/516k 192s
stm32f407disco ✅ 30/30 199s
metro_m4_express ✅ 30/30 ✅ C 608/629k M 574/576k 216s
lpcxpresso55s28 ✅ 30/30 ✅ C 8.6/8M M 31.2/31.4M 218s
lpcxpresso11u37 ✅ 30/30 ✅ C 476/308k M 578/565k 223s
nanoch32v203-fsdev ✅ 30/30 ✅ C 507/511k M 511/511k 163s
nanoch32v203-usbfs ❌ 0/30 92s
ch32v103r_r1_1v0 ✅ 30/30 ✅ C 499/507k M 511/513k 198s
ra4m1_ek ✅ 30/30 ✅ C 543/511k M 579/511k 275s
raspberry_pi_pico ✅ 30/30 ✅ C 553/523k M 522/511k 275s
stm32f072disco ✅ 30/30 ✅ C 507/266k M 516/484k 309s
ch582m_evt ❌ 0/30 ✅ C 231/204k M 479/481k 99s
max32666fthr ❌ 29/30 ✅ C 7/14.3M M 18.1/22M 366s
stm32l476disco ✅ 30/30 ✅ C 507/511k M 511/511k 346s
feather_nrf52840_express ❌ 7/30 ✅ C 507/511k M 511/511k 411s
stm32u083nucleo ✅ 30/30 ✅ C 506/503k M 511/511k 241s
adafruit_fruit_jam ✅ 30/30 ✅ C 507/511k M 511/512k 62 KB/s 62 KB/s 278s
stm32h743nucleo ✅ 30/30 ✅ C 553/581k M 591/547k 215s
stm32h743nucleo-DMA ✅ 30/30 ✅ C 507/511k M 515/514k FS? 287s
nrf54lm20dk ✅ 30/30 ✅ C 1.9/4.1M M 7.2/8.1M 273s
mimxrt1064_evk ✅ 30/30 ✅ C 23.5/17.1M M 34.7/31.5M 1361 KB/s 321s
stm32f723disco ✅ 30/30 ✅ C 507/511k M 511/511k 13443 KB/s 3912 KB/s 332s
stm32f723disco-DMA ✅ 30/30 ✅ C 640/541k M 514/511k 14169 KB/s 3942 KB/s 233s
ch32v307v_r1_1v0-usbhs ✅ 30/30 ✅ C 7.1/8.2M M 24.8/12.6M 312s
ch32v307v_r1_1v0-usbfs ✅ 30/30 ✅ C 507/510k M 511/511k 120s

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

MemBrowse Memory Report

No memory changes detected across 2473 targets. View Project Dashboard →

hathach added a commit that referenced this pull request Aug 24, 2026
The extras-block probe wrote BUILD_SELECT_FILE and MATRIX_JSON by raw string
concatenation, so a TMPDIR containing a space split the assignment and failed the
test for a reason with nothing to do with the block under test. Reproduced with
TMPDIR="/tmp/has space": test_a_real_family_list_stays_scoped fails before the fix
and passes after, and the whole suite is green under both.

Found by Copilot on #3845.
with open(sh, 'w') as fh:
# shlex.quote, not hand-rolled quoting: a TMPDIR with a space in it
# made this fail for a reason that had nothing to do with the block
fh.write('BUILD_SELECT_FILE=' + shlex.quote(selp) + '\n')

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b7c2f80. Reproduced first: with TMPDIR="/tmp/has space", test_a_real_family_list_stays_scoped fails before the change and the whole suite is green after. Both assignments now go through shlex.quote.

MATRIX_JSON was the less likely of the two (it is JSON, so no single quotes to break out of), but quoting both is the right habit rather than reasoning about which values happen to be safe today.

.build.families is read twice in the build-extras block: as a `|`-joined regex for
the metrics artifact pattern, and implicitly as "is anything selected". An EMPTY
list and one REJECTED by the charset guard both leave the regex empty and mean
opposite things, but the branch tested only `-z "$FAMILY_REGEX"` - so every
nothing-selected PR took the fall-open path and DISCARDED the correct all-empty
matrix ci_set_matrix had just produced.

Both merged PRs show it: #3842 (a skill, README.rst and .gitignore) and #3840
(test/hil/** plus docs) each rebuilt all 74 cmake legs after the selector had
correctly chosen no families. The HIL axis was right in both - rig jobs skipped on
#3842, full rig on #3840 per rule 2 - because it never passes through this block,
and no UNSCOPED marker appeared because ci_set_matrix behaved perfectly; the
override happens in the shell afterwards. That combination made it look like the
classifier was at fault when it was not.

Now branches on which case it is: a rejected list falls open exactly as before (all
three values drop together), while an empty list keeps the all-empty matrix so
every leg skips, sets build_filtered=false because nothing was built and there is
nothing to compare a baseline against, and leaves EXAMPLE_MAP at '{}'.

Renames FAM_* to FAMILY_* - FAM meant hw/bsp board family, and the abbreviation
read like it might be something else in a file that also says "family" for the
per-family example map and the get_deps family tokens.

The block had no test at all, which is how this shipped twice. test_ci_metrics now
extracts it from build.yml and runs it for real against all three shapes; the
empty case fails with `129 != 0` against master, and the scoped case with
`'' != 'stm32f4|rp2040'`. The probe quotes its shell assignments with shlex.quote,
so a TMPDIR containing a space cannot fail it for an unrelated reason.
Every repo-tree glob interpolated repo_root unescaped, so a checkout at a path
containing a glob metacharacter resolved nothing and the selector answered
"nothing to build" instead of falling open. Reproduced from /tmp/glob[1]test:
families=0 where the same diff gives 30 from a normal path - every toolchain then
gets [], the whole matrix compiles nothing, and the PR is green. A worktree named
after a PR number or a CI workspace with brackets is enough.

_rg() escapes the root and leaves the pattern parts alone; all five sites use it.

Also anchors LICENSE in _NONCODE_RE. The bare alternative swallowed anything
merely starting with it - a future LICENSE_extra.c would have been classified
non-code and selected nothing, the same silent-under-selection direction.
LICENSES/ is named explicitly because that directory really exists: anchoring
alone sent LICENSES/MIT.txt to rule 17, which TestNoTrackedFileIsUnclassified
caught immediately.

espressif no longer triggers ci_set_matrix's all-miss fall-open. Its examples need
the ESP-IDF environment (CLAUDE.md: `. "$IDF_PATH/export.sh"`), which the cmake
legs do not have - that is why it is commented out of family_list. Coverage comes
from hil-build-esp, which builds those boards by name in an IDF container:
verified an espressif-only PR gets 2 esp-idf legs, so the fall-open was adding 74
cmake legs none of which can compile espressif. The other six unbuilt families
(cxd56, efm32, f1c100s, pic32mz, py32f0, same7x) keep the net.
Added as an escape hatch for a silently under-selecting selector, but it cannot
fire: `on: pull_request` declares no `types:`, so it defaults to
[opened, synchronize, reopened] - adding a label starts no run, and a manual
re-run replays the original payload without it. It also reached neither
hil-hfp-iar (its own selection step, no needs: set-matrix) nor CircleCI (no label
access at all), so even when the workflow did run the promise in the comment was
false for two of the three consumers.

Nothing else referenced it - no labeler.yml entry, no doc, no test, no spec row.
A broken escape hatch is worse than none; reverting a bad selector commit gets
the same outcome.
Neither token can match. hw/bsp/kinetis_kl sets FAMILY_MCUS KINETIS_KL and
compiles OPT_MCU_KINETIS_KL; hw/bsp/samd5x_e5x sets FAMILY_MCUS SAMD51 SAME54 and
compiles OPT_MCU_SAMD51. MKL25ZXX and SAME5X are still valid OPT_MCU_ names in
src/tusb_option.h, which is why they grep clean and read as live - what changed is
the BSP side (the kinetis rework, 1fc203b).

They gate nothing today: every one of the 13 examples carrying them is already
built on the very board the line meant to exclude, verified per example with
skip_example(). So removal is a no-op for what CI compiles, confirmed by building
frdm_kl25z x {msc_dual_lun, cdc_msc_freertos, audio_test_freertos} and
metro_m4_express x {audio_test, cdc_uac2, audio_test_multi_rate, uac2_headset,
audio_4_channel_mic, uac2_speaker_fb} - all green.

Dropped rather than re-pointed at the current spellings: re-pointing would remove
build coverage that works. If the original intent was runtime rather than
compile-time (samd51 iso-IN capture, say), that exclusion belongs in the roster
where metro_m4_express already skips device/audio_test_freertos.

TestMcuTokensResolve's allowlist shrinks to match, so it stays a list that only
ever gets shorter.
get_family_boards' `if preferred_list and examples is None: return [preferred_list[0]]`
cannot be reached: with examples None, buildable() is True for every board, so the
loop above returns on its first iteration. Verified the picks are unchanged -
lpc54 unfiltered lpcxpresso54114, filtered lpcxpresso54608, samd5x metro_m4_express.

docs/reference/hil_boards.md is generated from the roster by tools/gen_doc.py and
was not regenerated when metro_m4_express's build.args became a variant, so its
Variants cell was empty where the generator now emits `metro_m4_express` - exactly
as raspberry_pi_pico already shows. Nothing in pre-commit or the workflows checks
generated-doc freshness, so the drift would have surfaced as an unexplained hunk
in someone else's PR.

The spec records the bth ruling: a class no example config enables selects nothing
on both axes. Worth stating because the exposure changed - GHA used to rebuild
everything for such a PR by accident, through the empty-families bug, so with that
fixed both providers now correctly build nothing.
A reader landing in ci_select.py could see `# rule 6` markers but had to open the
design spec to learn what rule 6 IS. The table now lives in the docstring as a
carbon copy of the spec's - all 23 rows, cell for cell.

Both are maintained by hand, so TestRuleTableIsCarbonOfTheSpec pins them: it
parses each table and compares row ids and every cell, so editing one without the
other fails. Verified by renaming a rule id in the docstring - both assertions
fire.

A second test pins the table against the CODE: every documented rule id must
appear as a `# rule N` marker on a branch. That turned up five rows whose branch
was marked by name rather than number (metadata, metrics, the empty-port case,
lib, get_deps), so a row could have been documented with no branch, or a branch
renumbered, without anything noticing. The markers are numeric now.

Unpadded pipes rather than an aligned table: the spec's row 16 path cell alone is
309 characters, and padding five columns to it would make the docstring unreadable.
The test compares stripped cells, so the two render differently and stay identical
in content.
A reviewer's mutation pass broke five expressions one at a time and the whole
suite stayed green each time. All five silently NARROW the selection, which is the
direction that merges a regression rather than wasting a runner:

  build.py   defines = ()            metro_m4_express's MAX3421 firmware, the rig's
                                     only one, stops being built
  build.py   buildable() -> True     lpc54 picks a board that skips the whole -e set,
                                     so the leg compiles nothing
  ci_select  no _CLS_STEM_RE         a midi2 change stops selecting the one example
                                     that compiles it - the e13eff8 fix, unpinned
  ci_select  drop + ('dual',)        a dcd/hcd change stops selecting dual examples
  ci_select  drop ci=True            selector answers differently on a laptop than on
                                     a runner

Each new test was checked against its own mutant: five mutants, one failure each,
zero on the restored tree.

The sixth was vacuous rather than missing. test_class_source_selecting_nothing_
selects_nothing asserted on src/class/vendor/vendor_host.c, which a57f857
deleted - so any made-up path reached the same branch and it could not fail. It
now uses src/class/bth/bth_device.c, asserts the file exists, and checks the
reason names the class.
Four hot spots, each measured before and after. No selection changes: a
260-path differential over every rule shape reports 0 divergences against
the previous commit, and the whole run is 1.8x faster (26.0s -> 14.6s).

_prune_buildable was 0.63s of 0.64s. It materialised every family's full
buildable list to answer two yes/no questions, so it probed 46 examples x
every board of the family whatever the diff had selected. Ask skip_example
per example instead and both questions short-circuit: 6,883 calls -> 1,889
on one cdc_device.c diff.

Its one subtlety is preserved and now pinned by a test. A family whose
selection is already everything it can build carries no -e list, because
build.py applies the same skip_example the pruner just did and would yield
that set anyway. Dropping that check compiled exactly the same firmware and
only inflated the payload, which is why 216 tests stayed green through it.

reasons are a human diagnostic printed to stderr, and no consumer reads them
back - but they rode in the JSON as ~97% of it (453 KB -> 12 KB on a
whole-tree diff), through ci_set_matrix, hil_ci_set_matrix, an inline python
and three jq calls. The in-process dicts still carry them for the tests.

tusb_config.h is re-read once per class path per axis: 4,240 reads of the
same 46 files for a diff touching all of src/class. Caching the text (not
_config_enables, whose macros argument is a list) takes that to 46, 0.48s ->
0.13s.

lib_examples globbed '**/*' per example to keep two filenames - 489 entries
to use 107 against a clean tree, and unbounded once `make BOARD=... all` has
written _build/, which is where /pre-pr runs.
The upload step passed $EX_ARGS, which never scoped its targets -
examples-membrowse-upload is not `all`, so resolve_example_target_groups
passes it through as the aggregate - but did move the board, because
--one-first picks one that can build the -e set.

Dropping it keeps the size history keyed on each family's preferred board
whatever a PR touches. The cost, accepted deliberately: where that board is
not the one the Build step compiled, there is no elf and every example
uploads --identical, so the leg contributes no real data. Eleven families
are in that position (imxrt lpc11 lpc18 lpc54 mcx rp2040 rx samd11 stm32l0
stm32l4 tm4c) and the rest are unaffected.

The test that pinned the old contract is inverted rather than deleted, and a
second one derives the diverging set from build.py and pins it, so the cost
stays measured - it already caught an undercount of four.
@hathach
hathach force-pushed the claude/ci-empty-families branch from 6db69b6 to 94fa573 Compare August 25, 2026 02:04
@hathach hathach changed the title ci: stop treating an empty family list as an unusable one ci: an empty selection must build nothing, plus selector follow-ups Aug 25, 2026
@hathach
hathach merged commit da255b1 into master Aug 25, 2026
336 of 341 checks passed
@hathach
hathach deleted the claude/ci-empty-families branch August 25, 2026 02:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants