ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_build_shard_union.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Gate: cross-build shards covered every firmware configuration exactly once.
5
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.
17
18The proof is a set comparison against an INDEPENDENTLY re-derived truth:
19
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
25 that did not run.
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.
31
32Run::
33
34 check_build_shard_union.py --shards N # the gate
35 check_build_shard_union.py --selftest # prove it still detects violations
36
37Exit 0 when the shards cover the tree exactly, 1 (naming every discrepancy)
38otherwise.
39"""
40
41from __future__ import annotations
42
43import argparse
44import sys
45import tempfile
46from pathlib import Path
47
48REPO_ROOT = Path(__file__).resolve().parents[2]
49
50#: Where all_examples.sh writes its per-shard manifests.
51SHARD_SUBDIR = Path("build") / "build_all_examples" / ".shard"
52
53#: The full execution matrix, written identically by every shard.
54ALL_CONFIGS_NAME = "all-configs.txt"
55
56RC_OK = 0
57RC_VIOLATION = 1
58MIN_EXAMPLE_PATH_PARTS = 2
59
60
61def discover_apps(repo_root: Path) -> list[str]:
62 """Independently derive every required firmware build configuration.
63
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.
69
70 :param repo_root: Repository root to discover under.
71 :returns: Sorted app names.
72 """
73 configs: set[str] = set()
74 examples = repo_root / "examples"
75 if examples.is_dir():
76 for main_c in examples.rglob("main.c"):
77 if main_c.parent.name != "src":
78 continue
79 app_dir = main_c.parent.parent
80 if not (app_dir / "CMakeLists.txt").is_file():
81 continue
82 rel = app_dir.relative_to(examples)
83 if len(rel.parts) < MIN_EXAMPLE_PATH_PARTS or rel.parts[0] == "shared":
84 continue
85 configs.add("::".join(rel.parts))
86
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":
91 continue
92 app_dir = main_c.parent.parent
93 if not (app_dir / "CMakeLists.txt").is_file():
94 continue
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)
102
103
104def read_manifest(path: Path) -> list[str]:
105 """Read one newline-delimited manifest, dropping blank lines.
106
107 :param path: Manifest file.
108 :returns: The app names it lists, in file order.
109 """
110 if not path.is_file():
111 return []
112 return [ln.strip() for ln in path.read_text(encoding="ascii").splitlines() if ln.strip()]
113
114
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):
120 if app in seen:
121 problems.append(f"app '{app}' claimed by both shard {seen[app]} and shard {k}")
122 else:
123 seen[app] = k
124
125 if sorted(seen.keys()) != expected:
126 missing = set(expected) - set(seen.keys())
127 extra = set(seen.keys()) - set(expected)
128 if missing:
129 problems.append(f"{len(missing)} firmware configuration(s) never built by any shard")
130 if extra:
131 problems.append(
132 f"{len(extra)} configuration(s) claimed in manifests are not structural"
133 )
134 return problems
135
136
137def check_union(repo_root: Path, shards: int) -> tuple[int, list[str]]:
138 """Compare the union of the shard manifests against a fresh discovery.
139
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)``.
143 """
144 problems: list[str] = []
145 shard_dir = repo_root / SHARD_SUBDIR
146 expected = discover_apps(repo_root)
147 if not expected:
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.",
152 ]
153 if not shard_dir.is_dir():
154 return RC_VIOLATION, [f"shard manifest directory {shard_dir} does not exist."]
155
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")
161
162 shard_files = [shard_dir / f"shard-{k}-of-{shards}.txt" for k in range(1, shards + 1)]
163 problems.extend(
164 f"missing shard manifest {path.name}" for path in shard_files if not path.is_file()
165 )
166
167 if problems:
168 return RC_VIOLATION, problems
169
170 problems.extend(_audit_shard_contents(shard_files, expected))
171 if problems:
172 return RC_VIOLATION, problems
173
174 print(
175 f"check_build_shard_union.py: {shards} shard(s) covered all "
176 f"{len(expected)} firmware configuration(s) exactly once."
177 )
178 return RC_OK, []
179
180
181def _write_tree(root: Path, apps: list[str], *, ereader: bool = False) -> None:
182 """Materialise a throwaway examples/ tree of buildable apps.
183
184 :param root: Fake repo root.
185 :param apps: App directory names to create.
186 """
187 for app in apps:
188 d = root / "examples" / "tier" / app
189 src = d / "src"
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")
193 if ereader:
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",
199 encoding="ascii",
200 )
201
202
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.
205
206 :param root: Fake repo root.
207 :param shards: Declared shard count.
208 :param slices: Per-shard configuration lists, index 0 == shard 1.
209 """
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),
217 encoding="ascii",
218 )
219
220
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], ...] = (
224 # (label, example names, ereader, shards, slices, expect_pass)
225 (
226 "complete examples plus board variants",
227 ["a", "b"],
228 True,
229 2,
230 [
231 ["board::stand_alone::ra8d2-ereader", "tier::a"],
232 ["board::stand_alone::ra8d2-ereader@ns-xip", "tier::b"],
233 ],
234 True,
235 ),
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),
240 (
241 "an unknown app appeared",
242 ["a", "b"],
243 False,
244 2,
245 [["tier::a"], ["tier::b", "ghost"]],
246 False,
247 ),
248 (
249 "e-reader XIP configuration omitted",
250 [],
251 True,
252 1,
253 [["board::stand_alone::ra8d2-ereader"]],
254 False,
255 ),
256 )
257 failures = 0
258 for label, tree, ereader, shards, slices, expect_pass in cases:
259 with tempfile.TemporaryDirectory() as td:
260 root = Path(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}")
268 if not ok:
269 failures += 1
270 for p in problems:
271 print(f" {p}")
272
273 return failures
274
275
276def _selftest_boundaries() -> int:
277 """Prove missing manifests and an empty discovery cannot pass vacuously."""
278 failures = 0
279 with tempfile.TemporaryDirectory() as td:
280 root = Path(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
286
287 with tempfile.TemporaryDirectory() as td:
288 root = Path(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
294 return failures
295
296
297def selftest() -> int:
298 """Assert the union checker fires on breaks and stays quiet when complete.
299
300 :returns: 0 when every case behaves, 1 otherwise.
301 """
302 failures = _selftest_cases() + _selftest_boundaries()
303
304 if failures:
305 print(f"check_build_shard_union.py --selftest: {failures} case(s) FAILED", file=sys.stderr)
306 return RC_VIOLATION
307 print("check_build_shard_union.py --selftest: all cases pass.")
308 return RC_OK
309
310
311def main(argv: list[str]) -> int:
312 """Entry point.
313
314 :param argv: Command-line arguments without the program name.
315 :returns: Process exit status.
316 """
317 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
318 parser.add_argument(
319 "--shards",
320 type=int,
321 default=None,
322 help="how many shards were scheduled (must match the manifest names)",
323 )
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)
327
328 if args.selftest:
329 return selftest()
330 if args.shards is None or args.shards < 1:
331 parser.error("--shards must be a positive integer")
332
333 rc, problems = check_union(args.repo_root, args.shards)
334 if problems:
335 print(
336 "check_build_shard_union.py: the cross-build shards did NOT cover the tree",
337 file=sys.stderr,
338 )
339 for p in problems:
340 print(f" {p}", file=sys.stderr)
341 print(
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.",
346 file=sys.stderr,
347 )
348 return rc
349
350
351if __name__ == "__main__":
352 sys.exit(main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298