From fff92f0588f7fc1a36e1f3eab6eddcd52e85e82e Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sun, 13 Sep 2026 11:31:05 -0700 Subject: [PATCH] Report portable-slot exclusions alongside release failures --- docs/releases/1.17.1.6.md | 4 +++ scripts/package_cascade_release.py | 46 ++++++++++++++++++++++++++-- test/test_cascade_release_package.py | 21 ++++++++++++- 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/docs/releases/1.17.1.6.md b/docs/releases/1.17.1.6.md index 04ed8163..a55768a1 100644 --- a/docs/releases/1.17.1.6.md +++ b/docs/releases/1.17.1.6.md @@ -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. diff --git a/scripts/package_cascade_release.py b/scripts/package_cascade_release.py index 5219fbb2..677be042 100644 --- a/scripts/package_cascade_release.py +++ b/scripts/package_cascade_release.py @@ -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 = ("" @@ -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)) diff --git a/test/test_cascade_release_package.py b/test/test_cascade_release_package.py index f60039f8..ad9135fa 100644 --- a/test/test_cascade_release_package.py +++ b/test/test_cascade_release_package.py @@ -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)