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)