Files
pyxis/tests/build_scripts/test_merge_pyxis_release.py
torlando-agent[bot] 01fc9adc79 release: ship merged full-flash binary on releases
Add tools/merge_pyxis_release.py, which assembles the four validated
release assets (bootloader, partition table, OTA selector, application)
into a single 8 MiB image at their fixed flash offsets on an erased
canvas, with per-image read-back and gap checks.

Wire it into release-firmware.yml so every tag build publishes
pyxis-<tag>-merged.bin as a release asset, and document the
provisioning semantics in the README: the merged image is a
first-install image (it erases NVS and LittleFS); data-preserving
updates keep using firmware.bin or the Columba .pyxis package.
2026-08-18 16:28:02 +00:00

169 lines
5.7 KiB
Python

"""Contract tests for the merged full-flash Pyxis release image."""
import hashlib
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SCRIPT = ROOT / "tools/merge_pyxis_release.py"
WORKFLOW = ROOT / ".github/workflows/release-firmware.yml"
README = ROOT / "README.md"
FLASH_SIZE = 8 * 1024 * 1024
OFFSETS = {
"bootloader.bin": 0x0,
"partitions.bin": 0x8000,
"boot_app0.bin": 0xE000,
"firmware.bin": 0x10000,
}
def valid_bootloader() -> bytes:
return b"\xE9\x03" + b"\xA5" * 512
def valid_partitions() -> bytes:
return b"\xAA\x50" + b"\x00" * 511
def valid_boot_app0() -> bytes:
image = bytearray([0xFF] * 0x2000)
image[:4] = b"\x01\x00\x00\x00"
return bytes(image)
def valid_firmware() -> bytes:
image = bytearray(0x1000)
image[0] = 0xE9
image[12:14] = (9).to_bytes(2, "little")
return bytes(image)
def images() -> dict[str, bytes]:
return {
"bootloader.bin": valid_bootloader(),
"partitions.bin": valid_partitions(),
"boot_app0.bin": valid_boot_app0(),
"firmware.bin": valid_firmware(),
}
def write_release_directory(directory: Path, version: str = "v9.9.9", images_: dict | None = None) -> Path:
directory.mkdir(parents=True, exist_ok=True)
images_ = images_ or images()
for name, data in images_.items():
(directory / name).write_bytes(data)
metadata = {
"schema": 1,
"version": version,
"source_commit": "a" * 40,
"environment": "tdeck-release",
"chip_family": "ESP32-S3",
"flash_size": FLASH_SIZE,
"persistence_safe": True,
"images": {
name: {
"file": name,
"offset": OFFSETS[name],
"size": len(images_[name]),
"sha256": hashlib.sha256(images_[name]).hexdigest(),
}
for name in OFFSETS
},
}
(directory / "pyxis-release.json").write_text(json.dumps(metadata))
return directory
def run_builder(directory: Path, output: Path, version: str = "v9.9.9") -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, str(SCRIPT), "--directory", str(directory), "--version", version, "--output", str(output)],
capture_output=True,
text=True,
)
def test_merged_image_places_assets_at_fixed_offsets_and_pads_erased(tmp_path):
directory = write_release_directory(tmp_path / "release")
output = tmp_path / "merged.bin"
result = run_builder(directory, output)
assert result.returncode == 0, result.stderr
merged = output.read_bytes()
assert len(merged) == FLASH_SIZE
images_ = images()
for name, offset in OFFSETS.items():
assert merged[offset : offset + len(images_[name])] == images_[name]
# Every byte outside the four images must be erased flash.
occupied = sorted(
(offset, offset + len(images_[name])) for name, offset in OFFSETS.items()
)
for index in range(len(merged)):
in_image = any(start <= index < end for start, end in occupied)
if not in_image:
assert merged[index] == 0xFF
def test_merged_image_is_byte_for_byte_deterministic(tmp_path):
directory = write_release_directory(tmp_path / "release")
first = tmp_path / "first.bin"
second = tmp_path / "second.bin"
assert run_builder(directory, first).returncode == 0
assert run_builder(directory, second).returncode == 0
assert first.read_bytes() == second.read_bytes()
def test_merged_image_reports_sha256_matching_file(tmp_path):
directory = write_release_directory(tmp_path / "release")
output = tmp_path / "merged.bin"
result = run_builder(directory, output)
assert result.returncode == 0, result.stderr
assert f"merged_sha256={hashlib.sha256(output.read_bytes()).hexdigest()}" in result.stdout
def test_builder_rejects_version_mismatch(tmp_path):
directory = write_release_directory(tmp_path / "release")
result = run_builder(directory, tmp_path / "merged.bin", version="v9.9.10")
assert result.returncode != 0
assert not (tmp_path / "merged.bin").exists()
def test_builder_rejects_tampered_image(tmp_path):
images_ = images()
images_["firmware.bin"] = b"\xE9" + b"\x00" * 31 # hash in metadata won't match
directory = write_release_directory(tmp_path / "release", images_=images_)
(tmp_path / "release" / "firmware.bin").write_bytes(b"\xE9" + b"\x00" * 63)
result = run_builder(directory, tmp_path / "merged.bin")
assert result.returncode != 0
assert not (tmp_path / "merged.bin").exists()
def test_workflow_builds_and_publishes_merged_binary():
workflow = WORKFLOW.read_text()
assert "Build merged full-flash binary" in workflow
assert "python tools/merge_pyxis_release.py" in workflow
assert "--directory docs/flasher/firmware" in workflow
assert 'pyxis-${VERSION}-merged.bin' in workflow
assert "docs/flasher/firmware/pyxis-*-merged.bin" in workflow
# The merged build must be gated to tag builds, like the other release artifacts.
merged_step = workflow[workflow.index("Build merged full-flash binary") :]
assert "if: startsWith(github.ref, 'refs/tags/v')" in merged_step[: merged_step.index("name: Download existing release assets")]
def test_readme_documents_merged_binary_with_honest_semantics():
readme = README.read_text()
assert "merged binary" in readme.lower()
assert "write_flash 0x0" in readme
assert re.search(r"pyxis-<tag>-merged\.bin", readme)
# The README must say the merged image wipes persistent data and that
# data-preserving updates use firmware.bin.
assert "erase_flash" in readme
assert "NVS" in readme
assert "firmware.bin" in readme