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.
268 lines
11 KiB
Python
Executable File
268 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Emit the native-test CI matrix: one shard per matrix row, derived from test/.
|
|
|
|
Shards are safe to run in parallel because isolation is per suite, not per run: every suite gets
|
|
its own scratch $HOME via bin/pio-test-isolate.sh.
|
|
|
|
Two kinds of row come out:
|
|
|
|
* general - a slice of the test_* tree under [env:coverage]. AREA_RULES place each suite, first
|
|
match wins, unmatched to "misc". Areas over --max-suites split, smaller ones pack together.
|
|
|
|
* fixed-env - one row per SPECIAL_ENVS entry, whose suite list is read from its test_filter in
|
|
platformio.ini rather than restated here.
|
|
|
|
Usage:
|
|
bin/test-shards.py # matrix JSON on stdout
|
|
bin/test-shards.py --summary # ... plus a human-readable table on stderr
|
|
bin/test-shards.py --max-suites 8 # smaller shards, more of them
|
|
bin/test-shards.py --seed 12345 # vary which suites share a shard
|
|
|
|
Exit: 0 ok, 2 on a malformed tree or a fixed env whose test_filter went missing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import configparser
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
|
|
# Ordered "area name" -> regex; first match wins. Extend an area by widening its regex, add an area
|
|
# by inserting a line. Anything unmatched lands in FALLBACK_AREA.
|
|
AREA_RULES = [
|
|
("admin", r"^test_(admin|pki)_"),
|
|
("crypto", r"^test_(crypto|packet_signing)$"),
|
|
("routing", r"^test_(mesh|nexthop|traceroute|hop|traffic|nodedb|warm)_"),
|
|
("position", r"^test_position_"),
|
|
("fuzz", r"^test_fuzz_"),
|
|
("packets", r"^test_(packet|transmit|meshpacket)_"),
|
|
("io", r"^test_(serial|stream|xmodem|http|mqtt)"),
|
|
]
|
|
FALLBACK_AREA = "misc"
|
|
|
|
# Envs that rebuild a fixed set of suites with different build flags. Their test_filter lives in
|
|
# the ini and is read from there.
|
|
NATIVE_INI = REPO / "variants" / "native" / "portduino" / "platformio.ini"
|
|
SPECIAL_ENVS = ["coverage-event-policy", "coverage-channel-table"]
|
|
|
|
# Suite names reach a shell as `-f <name>`. Constrained here, the one place the list is produced,
|
|
# so a creatively named directory cannot become shell text.
|
|
SUITE_RE = re.compile(r"^test_[A-Za-z0-9_]+$")
|
|
|
|
|
|
def discover_suites():
|
|
"""Every test_* directory directly under test/, sorted. The canonical set."""
|
|
suites = sorted(
|
|
p.name for p in (REPO / "test").iterdir() if p.is_dir() and p.name.startswith("test_")
|
|
)
|
|
bad = [s for s in suites if not SUITE_RE.match(s)]
|
|
if bad:
|
|
sys.exit(f"test-shards: refusing to shard, unusable suite name(s): {' '.join(bad)}")
|
|
if not suites:
|
|
sys.exit("test-shards: no test_* directories under test/ - the tree is not what it should be")
|
|
return suites
|
|
|
|
|
|
def shuffle(seed, items):
|
|
"""Reorder via bin/lib/shuffle.sh, the one implementation of the seeded shuffle.
|
|
|
|
Two copies of a Fisher-Yates would drift, and announce it as a replay reproducing a different
|
|
arrangement. The repo path goes in as $1 so a checkout directory never becomes shell source.
|
|
"""
|
|
script = 'source "$1"; shift; shuffle_suites "$@"'
|
|
out = subprocess.run(
|
|
["bash", "-c", script, "_", str(REPO / "bin" / "lib" / "shuffle.sh"), seed, *items],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
return out.stdout.split()
|
|
|
|
|
|
def areas_of(suites):
|
|
"""Bucket suites into areas, preserving AREA_RULES order and putting misc last."""
|
|
grouped = {name: [] for name, _ in AREA_RULES}
|
|
grouped[FALLBACK_AREA] = []
|
|
for suite in suites:
|
|
area = next((name for name, rule in AREA_RULES if re.search(rule, suite)), FALLBACK_AREA)
|
|
grouped[area].append(suite)
|
|
return {name: members for name, members in grouped.items() if members}
|
|
|
|
|
|
def split(members, cap):
|
|
"""Split into the fewest chunks of at most `cap`, sized as evenly as the count allows.
|
|
|
|
Wall clock is the slowest shard, so 11 suites at cap 10 becomes 6+5, not 10+1.
|
|
"""
|
|
chunks = -(-len(members) // cap) # ceil
|
|
base, extra = divmod(len(members), chunks)
|
|
out, start = [], 0
|
|
for i in range(chunks):
|
|
size = base + (1 if i < extra else 0)
|
|
out.append(members[start : start + size])
|
|
start += size
|
|
return out
|
|
|
|
|
|
def pack(areas, cap):
|
|
"""Pack whole areas into the fewest shards of at most `cap`, keeping the loads even.
|
|
|
|
Longest-processing-time-first: within 4/3 of optimal, and unlike first-fit it will not leave
|
|
one shard holding a single two-suite area.
|
|
"""
|
|
load = lambda b: sum(len(areas[a]) for a in b) # noqa: E731
|
|
ranked = sorted(areas, key=lambda a: len(areas[a]), reverse=True)
|
|
# ceil(total / cap) is a lower bound, not a guarantee - whole areas do not divide, so three
|
|
# areas of 6 at cap 10 would put 12 in one of two bins. Grow the count until every bin fits.
|
|
for count in range(-(-sum(len(m) for m in areas.values()) // cap), len(areas) + 1):
|
|
bins = [[] for _ in range(count)]
|
|
for area in ranked:
|
|
min(bins, key=load).append(area)
|
|
if all(load(b) <= cap for b in bins):
|
|
break
|
|
# Report each shard's areas in declared order, so a name reads the same way the rules do.
|
|
order = list(areas)
|
|
return [sorted(b, key=order.index) for b in bins if b]
|
|
|
|
|
|
def build(areas, cap):
|
|
"""Lay the areas out into shards of at most `cap` suites. Returns (rows, suites placed)."""
|
|
rows, placed, small = [], [], {}
|
|
# Oversized areas become numbered shards of their own; what is left is packed together.
|
|
for area, members in areas.items():
|
|
if len(members) <= cap:
|
|
small[area] = members
|
|
continue
|
|
for i, chunk in enumerate(split(members, cap), start=1):
|
|
rows.append({"shard": f"{area}-{i}", "env": "coverage", "suites": " ".join(chunk)})
|
|
placed += chunk
|
|
for group in pack(small, cap):
|
|
members = [suite for area in group for suite in small[area]]
|
|
rows.append({"shard": "+".join(group), "env": "coverage", "suites": " ".join(members)})
|
|
placed += members
|
|
return rows, placed
|
|
|
|
|
|
def fixed_env_filter(env):
|
|
"""The suites [env:<env>] pins in its own test_filter."""
|
|
# interpolation=None: platformio.ini interpolates with ${section.option}, not configparser's
|
|
# %(name)s, so a bare % in any value elsewhere in the file would otherwise abort the parse.
|
|
parser = configparser.ConfigParser(strict=False, interpolation=None)
|
|
parser.read(NATIVE_INI, encoding="utf-8")
|
|
section = f"env:{env}"
|
|
if not parser.has_option(section, "test_filter"):
|
|
sys.exit(
|
|
f"test-shards: [{section}] in {NATIVE_INI.name} has no test_filter. It had one when this "
|
|
f"matrix was written; either restore it or drop {env} from SPECIAL_ENVS - silently "
|
|
f"emitting an empty filter would run every suite under the wrong build flags."
|
|
)
|
|
# test_filter accepts globs, and these tokens reach the same unquoted word-split and the same
|
|
# attribution gate as discovered names. Hold them to SUITE_RE too, at the producer.
|
|
names = parser.get(section, "test_filter").split()
|
|
bad = [n for n in names if not SUITE_RE.match(n)]
|
|
if bad:
|
|
sys.exit(
|
|
f"test-shards: [{section}] test_filter names something that is not a literal suite: "
|
|
f"{' '.join(bad)}. The matrix and the attribution gate both need exact names."
|
|
)
|
|
return names
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(
|
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
|
)
|
|
ap.add_argument(
|
|
"--max-suites",
|
|
type=int,
|
|
default=10,
|
|
help="largest shard, in suites (default: 10). Lower is faster and costs more runners; the "
|
|
"per-shard floor is checkout + toolchain + one src build, so past ~8 the fixed cost wins.",
|
|
)
|
|
ap.add_argument(
|
|
"--max-shards",
|
|
type=int,
|
|
default=24,
|
|
help="hard ceiling on matrix rows (default: 24). A budget, not a preference: --max-suites "
|
|
"is raised until the matrix fits, so no branch can size the fan-out by adding directories.",
|
|
)
|
|
ap.add_argument(
|
|
"--seed",
|
|
default="",
|
|
help="vary which suites share a shard. Co-location, not order - PlatformIO picks the order "
|
|
"within a shard either way. Empty means the declared alphabetical arrangement.",
|
|
)
|
|
ap.add_argument("--summary", action="store_true", help="also print the shard table to stderr")
|
|
args = ap.parse_args()
|
|
|
|
if args.max_suites < 1:
|
|
sys.exit("test-shards: --max-suites must be at least 1")
|
|
if args.max_shards <= len(SPECIAL_ENVS):
|
|
sys.exit(f"test-shards: --max-shards must leave room for the {len(SPECIAL_ENVS)} fixed envs")
|
|
|
|
suites = discover_suites()
|
|
areas = areas_of(suites)
|
|
if args.seed:
|
|
areas = {area: shuffle(args.seed, members) for area, members in areas.items()}
|
|
|
|
# Shard size is a preference, shard count is a budget: without this a branch could size the
|
|
# fan-out by adding directories. --max-suites gives way so the runner count stays bounded.
|
|
cap = args.max_suites
|
|
while True:
|
|
rows, placed = build(areas, cap)
|
|
if len(rows) + len(SPECIAL_ENVS) <= args.max_shards:
|
|
break
|
|
cap += 1
|
|
if cap != args.max_suites:
|
|
print(
|
|
f"test-shards: {len(suites)} suites would need more than {args.max_shards} shards at "
|
|
f"--max-suites {args.max_suites}; using {cap} per shard instead.",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
# Prove nothing fell out, rather than discovering an unrun suite from a coverage graph later.
|
|
if sorted(placed) != suites:
|
|
missing = sorted(set(suites) - set(placed))
|
|
sys.exit(f"test-shards: {len(missing)} suite(s) reached no shard: {' '.join(missing)}")
|
|
|
|
for env in SPECIAL_ENVS:
|
|
rows.append(
|
|
{
|
|
"shard": env.removeprefix("coverage-"),
|
|
"env": env,
|
|
"suites": " ".join(fixed_env_filter(env)),
|
|
}
|
|
)
|
|
|
|
# Exactly one shard writes the shared compiler cache: all of them compile the same src/ tree,
|
|
# and letting each save would race for the key and store the same objects a dozen times.
|
|
for row in rows:
|
|
row["cache_writer"] = False
|
|
rows[0]["cache_writer"] = True
|
|
|
|
if args.summary:
|
|
width = max(len(row["shard"]) for row in rows)
|
|
for row in rows:
|
|
count = len(row["suites"].split())
|
|
print(
|
|
f" {row['shard']:<{width}} {row['env']:<24} {count:>2} suite(s)", file=sys.stderr
|
|
)
|
|
print(
|
|
f" {len(rows)} shard(s), {len(suites)} suite(s) in test/, "
|
|
f"largest shard {max(len(row['suites'].split()) for row in rows)}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
print(json.dumps({"include": rows}))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|