Report portable-slot exclusions alongside release failures

This commit is contained in:
mikecarper
2026-09-13 11:31:05 -07:00
parent 1274956f86
commit fff92f0588
3 changed files with 68 additions and 3 deletions
+4
View File
@@ -132,3 +132,7 @@ accepts `--allow-partial` with `--version 1.17.1.6`, the original completed buil
status and source commit. It lists every failed attempt in `BUILD-FAILURES.md`
and `BUILD-FAILURES.json`; each published file must still pass all qualification
and memory checks. Repair the failures and add the corrected profiles afterward.
`PORTABLE-PROFILE-EXCLUSIONS.md` and `.json` separately list standard ESP32
profiles that exceed the 1.25 MiB slot and are redirected to the expanded Full
pass. Those size deferrals are not compiler failures. Qualified smaller images
remain available; Full alternatives require their matching partition layout.
+44 -2
View File
@@ -128,6 +128,31 @@ def completed_matrix_failures(status, allow_partial=False):
return failures
def portable_profile_exclusions(status):
if not status.get("log"):
return [] # Older successful status files did not require a log path.
text = Path(status["log"]).read_text(errors="replace")
summaries = list(re.finditer(
r"^(\d+) standard ESP32 target\(s\) exceeded the portable OTA slot and were deferred to the expanded FULL pass:\s*$",
text, re.M))
if not summaries:
if re.search(r"^DEFERRED: ", text, re.M):
raise ValueError("portable-profile exclusion summary is missing")
return []
summary = summaries[-1]
count = int(summary[1])
names = []
for line in text[summary.end():].lstrip("\r\n").splitlines()[:count]:
match = re.fullmatch(r" ([\w.+-]+)", line)
if not match:
raise ValueError("portable-profile exclusion summary is incomplete")
names.append(match[1])
if not count or len(names) != count or len(set(names)) != count:
raise ValueError("portable-profile exclusion summary is incomplete")
return [{"target": name, "profile": "standard", "reason": "portable_slot_overflow"}
for name in names]
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", required=True, type=Path)
@@ -141,6 +166,7 @@ def main():
args = parser.parse_args()
status = dict(line.split("=", 1) for line in args.build_status.read_text().splitlines() if "=" in line)
failures = completed_matrix_failures(status, args.allow_partial)
portable_exclusions = portable_profile_exclusions(status)
output_directory = Path(status["working_directory"]) / status["output_directory"]
if output_directory.resolve() != args.input.resolve():
raise ValueError("build status belongs to another output directory")
@@ -213,6 +239,21 @@ def main():
if exclusions.is_file():
shutil.copy2(exclusions, destination / exclusions.name)
partial_note = ""
portable_note = ""
if portable_exclusions:
report = {"source": args.commit, "version": args.version,
"portable_slot_bytes": 1310720, "exclusions": portable_exclusions}
(destination / "PORTABLE-PROFILE-EXCLUSIONS.json").write_text(json.dumps(report, indent=2) + "\n")
(destination / "PORTABLE-PROFILE-EXCLUSIONS.md").write_text(
"# Portable image limits\n\n"
"These standard profiles exceeded the 1.25 MiB (1,310,720-byte) application slot. "
"The matrix attempted their expanded Full alternatives instead. Check TARGET-MANIFEST.json "
"for qualified Full images and use their matching partition layout. "
"Smaller images that passed qualification remain available.\n\n"
+ "".join(f"- `{item['target']}`\n" for item in portable_exclusions))
report_url = f"https://github.com/{args.repo}/releases/download/{group['tag']}/PORTABLE-PROFILE-EXCLUSIONS.md"
portable_note = (f"**Portable image limits:** {len(portable_exclusions)} standard profile(s) exceeded "
f"the 1.25 MiB slot and were redirected to the Full pass. [Affected profiles]({report_url}).\n\n")
if failures:
report = {"source": args.commit, "version": args.version, "matrix_state": status["state"],
"matrix_exit_code": int(status["exit_code"]), "failures": failures}
@@ -235,7 +276,7 @@ def main():
f"[Release details]({source_url}/docs/releases/{args.version}.md)\n\n"
"Match the exact board, radio, display, and storage variant. ESP32 WiFi updates use the application `.bin`; the merged image installs boot/partition data over USB. nRF52 `.zip` files are native application DFU packages. LoRa MOTA installation requires an exact destination package; nRF52 also requires its matching OTAFIX bootloader. Full Companions do not LoRa-install onto themselves.\n\n"
"Firmware and capability checks passed in the build matrix. Hardware update testing across every board was not performed.\n\n"
+ partial_note + links + "\n")
+ partial_note + portable_note + links + "\n")
(args.output / (group["key"] + "-notes.md")).write_text(body)
(destination / "BUILD-NOTES.txt").write_text(body)
picker = ("<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width'>"
@@ -265,7 +306,8 @@ def main():
group["asset_count"] = len(files) + 1
group["target_count"] = len(group.pop("records"))
(args.output / "release-plan.json").write_text(json.dumps({"source": args.commit, "version": args.version, "radio": radio, "groups": groups,
"matrix": {"state": status["state"], "exit_code": int(status["exit_code"]), "failures": failures}}, indent=2) + "\n")
"matrix": {"state": status["state"], "exit_code": int(status["exit_code"]), "failures": failures,
"portable_profile_exclusions": portable_exclusions}}, indent=2) + "\n")
print(json.dumps({"targets": len(records), "groups": groups}, indent=2))
+20 -1
View File
@@ -15,6 +15,19 @@ spec.loader.exec_module(package)
class ReleaseQualificationTest(unittest.TestCase):
def test_portable_exclusions_require_a_complete_summary(self):
self.assertEqual(package.portable_profile_exclusions({}), [])
with tempfile.TemporaryDirectory() as temp:
log = Path(temp) / "matrix.log"
status = {"log": str(log)}
header = "2 standard ESP32 target(s) exceeded the portable OTA slot and were deferred to the expanded FULL pass:\n"
for body in ("DEFERRED: one (standard)\n", header + " one\n", header + " one\n one\n"):
log.write_text(body)
with self.assertRaisesRegex(ValueError, "exclusion summary"):
package.portable_profile_exclusions(status)
log.write_text(header + " one\n two\nLogging matrix completed successfully.\n")
self.assertEqual([r["target"] for r in package.portable_profile_exclusions(status)], ["one", "two"])
def test_partial_matrix_requires_completion_and_an_explicit_opt_in(self):
self.assertEqual(package.completed_matrix_failures({"state": "completed", "exit_code": "0"}), [])
for state, code in (("running", "0"), ("starting", "0"), ("failed", "143")):
@@ -124,7 +137,9 @@ class ReleaseQualificationTest(unittest.TestCase):
# A finished matrix can publish good files while explicitly
# retaining the failed attempts and the real nonzero exit code.
log = directory / "matrix.log"
log.write_text("Logging matrix completed with 1 failed build(s):\n"
log.write_text("1 standard ESP32 target(s) exceeded the portable OTA slot and were deferred to the expanded FULL pass:\n"
" large_repeater\n"
"Logging matrix completed with 1 failed build(s):\n"
" missing_repeater (standard) -> /tmp/missing.log\n")
status.write_text("state=failed\n" + settings.replace("exit_code=0", "exit_code=1")
+ f"log={log}\n")
@@ -141,9 +156,13 @@ class ReleaseQualificationTest(unittest.TestCase):
assets = directory / "partial/companion"
self.assertIn("Partial matrix", (assets / "BUILD-NOTES.txt").read_text())
self.assertIn("missing_repeater", (assets / "BUILD-FAILURES.md").read_text())
self.assertEqual(plan["matrix"]["portable_profile_exclusions"][0]["target"], "large_repeater")
self.assertIn("large_repeater", (assets / "PORTABLE-PROFILE-EXCLUSIONS.md").read_text())
self.assertIn("Portable image limits", (assets / "BUILD-NOTES.txt").read_text())
self.assertEqual(len(list(assets.iterdir())), plan["groups"][0]["asset_count"])
checksums = (assets / "SHA256SUMS.txt").read_text()
self.assertIn("BUILD-FAILURES.json", checksums)
self.assertIn("PORTABLE-PROFILE-EXCLUSIONS.json", checksums)
for line in checksums.splitlines():
digest, name = line.split(" ", 1)
self.assertEqual(hashlib.sha256((assets / name).read_bytes()).hexdigest(), digest)