Preserve qualified release artifacts across resumed builds

This commit is contained in:
mikecarper
2026-10-06 09:19:02 -07:00
parent 8d31b3932d
commit 3baa169fc6
8 changed files with 625 additions and 18 deletions
+1
View File
@@ -412,6 +412,7 @@ jobs:
python3 -B test/test_cascade_release_package.py
python3 -B test/test_release_publication.py
python3 -B test/test_local_release_full_selection.py
python3 -B test/test_local_release_capacity_archive.py
python3 -B test/test_t096_full_memory.py
python3 -B test/test_nrf52_ble_startup.py
python3 -B test/test_nrf52_ble_trace.py
+1 -1
View File
@@ -6174,7 +6174,7 @@ run_logged_build_targets() {
fi
preserved_log=0
echo "Building ${env} (${profile}); log: ${log_path}"
build_firmware "$env" > "$log_tmp" 2>&1
PIO_BUILD_JOBS_OVERRIDE=$pio_job_limit build_firmware "$env" > "$log_tmp" 2>&1
build_status=$?
if [ "$build_status" -eq 0 ] \
&& grep -Eq "^Skipping ${env}; (existing artifacts found|matching recipe and qualified artifacts found)" "$log_tmp" \
+148
View File
@@ -51,6 +51,145 @@ def run_logged(command: list[str], log: Path, environment: dict[str, str]) -> No
raise RuntimeError(f"build failed; see {log}")
def capacity_rejected_attempt(manifest_path: Path, version: str, source: str) -> dict:
"""Recognize only the successful qualification followed by portable-size rejection."""
work = manifest_path.parent
if manifest_path.is_symlink() or not manifest_path.is_file():
raise ValueError("manifest is not a regular non-symlink file")
raw = manifest_path.read_bytes()
manifest = json.loads(raw)
target = manifest.get("target")
if not isinstance(target, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.+-]*", target):
raise ValueError("invalid target identity")
artifact_version = f"{version}-{source[:8]}"
stems = (f"{target}-{artifact_version}", f"{target}-ota-{artifact_version}")
if manifest_path.name not in {stem + ".capabilities.json" for stem in stems}:
raise ValueError("manifest filename does not match target/version/source")
stem = manifest_path.name.removesuffix(".capabilities.json")
# Even an unfamiliar sidecar can contain firmware or other failed-build
# evidence. Never consume such a package merely because its bin is absent.
siblings = list(work.glob(stem + "*"))
if siblings != [manifest_path] or list(work.rglob(stem + "*")) != [manifest_path]:
raise ValueError("manifest is not the sole same-stem output")
recipe = manifest.get("build_recipe")
checks = manifest.get("verification")
if (type(manifest.get("schema_version")) is not int or manifest["schema_version"] != 2
or manifest.get("platform") != "ESP32_PLATFORM"
or manifest.get("build_profile") != "standard"
or manifest.get("artifact_target") != target
or manifest.get("verified") is not True
or manifest.get("ota_update_verified") is not True
or manifest.get("ota_update_evidence") !=
"firmware fits both OTA application slots; otadata present"
or not isinstance(checks, list) or not checks
or any(not isinstance(check, dict) or check.get("present") is not True
for check in checks)
or not isinstance(recipe, dict) or set(recipe) != {"schema_version", "sha256"}
or type(recipe.get("schema_version")) is not int or recipe["schema_version"] != 1
or not isinstance(recipe.get("sha256"), str)
or not re.fullmatch(r"[0-9a-f]{64}", recipe["sha256"])):
raise ValueError("qualification or original recipe binding is incomplete")
log = work / "build-logs" / f"{target}-standard.log"
if log.parent.is_symlink() or log.is_symlink() or not log.is_file():
raise ValueError("original standard log is missing or unsafe")
log_raw = log.read_bytes()
lines = log_raw.decode("utf-8").splitlines()
overflow = re.fullmatch(
r"ESP32 app image is (\d+) bytes, exceeding portable LoRa-OTA "
r"slot 0x10000\.\.0x150000 \((\d+) bytes\) by (\d+) bytes",
lines[-2] if len(lines) >= 2 else "")
marker = (f"DEFERRED: {target} (standard) exceeds the portable OTA slot; "
"the expanded FULL pass is required.")
version_line = f' -DFIRMWARE_VERSION="{artifact_version}"'
qualification = re.compile(
r"Verified \d+ capability marker\(s\); manifest: " + re.escape(str(manifest_path)))
if (not overflow or lines[-1] != marker or version_line not in lines
or not any(qualification.fullmatch(line) for line in lines[:-2])):
raise ValueError("original log does not prove this exact portable-size rejection")
size, limit, excess = map(int, overflow.groups())
if limit != 0x140000 or size <= limit or excess != size - limit:
raise ValueError("portable overflow measurements are inconsistent")
return {"target": target, "manifest": manifest_path.name,
"manifest_sha256": hashlib.sha256(raw).hexdigest(),
"original_log": str(log.relative_to(work)),
"original_log_sha256": hashlib.sha256(log_raw).hexdigest(),
"build_recipe": recipe, "image_bytes": size,
"portable_limit_bytes": limit, "excess_bytes": excess,
"reason": "portable_slot_overflow"}
def archive_capacity_rejected_attempts(work: Path, version: str, source: str,
*, project_root: Path = ROOT,
archive_parent: Path | None = None) -> dict:
"""Preserve proven manifest-only rejections outside recursive package discovery."""
if not re.fullmatch(r"[0-9a-f]{40}", source):
raise ValueError("invalid release source commit")
if not re.fullmatch(r"v[0-9]+(?:\.[0-9]+){3}-halo-keymind-cascade-dev", version):
raise ValueError("invalid release firmware version")
work = Path(os.path.abspath(work))
parent = Path(os.path.abspath(archive_parent or work.parent))
if (work.resolve() != work or not work.is_dir() or parent.resolve() != parent
or parent == work or work in parent.parents):
raise ValueError("unsafe work/archive location")
lock_path = project_root / ".pio" / "build-sh.lock"
lock_path.parent.mkdir(parents=True, exist_ok=True)
if lock_path.parent.resolve() != lock_path.parent or lock_path.is_symlink():
raise ValueError("unsafe build lock location")
with lock_path.open("a+") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as error:
raise RuntimeError("active build owns the checkout; no attempts were archived") from error
candidates = []
preserved = []
for manifest in sorted(work.glob("*.capabilities.json")):
try:
candidates.append(capacity_rejected_attempt(manifest, version, source))
except (ValueError, OSError, TypeError, AttributeError) as error:
preserved.append({"manifest": manifest.name, "reason": str(error)})
if not candidates:
return {"archived": [], "preserved": preserved, "archive": None}
# Complete the scan and recheck every candidate before any file moves.
for record in candidates:
if capacity_rejected_attempt(work / record["manifest"], version, source) != record:
raise ValueError("capacity attempt changed during archival scan")
archive = Path(tempfile.mkdtemp(
prefix=f".capacity-rejected-{version}-{source[:8]}-", dir=parent))
(archive / "manifests").mkdir()
(archive / "logs").mkdir()
for record in candidates:
original = work / record["original_log"]
copy = archive / "logs" / original.name
shutil.copy2(original, copy)
if digest(copy) != record["original_log_sha256"]:
raise ValueError("original log changed while preserving rejection evidence")
index = {"format": "meshcore-capacity-rejected-attempts-v1",
"source_commit": source, "firmware_version": version,
"work_directory": str(work), "state": "prepared",
"attempts": candidates, "moved_manifests": []}
def save_index() -> None:
temporary = archive / ".index.json.tmp"
temporary.write_text(json.dumps(index, indent=2, sort_keys=True,
ensure_ascii=True) + "\n", encoding="ascii")
temporary.replace(archive / "index.json")
save_index()
try:
for record in candidates:
manifest = work / record["manifest"]
if capacity_rejected_attempt(manifest, version, source) != record:
raise ValueError("capacity attempt changed before archival move")
manifest.rename(archive / "manifests" / manifest.name)
index["moved_manifests"].append(manifest.name)
save_index()
except (ValueError, OSError):
index["state"] = "interrupted"
save_index()
raise
index["state"] = "archived"
save_index()
return {"archived": candidates, "preserved": preserved, "archive": str(archive)}
def full_image_pattern(target: str, version: str, short_source: str) -> str:
return f"{target}-full-*-{version}-{short_source}.bin"
@@ -400,6 +539,15 @@ def main() -> None:
environment["PLATFORMIO_BUILD_DIR"] = str(utility_build_dir)
work.mkdir(parents=True, exist_ok=True)
environment["OUTPUT_DIR"] = str(work)
if args.resume:
archived = archive_capacity_rejected_attempts(work, args.firmware_version, source)
if archived["archived"]:
print(f'Archived {len(archived["archived"])} proven portable-capacity rejections: '
f'{archived["archive"]}', flush=True)
print(f'Preserved {len(archived["preserved"])} other package manifests; '
'ordinary resume qualification still applies.', flush=True)
# All other packages retain the existing recipe/qualification checks
# in build_legacy.sh; this helper never makes them resumable.
run_logged([
"bash", "build_legacy.sh", "build-firmwares-logging-matrix",
"--firmware-version", args.firmware_version,
+31 -9
View File
@@ -81,10 +81,9 @@ def read_stage(stage):
if len(manifests) != group["target_count"]:
raise ValueError("release target count differs from manifest")
for row in manifests:
key = row["artifact_target"]
if key in seen or row.get("verified") is not True:
raise ValueError("duplicate or unqualified release profile")
seen.add(key)
logical_target = row["artifact_target"]
if row.get("verified") is not True or row.get("source_commit", source) != source:
raise ValueError("unqualified or wrong-source release profile")
files = []
for name in row["files"]:
if Path(name).name != name or name not in checksums:
@@ -93,12 +92,35 @@ def read_stage(stage):
if hashlib.sha256(path.read_bytes()).hexdigest() != checksums[name]:
raise ValueError(f"release asset checksum failed: {name}")
if path.suffix in SUFFIXES:
if identity(name) != key or family not in name:
raise ValueError("firmware filename differs from qualified profile")
if family not in name:
raise ValueError("firmware filename belongs to a different family")
files.append(path)
if not files:
raise ValueError("qualified profile contains no firmware")
records.append((row, group["tag"], files))
identities = {identity(path.name) for path in files}
if len(identities) != 1:
raise ValueError("firmware filenames disagree on publication profile")
published_target = identities.pop()
# Full images keep their OTA identity while the filename adds its
# qualified build profile. Do not relabel the logical target.
infix = published_target.removeprefix(logical_target)
valid_infix = (published_target == logical_target
or (published_target.startswith(logical_target + "-") and (
(infix == "-ota" and "lora" in row.get("ota_update_methods", []))
or (row["build_profile"] == "full" and
re.fullmatch(r"-full(?:-usb-wifi|-logging)?(?:-ota)?", infix))
or (row["build_profile"] == "logging" and
re.fullmatch(r"-logging(?:-ota)?", infix)))))
qualified_stem = published_target + "-" + family
if any(path.stem.removesuffix("-merged") != qualified_stem for path in files):
raise ValueError("firmware filename differs from exact release source/version")
if (not valid_infix or any(qualified_stem + suffix not in row["files"]
for suffix in (".capabilities.json", ".memory.json"))):
raise ValueError("firmware filename differs from qualified profile")
if published_target in seen:
raise ValueError("duplicate release publication profile")
seen.add(published_target)
records.append(({**row, "publication_target": published_target}, group["tag"], files))
return plan, family, records
@@ -116,7 +138,7 @@ def generate(catalog, plan, family, records, profiles, repo):
by_hardware.setdefault((device["type"], hardware), set()).add(index)
result["device"][index]["firmware"] = []
for row, tag, files in records:
target = row["artifact_target"]
target = row.get("publication_target", row["artifact_target"])
parsed = profiles[target]
platform = PLATFORMS[row["platform"]]
indexes = by_identity.get(target) or by_identity.get(row["target"])
@@ -201,7 +223,7 @@ def main():
args = parser.parse_args()
catalog = json.loads(args.catalog.read_text())
plan, family, records = read_stage(args.stage)
targets = {row["artifact_target"] for row, _, _ in records}
targets = {row.get("publication_target", row["artifact_target"]) for row, _, _ in records}
targets.update(identity(file["name"]) for device in catalog["device"]
for firmware in device["firmware"] for version in firmware["version"].values()
for file in version["files"])
+15 -4
View File
@@ -290,18 +290,24 @@ def collect_local_release(directory, version, source):
if any(category(record) != group.name for record in group_records):
raise ValueError("local release firmware group disagrees with qualification")
records.extend(group_records)
indexed = {record["manifest"]["artifact_target"]: record for record in records}
# Standard and expanded Full images may retain the same OTA identity.
# Match each independently qualified package by its exact file inventory;
# the logical target alone cannot distinguish those publication profiles.
indexed = {frozenset(str(p.relative_to(directory)) for p in record["files"]): record
for record in records}
entries = manifest["firmware"]
if (len(indexed) != len(records) or len(entries) != len(records)
or len(records) != manifest["firmware_target_count"]
or len({entry["artifact_target"] for entry in entries}) != len(entries)):
or any(len(entry["files"]) != len(set(entry["files"])) for entry in entries)
or len({frozenset(entry["files"]) for entry in entries}) != len(entries)):
raise ValueError("local release target inventory disagrees with qualification")
for entry in entries:
record = indexed.get(entry["artifact_target"])
record = indexed.get(frozenset(entry["files"]))
if record is None:
raise ValueError("local release contains an unqualified target")
qualified = record["manifest"]
if (qualified["target"] != entry["target"]
or qualified["artifact_target"] != entry["artifact_target"]
or qualified.get("source_commit", source) != source
or qualified["platform"] != entry["platform"]
or qualified["build_profile"] != entry["profile"]
@@ -404,6 +410,8 @@ def main():
summaries = []
for record in group["records"]:
manifest = record["manifest"]
if manifest.get("source_commit", args.commit) != args.commit:
raise ValueError("qualified firmware belongs to another source")
file_links = []
for path in record["files"]:
shutil.copy2(path, destination / path.name)
@@ -412,7 +420,10 @@ def main():
label = "merged.bin (USB)" if path.name.endswith("-merged.bin") else path.suffix[1:]
file_links.append(f'<a href="{html.escape(url)}">{html.escape(label)}</a>')
methods = ", ".join(manifest.get("ota_update_methods", [])) or "USB"
summaries.append({**manifest, "sensor_profile": nrf52_sensor_profile(manifest),
# Older capability manifests inherit source provenance from the
# validated completed release inventory. Preserve their raw bytes.
summaries.append({**manifest, "source_commit": args.commit,
"sensor_profile": nrf52_sensor_profile(manifest),
"files": [path.name for path in record["files"]]})
rows.append(f"<tr><td>{html.escape(manifest['artifact_target'])}</td><td>{html.escape(release_profile_label(manifest))}</td><td>{html.escape(methods)}</td><td>{' | '.join(file_links)}</td></tr>")
if group['key'] == 'utility':
+26
View File
@@ -680,5 +680,31 @@ printf 'RESULT:%s BATCH:%s\n' "$status" "$BATCH_BUILD_MODE"
self.assertEqual(before, (original.read_bytes(), original.stat().st_ino, original.stat().st_mtime_ns))
class CompilerJobLimitTests(unittest.TestCase):
def test_serial_matrix_passes_validated_job_limit_and_restores_caller_state(self):
for requested, expected in (("4", "4"), ("2", "2"), ("0", "8"), ("invalid", "8")):
with self.subTest(requested=requested), tempfile.TemporaryDirectory(prefix="mesh-compiler-jobs-") as temporary:
script = r'''
source "$1/build_legacy.sh"
OUTPUT_DIR=$2
OPTION3_PIO_JOBS=$3
PIO_BUILD_JOBS_OVERRIDE=3
pio() { printf 'FORBIDDEN_PLATFORMIO\n' >&2; return 99; }
build_firmware() {
printf '%s:%s:%s\n' "$1" "$PIO_BUILD_JOBS_OVERRIDE" "$BATCH_BUILD_MODE" >> "$OUTPUT_DIR/jobs-observed.txt"
}
run_logged_build_targets first second
status=$?
printf 'RESULT:%s RESTORED_JOBS:%s RESTORED_BATCH:%s\n' "$status" "$PIO_BUILD_JOBS_OVERRIDE" "$BATCH_BUILD_MODE"
'''
result = subprocess.run(["bash", "-c", script, "jobs-test", str(ROOT), temporary, requested],
cwd=temporary, text=True, capture_output=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertNotIn("FORBIDDEN_PLATFORMIO", result.stdout + result.stderr)
self.assertIn("RESULT:0 RESTORED_JOBS:3 RESTORED_BATCH:0", result.stdout)
self.assertEqual((Path(temporary) / "jobs-observed.txt").read_text().splitlines(),
["first:" + expected + ":1", "second:" + expected + ":1"])
if __name__ == "__main__":
unittest.main()
+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""Exercise production archival and resume wiring without invoking PlatformIO."""
import fcntl
import hashlib
import importlib.util
import io
import json
import os
from pathlib import Path
import sys
import tempfile
import unittest
from unittest import mock
REPO = Path(os.environ.get("CAPACITY_ARCHIVE_REPO", str(Path(__file__).resolve().parents[1])))
sys.path.insert(0, str(REPO / "scripts"))
from firmware_build_recipe import package_occupied
MODULE = Path(os.environ.get("CAPACITY_ARCHIVE_MODULE", str(REPO / "scripts/build_local_release.py")))
SPEC = importlib.util.spec_from_file_location("capacity_archive_release", MODULE)
BUILD = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(BUILD)
VERSION = "v1.17.1.9-halo-keymind-cascade-dev"
SOURCE = "8d31b3932de614ac38d238f1a01010bc5f362fe8"
TARGET = "heltec_v4_repeater_lora_ota_no_external_sensors"
class CapacityArchiveTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix="mesh-capacity-archive-test-")
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.work = self.root / ".releases" / (".build-" + VERSION + "-" + SOURCE[:8])
(self.work / "build-logs").mkdir(parents=True)
def attempt(self, target=TARGET, *, infix="-ota"):
stem = f"{target}{infix}-{VERSION}-{SOURCE[:8]}"
manifest = self.work / (stem + ".capabilities.json")
value = {"schema_version": 2, "target": target, "artifact_target": target,
"platformio_env": "heltec_v4_repeater", "platform": "ESP32_PLATFORM",
"build_profile": "standard", "verified": True,
"ota_update_verified": True,
"ota_update_evidence": "firmware fits both OTA application slots; otadata present",
"verification": [{"capability": "ota.update.lora", "present": True,
"source": "packaged application", "evidence": "OTA: status"}],
"build_recipe": {"schema_version": 1, "sha256": "a" * 64}}
manifest.write_text(json.dumps(value, indent=2) + "\n", encoding="ascii")
log = self.work / "build-logs" / (target + "-standard.log")
log.write_text(
f' -DFIRMWARE_VERSION="{VERSION}-{SOURCE[:8]}"\n'
f"Verified 1 capability marker(s); manifest: {manifest}\n"
"ESP32 app image is 1334184 bytes, exceeding portable LoRa-OTA "
"slot 0x10000..0x150000 (1310720 bytes) by 23464 bytes\n"
f"DEFERRED: {target} (standard) exceeds the portable OTA slot; "
"the expanded FULL pass is required.\n", encoding="ascii")
return manifest, log
def archive(self, **kwargs):
return BUILD.archive_capacity_rejected_attempts(
self.work, VERSION, SOURCE, project_root=self.root, **kwargs)
def mutate(self, manifest, **updates):
value = json.loads(manifest.read_text()); value.update(updates)
manifest.write_text(json.dumps(value) + "\n", encoding="ascii")
def assert_preserved(self, manifest, log):
raw_manifest, raw_log = manifest.read_bytes(), log.read_bytes()
before = sorted(path.name for path in self.work.parent.iterdir())
result = self.archive()
self.assertEqual(result["archived"], [])
self.assertEqual(manifest.read_bytes(), raw_manifest)
self.assertEqual(log.read_bytes(), raw_log)
self.assertEqual(sorted(path.name for path in self.work.parent.iterdir()), before)
self.assertEqual(len(result["preserved"]), 1)
def test_real_log_shape_preserves_raw_bytes_and_leaves_log_in_place(self):
manifest, log = self.attempt()
raw_manifest, raw_log = manifest.read_bytes(), log.read_bytes()
self.assertTrue(package_occupied(manifest.with_name(
manifest.name.removesuffix(".capabilities.json"))))
result = self.archive(); archive = Path(result["archive"])
self.assertEqual(len(result["archived"]), 1)
self.assertFalse(manifest.exists())
self.assertFalse(package_occupied(manifest.with_name(
manifest.name.removesuffix(".capabilities.json"))))
self.assertEqual(log.read_bytes(), raw_log)
self.assertEqual((archive / "manifests" / manifest.name).read_bytes(), raw_manifest)
self.assertEqual((archive / "logs" / log.name).read_bytes(), raw_log)
self.assertEqual(archive.parent, self.work.parent)
self.assertNotIn(archive, self.work.rglob("*"))
index = json.loads((archive / "index.json").read_text(encoding="ascii"))
self.assertEqual(index["state"], "archived")
self.assertEqual(index["source_commit"], SOURCE)
record = index["attempts"][0]
self.assertEqual(record["manifest_sha256"], hashlib.sha256(raw_manifest).hexdigest())
self.assertEqual(record["original_log_sha256"], hashlib.sha256(raw_log).hexdigest())
self.assertEqual(record["image_bytes"] - record["portable_limit_bytes"], 23464)
self.assertEqual(self.archive()["archived"], [])
def test_plain_c6_filename_and_multiple_attempts_receive_unique_archive(self):
self.attempt("Xiao_C6_repeater_", infix="")
self.attempt()
first = self.archive()
self.assertEqual(len(first["archived"]), 2)
self.attempt()
second = self.archive()
self.assertNotEqual(first["archive"], second["archive"])
self.assertTrue(Path(first["archive"]).is_dir())
def test_log_only_precollection_size_deferral_needs_no_archive(self):
manifest, log = self.attempt("Station_G2_repeater_lora_ota_no_external_sensors")
manifest.unlink(); raw_log = log.read_bytes()
result = self.archive()
self.assertEqual(result["archived"], [])
self.assertIsNone(result["archive"])
self.assertEqual(log.read_bytes(), raw_log)
def test_any_firmware_memory_license_unknown_sidecar_or_directory_is_preserved(self):
for suffix in (".bin", "-merged.bin", ".uf2", ".zip", ".hex", ".memory.json",
".font-license.txt", ".unexpected", ".directory", ".bin-link"):
with self.subTest(suffix=suffix):
manifest, log = self.attempt()
sibling = self.work / (manifest.name.removesuffix(".capabilities.json") + suffix)
if suffix == ".directory": sibling.mkdir()
elif suffix == ".bin-link": sibling.symlink_to(self.root / "missing.bin")
else: sibling.write_bytes(b"original evidence or qualified firmware")
self.assert_preserved(manifest, log)
if sibling.is_dir(): sibling.rmdir()
else: sibling.unlink()
def test_unknown_failures_and_incomplete_or_nonstandard_contracts_are_preserved(self):
faults = [{"verified": False}, {"ota_update_verified": False},
{"platform": "NRF52_PLATFORM"}, {"build_profile": "full"},
{"artifact_target": "different"}, {"schema_version": 1},
{"schema_version": 2.0},
{"verification": []}, {"verification": [{"present": False}]},
{"ota_update_evidence": "firmware does not fit both OTA application slots"},
{"build_recipe": None}, {"build_recipe": {"schema_version": 1, "sha256": "x" * 64}},
{"build_recipe": {"schema_version": True, "sha256": "a" * 64}},
{"build_recipe": {"schema_version": 1, "sha256": "a" * 64, "unknown": 1}}]
for fault in faults:
with self.subTest(fault=fault):
manifest, log = self.attempt(); self.mutate(manifest, **fault)
self.assert_preserved(manifest, log)
manifest, log = self.attempt(); manifest.write_bytes(b"{malformed")
self.assert_preserved(manifest, log)
def test_nested_firmware_is_not_hidden_by_flat_manifest_scan(self):
manifest, log = self.attempt()
nested = self.work / "nested"; nested.mkdir()
(nested / (manifest.name.removesuffix(".capabilities.json") + ".bin")).write_bytes(b"firmware")
self.assert_preserved(manifest, log)
def test_numeric_source_identity_and_final_failure_consistency_are_required(self):
replacements = [("23464 bytes", "23465 bytes"), ("1310720 bytes", "1310721 bytes"),
("1334184 bytes", "1310719 bytes"),
("portable LoRa-OTA slot", "ota_0"),
(SOURCE[:8], "deadbeef"), ("(standard)", "(full)"),
("Verified 1 capability", "Failed 1 capability"),
("; the expanded FULL pass is required.", "; unknown failure.")]
for old, new in replacements:
with self.subTest(old=old):
manifest, log = self.attempt()
log.write_text(log.read_text().replace(old, new), encoding="ascii")
self.assert_preserved(manifest, log)
manifest, log = self.attempt()
with log.open("a") as output: output.write("FAILED: unrelated compiler error\n")
self.assert_preserved(manifest, log)
def test_wrong_source_version_or_target_filename_is_not_consumed(self):
for token, replacement in ((SOURCE[:8], "deadbeef"), (VERSION, VERSION.replace("1.9", "1.8")),
(TARGET, "different_target")):
with self.subTest(token=token):
manifest, log = self.attempt()
renamed = manifest.with_name(manifest.name.replace(token, replacement))
manifest.rename(renamed)
self.assert_preserved(renamed, log); renamed.unlink()
def test_symlink_manifest_log_log_directory_or_work_is_never_consumed(self):
manifest, log = self.attempt()
original = self.root / "original.capabilities.json"
manifest.rename(original); manifest.symlink_to(original)
self.assert_preserved(manifest, log)
manifest.unlink(); original.rename(manifest)
real_log = self.root / "original.log"; log.rename(real_log); log.symlink_to(real_log)
self.assert_preserved(manifest, log)
log.unlink(); real_log.rename(log)
real_logs = self.root / "original-logs"
log.parent.rename(real_logs); log.parent.symlink_to(real_logs, target_is_directory=True)
self.assert_preserved(manifest, log)
alias = self.root / "work-alias"; alias.symlink_to(self.work, target_is_directory=True)
with self.assertRaisesRegex(ValueError, "unsafe work/archive"):
BUILD.archive_capacity_rejected_attempts(alias, VERSION, SOURCE, project_root=self.root)
def test_archive_overlap_and_held_build_lock_leave_every_package_byte_untouched(self):
manifest, log = self.attempt(); raw_manifest, raw_log = manifest.read_bytes(), log.read_bytes()
for parent in (self.work, self.work / "nested"):
with self.subTest(parent=parent), self.assertRaisesRegex(ValueError, "unsafe work/archive"):
self.archive(archive_parent=parent)
lock_path = self.root / ".pio" / "build-sh.lock"; lock_path.parent.mkdir()
lock_path.write_bytes(b"original active build lock owner\n")
with lock_path.open("a+") as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
with self.assertRaisesRegex(RuntimeError, "active build owns"):
self.archive()
self.assertEqual(lock_path.read_bytes(), b"original active build lock owner\n")
self.assertEqual(manifest.read_bytes(), raw_manifest)
self.assertEqual(log.read_bytes(), raw_log)
self.assertEqual(list(self.work.parent.glob(".capacity-rejected-*")), [])
def test_complete_scan_recheck_blocks_changes_before_any_move(self):
first, _ = self.attempt(); second, _ = self.attempt("second_repeater")
original = BUILD.capacity_rejected_attempt; calls = 0
def racing(*args):
nonlocal calls
calls += 1
record = original(*args)
if calls == 2: self.mutate(first, verified=False)
return record
with mock.patch.object(BUILD, "capacity_rejected_attempt", side_effect=racing):
with self.assertRaises(ValueError): self.archive()
self.assertTrue(first.exists()); self.assertTrue(second.exists())
self.assertEqual(list(self.work.parent.glob(".capacity-rejected-*")), [])
def test_late_failure_keeps_moved_raw_evidence_and_marks_partial_archive(self):
first, _ = self.attempt(); second, _ = self.attempt("second_repeater")
raw_first, raw_second = first.read_bytes(), second.read_bytes()
original = BUILD.capacity_rejected_attempt; calls = 0
def racing(*args):
nonlocal calls
calls += 1
if calls == 6: raise ValueError("injected late race")
return original(*args)
with mock.patch.object(BUILD, "capacity_rejected_attempt", side_effect=racing):
with self.assertRaisesRegex(ValueError, "injected late race"): self.archive()
archive, = self.work.parent.glob(".capacity-rejected-*")
self.assertFalse(first.exists()); self.assertTrue(second.exists())
self.assertEqual((archive / "manifests" / first.name).read_bytes(), raw_first)
self.assertEqual(second.read_bytes(), raw_second)
index = json.loads((archive / "index.json").read_text())
self.assertEqual(index["state"], "interrupted")
self.assertEqual(index["moved_manifests"], [first.name])
def test_resume_main_archives_before_matrix_and_nonresume_dryrun_do_not_archive(self):
(self.work / "build-logs").rmdir()
for arguments, expected in ((["--resume"], True), ([], False), (["--resume", "--dry-run"], False)):
with self.subTest(arguments=arguments):
order = []
def archive(*args):
order.append("archive")
return {"archived": [], "preserved": [], "archive": None}
def matrix(*args):
order.append("matrix")
raise RuntimeError("stop before any external build")
with mock.patch.object(BUILD, "ROOT", self.root), \
mock.patch.object(BUILD, "git", side_effect=[SOURCE, ""]), \
mock.patch.object(BUILD, "archive_capacity_rejected_attempts", side_effect=archive), \
mock.patch.object(BUILD, "run_logged", side_effect=matrix), \
mock.patch.object(BUILD, "resolve_pio_build_dir", return_value=self.root / ".pio/build"), \
mock.patch.object(sys, "argv", ["build_local_release.py"] + arguments), \
mock.patch("sys.stdout", new=io.StringIO()), \
mock.patch("sys.stderr", new=io.StringIO()):
if "--dry-run" in arguments: BUILD.main()
else:
with self.assertRaisesRegex(RuntimeError, "stop before"): BUILD.main()
self.assertEqual(order, ["archive", "matrix"] if expected else
([] if "--dry-run" in arguments else ["matrix"]))
def test_explicit_release_ci_step_runs_capacity_archive_regressions(self):
workflow = Path(os.environ.get("CAPACITY_ARCHIVE_WORKFLOW", str(
REPO / ".github/workflows/run-unit-tests.yml"))).read_text(encoding="utf-8")
command = "python3 -B test/test_local_release_capacity_archive.py"
self.assertEqual(workflow.count(command), 1)
# This workflow lists host suites explicitly, rather than discovering
# test files. Keep the new resume boundary in the existing release step.
self.assertIn("python3 -B test/test_release_publication.py\n"
" python3 -B test/test_local_release_full_selection.py\n"
" " + command, workflow)
if __name__ == "__main__":
unittest.main()
+121 -4
View File
@@ -21,17 +21,19 @@ FAMILY = VERSION + "-" + SOURCE[:8]
class ReleasePublicationTest(unittest.TestCase):
def record(self, directory, target, platform="ESP32_PLATFORM", sensor=""):
def record(self, directory, target, platform="ESP32_PLATFORM", sensor="", *,
publication_target=None, build_profile="auto"):
directory.mkdir(parents=True, exist_ok=True)
stem = target + "-" + FAMILY
stem = (publication_target or target) + "-" + FAMILY
names = [stem + suffix for suffix in (
[".uf2", ".zip"] if platform == "NRF52_PLATFORM" else [".bin", "-merged.bin"])]
names += [stem + ".capabilities.json", stem + ".memory.json"]
for name in names:
(directory / name).write_bytes((name + "\n").encode("ascii"))
row = {"artifact_target": target, "target": target.removesuffix("-" + sensor + "-ota") if sensor else target,
"platform": platform, "build_profile": "auto", "verified": True,
"sensor_profile": sensor, "files": names, "reductions": []}
"platform": platform, "build_profile": build_profile, "verified": True,
"sensor_profile": sensor, "files": names, "reductions": [],
"source_commit": SOURCE, "ota_update_methods": ["lora"]}
return row, [directory / name for name in names]
def stage(self, directory):
@@ -140,10 +142,114 @@ class ReleasePublicationTest(unittest.TestCase):
catalogs.generate(template, plan, family, records,
catalogs.target_profiles(targets), "mikecarper/MeshCore")
def test_catalog_accepts_standard_and_full_with_the_same_logical_identity(self):
with tempfile.TemporaryDirectory() as temp:
directory = Path(temp)
plan = self.stage(directory)
target = "Station_G2_repeater"
published = target + "-full-usb-wifi-ota"
row, _ = self.record(directory / "full-profiles", target,
publication_target=published, build_profile="full")
folder = directory / "full-profiles"
(folder / "TARGET-MANIFEST.json").write_text(json.dumps([row]))
(folder / "SHA256SUMS.txt").write_text("".join(
hashlib.sha256(p.read_bytes()).hexdigest() + " " + p.name + "\n"
for p in sorted(folder.iterdir())))
plan["groups"].append({"key": "full-profiles", "tag": "full-profiles-" + FAMILY,
"target_count": 1})
(directory / "release-plan.json").write_text(json.dumps(plan))
plan, family, records = catalogs.read_stage(directory)
self.assertEqual([row["artifact_target"] for row, _, _ in records].count(target), 2)
targets = {row["publication_target"] for row, _, _ in records} | {
"Station_G2_companion_radio_full", target, "RAK_4631_repeater"}
result = catalogs.generate(self.template(), plan, family, records,
catalogs.target_profiles(targets), "mikecarper/MeshCore")
entries = result["device"][0]["firmware"]
self.assertEqual(len(entries), 3)
full = next(entry for entry in entries if entry["subTitle"].startswith(published))
self.assertIn("Full profile", full["subTitle"])
self.assertTrue(all("/full-profiles-" + FAMILY + "/" in file["url"]
for file in full["version"][FAMILY]["files"]))
# Matching filenames do not authorize a profile for another board
# or a fabricated Full infix on a standard qualified build.
for change in ({"artifact_target": "Other_repeater"}, {"build_profile": "auto"},
{"source_commit": "b" * 40}):
(folder / "TARGET-MANIFEST.json").write_text(json.dumps([{**row, **change}]))
(folder / "SHA256SUMS.txt").write_text("".join(
hashlib.sha256(p.read_bytes()).hexdigest() + " " + p.name + "\n"
for p in sorted(folder.iterdir()) if p.name != "SHA256SUMS.txt"))
with self.subTest(change=change), self.assertRaises(ValueError):
catalogs.read_stage(directory)
# A second source suffix cannot hide inside an otherwise familiar
# family filename while keeping the original cap/memory stems.
wrong = {**row, "files": list(row["files"])}
for index, name in enumerate(wrong["files"]):
if name.endswith(".bin"):
extra = name.replace(FAMILY, FAMILY + "-bbbbbbbb")
(folder / name).rename(folder / extra)
wrong["files"][index] = extra
(folder / "TARGET-MANIFEST.json").write_text(json.dumps([wrong]))
(folder / "SHA256SUMS.txt").write_text("".join(
hashlib.sha256(p.read_bytes()).hexdigest() + " " + p.name + "\n"
for p in sorted(folder.iterdir()) if p.name != "SHA256SUMS.txt"))
with self.assertRaisesRegex(ValueError, "exact release source/version"):
catalogs.read_stage(directory)
def test_local_release_distinguishes_standard_and_full_by_exact_qualified_files(self):
with tempfile.TemporaryDirectory() as temp:
directory = Path(temp)
target = "Station_G2_companion_radio_usb"
folder = directory / "firmware/companion"
standard, standard_files = self.record(folder, target)
full, full_files = self.record(folder, target,
publication_target=target + "-full-usb-wifi-ota", build_profile="full")
records = [{"manifest": row, "files": files} for row, files in
((standard, standard_files), (full, full_files))]
manifest = {"format": "meshcore-local-release-v1", "source_commit": SOURCE,
"firmware_version": VERSION, "profile": "cascade", "publication": "local-only",
"radio": {"frequency_mhz": 910.525, "bandwidth_khz": 62.5,
"spreading_factor": 7, "coding_rate": 5},
"firmware_target_count": 2, "migration_board_role_count": 1,
"firmware": [{"target": row["target"], "artifact_target": row["artifact_target"],
"profile": row["build_profile"], "platform": row["platform"],
"files": [str(p.relative_to(directory)) for p in files]}
for row, files in ((standard, standard_files), (full, full_files))]}
migrations = directory / "esp32-partition-migration"
migrations.mkdir()
archive = migrations / ("example-" + FAMILY + "-migration.zip")
archive.write_bytes(b"qualified migration fixture")
bundle = directory / ("esp32-partition-migration-" + FAMILY + "-release.zip")
with zipfile.ZipFile(bundle, "w") as z:
z.writestr("release-manifest.json", "{}")
z.writestr(archive.name, archive.read_bytes())
def write_inventory():
(directory / "manifest.json").write_text(json.dumps(manifest))
(directory / "SHA256SUMS.txt").write_text("".join(
hashlib.sha256(p.read_bytes()).hexdigest() + " " + str(p.relative_to(directory)) + "\n"
for p in sorted(directory.rglob("*")) if p.is_file() and p.name != "SHA256SUMS.txt"))
with patch.object(package, "collect_artifacts", return_value=records), \
patch("package_esp32_partition_migration.BOARDS", {"example": {}}), \
patch("build_esp32_partition_migration.verify_archive"):
write_inventory()
self.assertEqual(len(package.collect_local_release(directory, VERSION, SOURCE)[0]), 2)
for field, value in (("artifact_target", "Other_companion_radio_usb"),
("profile", "auto")):
original = manifest["firmware"][1][field]
manifest["firmware"][1][field] = value
write_inventory()
with self.subTest(field=field), self.assertRaisesRegex(ValueError, "manifest disagrees"):
package.collect_local_release(directory, VERSION, SOURCE)
manifest["firmware"][1][field] = original
manifest["firmware"][1]["files"] = manifest["firmware"][0]["files"]
write_inventory()
with self.assertRaisesRegex(ValueError, "inventory disagrees"):
package.collect_local_release(directory, VERSION, SOURCE)
def test_stable_publication_notes_and_assets_are_ascii(self):
with tempfile.TemporaryDirectory() as temp:
base = Path(temp)
row, files = self.record(base / "input", "Station_G2_companion_radio_usb")
row.pop("source_commit") # Actual older cap manifests inherit validated release provenance.
output = base / "stage"
argv = ["package", "--input", str(base / "input"), "--output", str(output),
"--local-release", "--stable", "--version", "1.17.1.9", "--commit", SOURCE]
@@ -153,12 +259,23 @@ class ReleasePublicationTest(unittest.TestCase):
package.main()
plan = json.loads((output / "release-plan.json").read_text())
self.assertFalse(plan["groups"][0]["prerelease"])
staged_rows = json.loads((output / "companion/TARGET-MANIFEST.json").read_text())
self.assertEqual(staged_rows[0]["source_commit"], SOURCE)
self.assertNotIn("source_commit", row, "raw capability record was relabeled")
notes = (output / "companion-notes.md").read_text(encoding="ascii")
self.assertIn("Stable release", notes)
self.assertNotIn("Development prerelease", notes)
for path in output.rglob("*"):
if path.is_file():
path.read_text(encoding="ascii")
bad = {**row, "source_commit": "b" * 40}
bad_argv = list(argv)
bad_argv[bad_argv.index(str(output))] = str(base / "wrong-source-stage")
with patch.object(sys, "argv", bad_argv), patch.object(package, "collect_local_release",
return_value=([{"manifest": bad, "files": files}], [],
{"frequency": "910.525", "bandwidth": "62.5", "sf": "7", "cr": "5"})), \
self.assertRaisesRegex(ValueError, "another source"):
package.main()
def test_local_release_preserves_selected_profiles_and_verifies_complete_inventory(self):
with tempfile.TemporaryDirectory() as temp: