4"""Gate: cross-build shards covered every firmware configuration exactly once.
6``scripts/builders/all_examples.sh`` builds a stride slice of the canonical
7configuration matrix when ``RA8_BUILD_SHARDS``/``RA8_BUILD_SHARD`` are set, so the
8``build-cross`` gate can fan out across parallel CI jobs. That fan-out is only
9sound if the shards, taken together, build the same set the unsharded gate
10would have built. Nothing about a parallel matrix guarantees that: a shard
11whose job was skipped, cancelled, or silently slice-computed to the empty set
12produces no output and no error, and the downstream ``stack-usage`` aggregate
13would simply measure fewer ``.su`` files and still clear its floor on the
14shards that DID run. That is a gate quietly checking less than it claims --
15the failure mode this repository has hit repeatedly -- so it gets its own
16check rather than an assumption.
18The proof is a set comparison against an INDEPENDENTLY re-derived truth:
20* This checker re-runs structural discovery itself (:func:`discover_apps`)
21 rather than trusting the ``all-configs.txt`` a shard wrote. A shard vouching for its
22 own idea of what the tree contains proves nothing -- if its discovery broke,
23 its manifest and its self-report break together and agree.
24* Every shard ``1..N`` must have left a manifest. A missing one is a shard
26* The union of the manifests must equal the discovered set exactly: a missing
27 configuration FAILS (it was never built), and one claimed by two shards
28 FAILS (the stride is broken, so some other configuration is missing too).
29* ``all-configs.txt`` must agree with the re-derived set, which catches the shards
30 having discovered a *different* tree than this checker sees.
34 check_build_shard_union.py --shards N # the gate
35 check_build_shard_union.py --selftest # prove it still detects violations
37Exit 0 when the shards cover the tree exactly, 1 (naming every discrepancy)
41from __future__
import annotations
46from pathlib
import Path
48REPO_ROOT = Path(__file__).resolve().parents[2]
51SHARD_SUBDIR = Path(
"build") /
"build_all_examples" /
".shard"
54ALL_CONFIGS_NAME =
"all-configs.txt"
58MIN_EXAMPLE_PATH_PARTS = 2
61def discover_apps(repo_root: Path) -> list[str]:
62 """Independently derive every required firmware build configuration.
64 This structural walk deliberately does not import ``ra8_apps.py``, the
65 execution authority. It scans both examples and standalone board products,
66 then independently requires the e-reader's normal and Non-Secure XIP
67 configurations. A defect in the execution enumerator therefore cannot make
68 this proof agree with the same omission.
70 :param repo_root: Repository root to discover under.
71 :returns: Sorted app names.
73 configs: set[str] = set()
74 examples = repo_root /
"examples"
76 for main_c
in examples.rglob(
"main.c"):
77 if main_c.parent.name !=
"src":
79 app_dir = main_c.parent.parent
80 if not (app_dir /
"CMakeLists.txt").is_file():
82 rel = app_dir.relative_to(examples)
83 if len(rel.parts) < MIN_EXAMPLE_PATH_PARTS
or rel.parts[0] ==
"shared":
85 configs.add(
"::".join(rel.parts))
87 board_root = repo_root /
"apps" /
"board" /
"stand_alone"
88 if board_root.is_dir():
89 for main_c
in board_root.rglob(
"main.c"):
90 if main_c.parent.name !=
"src":
92 app_dir = main_c.parent.parent
93 if not (app_dir /
"CMakeLists.txt").is_file():
95 rel = app_dir.relative_to(board_root)
96 name =
"ra8d2-ereader" if rel.as_posix() ==
"ereader" else rel.name
97 identifier = f
"board::stand_alone::{name}"
98 configs.add(identifier)
99 if rel.as_posix() ==
"ereader":
100 configs.add(f
"{identifier}@ns-xip")
101 return sorted(configs)
104def read_manifest(path: Path) -> list[str]:
105 """Read one newline-delimited manifest, dropping blank lines.
107 :param path: Manifest file.
108 :returns: The app names it lists, in file order.
110 if not path.is_file():
112 return [ln.strip()
for ln
in path.read_text(encoding=
"ascii").splitlines()
if ln.strip()]
115def _audit_shard_contents(shard_files: list[Path], expected: list[str]) -> list[str]:
116 problems: list[str] = []
117 seen: dict[str, int] = {}
118 for k, path
in enumerate(shard_files, start=1):
119 for app
in read_manifest(path):
121 problems.append(f
"app '{app}' claimed by both shard {seen[app]} and shard {k}")
125 if sorted(seen.keys()) != expected:
126 missing = set(expected) - set(seen.keys())
127 extra = set(seen.keys()) - set(expected)
129 problems.append(f
"{len(missing)} firmware configuration(s) never built by any shard")
132 f
"{len(extra)} configuration(s) claimed in manifests are not structural"
137def check_union(repo_root: Path, shards: int) -> tuple[int, list[str]]:
138 """Compare the union of the shard manifests against a fresh discovery.
140 :param repo_root: Repository root holding the shard directory.
141 :param shards: The number of shards that were scheduled.
142 :returns: ``(exit_code, problem_lines)``.
144 problems: list[str] = []
145 shard_dir = repo_root / SHARD_SUBDIR
146 expected = discover_apps(repo_root)
148 return RC_VIOLATION, [
149 "no firmware configurations discovered under examples/ or "
150 "apps/board/stand_alone/ -- this checker "
151 "cannot vouch for a union it has no truth to compare against.",
153 if not shard_dir.is_dir():
154 return RC_VIOLATION, [f
"shard manifest directory {shard_dir} does not exist."]
156 all_configs_file = shard_dir / ALL_CONFIGS_NAME
157 if not all_configs_file.is_file():
158 problems.append(f
"missing {all_configs_file}")
159 elif read_manifest(all_configs_file) != expected:
160 problems.append(f
"{ALL_CONFIGS_NAME} disagrees with fresh discovery")
162 shard_files = [shard_dir / f
"shard-{k}-of-{shards}.txt" for k
in range(1, shards + 1)]
164 f
"missing shard manifest {path.name}" for path
in shard_files
if not path.is_file()
168 return RC_VIOLATION, problems
170 problems.extend(_audit_shard_contents(shard_files, expected))
172 return RC_VIOLATION, problems
175 f
"check_build_shard_union.py: {shards} shard(s) covered all "
176 f
"{len(expected)} firmware configuration(s) exactly once."
181def _write_tree(root: Path, apps: list[str], *, ereader: bool =
False) ->
None:
182 """Materialise a throwaway examples/ tree of buildable apps.
184 :param root: Fake repo root.
185 :param apps: App directory names to create.
188 d = root /
"examples" /
"tier" / app
190 src.mkdir(parents=
True, exist_ok=
True)
191 (src /
"main.c").write_text(
"int main(void){return 0;}\n", encoding=
"ascii")
192 (d /
"CMakeLists.txt").write_text(
"add_executable(test src/main.c)\n", encoding=
"ascii")
194 d = root /
"apps" /
"board" /
"stand_alone" /
"ereader"
195 (d /
"src").mkdir(parents=
True, exist_ok=
True)
196 (d /
"src" /
"main.c").write_text(
"void main(void) {}\n", encoding=
"ascii")
197 (d /
"CMakeLists.txt").write_text(
198 "add_executable(ereader src/main.c)\n",
203def _shard_manifests(root: Path, shards: int, slices: list[list[str]]) ->
None:
204 """Write per-shard manifests plus all-configs.txt into a fake tree.
206 :param root: Fake repo root.
207 :param shards: Declared shard count.
208 :param slices: Per-shard configuration lists, index 0 == shard 1.
210 d = root / SHARD_SUBDIR
211 d.mkdir(parents=
True, exist_ok=
True)
212 every = sorted({a
for s
in slices
for a
in s})
213 (d / ALL_CONFIGS_NAME).write_text(
"".join(f
"{a}\n" for a
in every), encoding=
"ascii")
214 for i, names
in enumerate(slices, start=1):
215 (d / f
"shard-{i}-of-{shards}.txt").write_text(
216 "".join(f
"{a}\n" for a
in names),
221def _selftest_cases() -> int:
222 """Run the complete and malformed shard-manifest fixtures."""
223 cases: tuple[tuple[str, list[str], bool, int, list[list[str]], bool], ...] = (
226 "complete examples plus board variants",
231 [
"board::stand_alone::ra8d2-ereader",
"tier::a"],
232 [
"board::stand_alone::ra8d2-ereader@ns-xip",
"tier::b"],
236 (
"complete 1-way", [
"a",
"b"],
False, 1, [[
"tier::a",
"tier::b"]],
True),
237 (
"a shard built nothing", [
"a",
"b"],
False, 2, [[
"tier::a"], []],
False),
238 (
"an app fell through", [
"a",
"b"],
False, 2, [[
"tier::a"], []],
False),
239 (
"an app built twice", [
"a",
"b"],
False, 2, [[
"tier::a"], [
"tier::a"]],
False),
241 "an unknown app appeared",
245 [[
"tier::a"], [
"tier::b",
"ghost"]],
249 "e-reader XIP configuration omitted",
253 [[
"board::stand_alone::ra8d2-ereader"]],
258 for label, tree, ereader, shards, slices, expect_pass
in cases:
259 with tempfile.TemporaryDirectory()
as td:
261 _write_tree(root, tree, ereader=ereader)
262 _shard_manifests(root, shards, slices)
263 rc, problems = check_union(root, shards)
264 ok = (rc == RC_OK)
if expect_pass
else (rc == RC_VIOLATION)
265 verdict =
"ok" if ok
else "FAIL"
266 want =
"pass" if expect_pass
else "fire"
267 print(f
" [{verdict}] {label}: expected to {want}, rc={rc}")
276def _selftest_boundaries() -> int:
277 """Prove missing manifests and an empty discovery cannot pass vacuously."""
279 with tempfile.TemporaryDirectory()
as td:
281 _write_tree(root, [
"a"])
282 rc, _ = check_union(root, 1)
283 ok = rc == RC_VIOLATION
284 print(f
" [{'ok' if ok else 'FAIL'}] absent manifest dir: expected to fire, rc={rc}")
285 failures += 0
if ok
else 1
287 with tempfile.TemporaryDirectory()
as td:
289 (root / SHARD_SUBDIR).mkdir(parents=
True)
290 rc, _ = check_union(root, 1)
291 ok = rc == RC_VIOLATION
292 print(f
" [{'ok' if ok else 'FAIL'}] empty tree: expected to fire, rc={rc}")
293 failures += 0
if ok
else 1
297def selftest() -> int:
298 """Assert the union checker fires on breaks and stays quiet when complete.
300 :returns: 0 when every case behaves, 1 otherwise.
302 failures = _selftest_cases() + _selftest_boundaries()
305 print(f
"check_build_shard_union.py --selftest: {failures} case(s) FAILED", file=sys.stderr)
307 print(
"check_build_shard_union.py --selftest: all cases pass.")
311def main(argv: list[str]) -> int:
314 :param argv: Command-line arguments without the program name.
315 :returns: Process exit status.
317 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
322 help=
"how many shards were scheduled (must match the manifest names)",
324 parser.add_argument(
"--repo-root", type=Path, default=REPO_ROOT)
325 parser.add_argument(
"--selftest", action=
"store_true")
326 args = parser.parse_args(argv)
330 if args.shards
is None or args.shards < 1:
331 parser.error(
"--shards must be a positive integer")
333 rc, problems = check_union(args.repo_root, args.shards)
336 "check_build_shard_union.py: the cross-build shards did NOT cover the tree",
340 print(f
" {p}", file=sys.stderr)
342 "\n Every app must be built by exactly one shard. Do NOT relax this to\n"
343 " make a red matrix pass: an unbuilt app is an unchecked app, and the\n"
344 " stack-usage aggregate downstream would still clear its floor on the\n"
345 " shards that did run.",
351if __name__ ==
"__main__":
352 sys.exit(
main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.