474 lines
31 KiB
Python
474 lines
31 KiB
Python
#!/usr/bin/env python3
|
|
"""Release integration checks for exact asset routing and stable staging."""
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
import zipfile
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT / "scripts"))
|
|
import generate_mesh_america_release as catalogs
|
|
import package_cascade_release as package
|
|
|
|
|
|
SOURCE = "a" * 40
|
|
VERSION = "v1.17.1.9-halo-keymind-cascade-dev"
|
|
FAMILY = VERSION + "-" + SOURCE[:8]
|
|
|
|
|
|
class ReleasePublicationTest(unittest.TestCase):
|
|
def record(self, directory, target, platform="ESP32_PLATFORM", sensor="", *,
|
|
publication_target=None, build_profile="auto"):
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
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": 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):
|
|
definitions = [("companion", "Station_G2_companion_radio_full", "ESP32_PLATFORM", ""),
|
|
("repeater-room", "Station_G2_repeater", "ESP32_PLATFORM", ""),
|
|
("lora-ota", "RAK_4631_repeater-full-ota", "NRF52_PLATFORM", "full"),
|
|
("lora-ota", "RAK_4631_repeater-reduced-ota", "NRF52_PLATFORM", "reduced")]
|
|
groups = {}
|
|
for group, target, platform, sensor in definitions:
|
|
row, files = self.record(directory / group, target, platform, sensor)
|
|
groups.setdefault(group, []).append(row)
|
|
plan = {"source": SOURCE, "version": "1.17.1.9", "groups": []}
|
|
for group, rows in groups.items():
|
|
path = directory / group
|
|
(path / "TARGET-MANIFEST.json").write_text(json.dumps(rows))
|
|
(path / "SHA256SUMS.txt").write_text("".join(
|
|
hashlib.sha256(p.read_bytes()).hexdigest() + " " + p.name + "\n"
|
|
for p in sorted(path.iterdir())))
|
|
plan["groups"].append({"key": group, "tag": FAMILY if group == "companion" else group + "-" + FAMILY,
|
|
"target_count": len(rows)})
|
|
(directory / "release-plan.json").write_text(json.dumps(plan))
|
|
return plan
|
|
|
|
def template(self):
|
|
devices = []
|
|
for hardware, platform, targets in (
|
|
("Station G2", "esp32", ["Station_G2_companion_radio_full", "Station_G2_repeater"]),
|
|
("RAK4631", "nrf52", ["RAK_4631_repeater"])):
|
|
devices.append({"name": hardware, "type": platform, "maker": "test", "firmware": [
|
|
{"role": "repeater", "version": {"old": {"notes": "stale old URLs",
|
|
"files": [{"name": target + "-v1.17.1.8-dev-12345678.bin"}]}}}
|
|
for target in targets]})
|
|
return {"maker": {"test": {"name": "Test"}}, "device": devices}
|
|
|
|
def test_catalog_routes_each_asset_to_its_manifest_group_and_keeps_sensor_pairs(self):
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
directory = Path(temp)
|
|
self.stage(directory)
|
|
plan, family, records = catalogs.read_stage(directory)
|
|
template = self.template()
|
|
targets = {row["artifact_target"] for row, _, _ in records} | {
|
|
"Station_G2_companion_radio_full", "Station_G2_repeater", "RAK_4631_repeater"}
|
|
result = catalogs.generate(template, plan, family, records,
|
|
catalogs.target_profiles(targets), "mikecarper/MeshCore")
|
|
self.assertEqual([len(d["firmware"]) for d in result["device"]], [2, 2])
|
|
for device in result["device"]:
|
|
for firmware in device["firmware"]:
|
|
version = firmware["version"][FAMILY]
|
|
self.assertNotIn("stale", version["notes"])
|
|
for file in version["files"]:
|
|
target = catalogs.identity(file["name"])
|
|
expected = next(tag for row, tag, _ in records if row["artifact_target"] == target)
|
|
self.assertIn("/download/" + expected + "/", file["url"])
|
|
self.assertEqual(template, self.template(), "input catalog was mutated")
|
|
|
|
def test_catalog_rejects_corrupt_assets_and_cross_family_tags(self):
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
directory = Path(temp)
|
|
plan = self.stage(directory)
|
|
binary = next((directory / "companion").glob("*.bin"))
|
|
original = binary.read_bytes()
|
|
binary.write_bytes(b"corrupt")
|
|
with self.assertRaisesRegex(ValueError, "checksum"):
|
|
catalogs.read_stage(directory)
|
|
binary.write_bytes(original)
|
|
plan["groups"][1]["tag"] = FAMILY
|
|
(directory / "release-plan.json").write_text(json.dumps(plan))
|
|
with self.assertRaisesRegex(ValueError, "different family"):
|
|
catalogs.read_stage(directory)
|
|
|
|
def test_catalog_distinguishes_qualified_bootloader_firmware_from_ordinary_images(self):
|
|
from nrf52_embedded_bootloader import (BOOT_SIZE, MANIFEST_SHA256, VERSION as BL_VERSION,
|
|
load_manifest, select_profile)
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
directory = Path(temp)
|
|
plan = self.stage(directory)
|
|
target = "RAK_4631_repeater"
|
|
artifact = target + "-with-bootloader-" + BL_VERSION + "-full-ota"
|
|
row, _ = self.record(directory / "lora-ota", artifact, "NRF52_PLATFORM", "full")
|
|
row["target"] = target
|
|
profile, _ = select_profile(load_manifest(), target)
|
|
row["capabilities"] = ["bootloader.embedded"]
|
|
row["verification"] = [{"capability": "bootloader.embedded", "present": True,
|
|
"source": "packaged application"}]
|
|
row["embedded_bootloader"] = {
|
|
"release": BL_VERSION, "profile": profile["id"],
|
|
"candidate_sha256": "a" * 64,
|
|
"asset_sha256": profile["files"]["zip"]["sha256"],
|
|
"manifest_sha256": MANIFEST_SHA256, "legacy_sha256": None,
|
|
"app_offset": 4, "image_bytes": BOOT_SIZE,
|
|
"activation": "ota bootloader update", "automatic_delay_seconds": 70}
|
|
folder = directory / "lora-ota"
|
|
rows = json.loads((folder / "TARGET-MANIFEST.json").read_text()) + [row]
|
|
def inventory():
|
|
(folder / "TARGET-MANIFEST.json").write_text(json.dumps(rows))
|
|
(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"))
|
|
inventory()
|
|
next(group for group in plan["groups"] if group["key"] == "lora-ota")["target_count"] = 3
|
|
(directory / "release-plan.json").write_text(json.dumps(plan))
|
|
plan, family, records = catalogs.read_stage(directory)
|
|
targets = {r["publication_target"] for r, _, _ in records} | {
|
|
"Station_G2_companion_radio_full", "Station_G2_repeater", target}
|
|
result = catalogs.generate(self.template(), plan, family, records,
|
|
catalogs.target_profiles(targets), "mikecarper/MeshCore")
|
|
entries = result["device"][1]["firmware"]
|
|
self.assertEqual(len(entries), 3)
|
|
embedded = next(item for item in entries if artifact in item["subTitle"])
|
|
self.assertIn("OTAFIX " + BL_VERSION + " bootloader", embedded["subTitle"])
|
|
self.assertIn("70 seconds", embedded["version"][FAMILY]["notes"])
|
|
for ordinary in (item for item in entries if item is not embedded):
|
|
self.assertIn("preserve the installed bootloader", ordinary["version"][FAMILY]["notes"])
|
|
# A publication name cannot opt into automatic bootloader changes.
|
|
row.pop("embedded_bootloader")
|
|
inventory()
|
|
with self.assertRaisesRegex(ValueError, "embedded bootloader lacks qualified metadata"):
|
|
catalogs.read_stage(directory)
|
|
|
|
def test_catalog_does_not_guess_an_unknown_hardware_card(self):
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
directory = Path(temp)
|
|
row, files = self.record(directory, "Unknown_repeater")
|
|
targets = {"Unknown_repeater", "Station_G2_companion_radio_full",
|
|
"Station_G2_repeater", "RAK_4631_repeater"}
|
|
with self.assertRaisesRegex(ValueError, "exact hardware card"):
|
|
catalogs.generate(self.template(), {"version": "1.17.1.9", "source": SOURCE, "groups": []},
|
|
FAMILY, [(row, FAMILY, files)], catalogs.target_profiles(targets), "mikecarper/MeshCore")
|
|
|
|
def test_catalog_retains_download_only_cards_for_new_sensor_profiles(self):
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
directory = Path(temp)
|
|
self.stage(directory)
|
|
plan, family, records = catalogs.read_stage(directory)
|
|
template = self.template()
|
|
template["device"][1]["type"] = "noflash"
|
|
targets = {row["artifact_target"] for row, _, _ in records} | {
|
|
"Station_G2_companion_radio_full", "Station_G2_repeater", "RAK_4631_repeater"}
|
|
result = catalogs.generate(template, plan, family, records,
|
|
catalogs.target_profiles(targets), "mikecarper/MeshCore")
|
|
device = result["device"][1]
|
|
self.assertEqual(device["type"], "noflash")
|
|
self.assertEqual(len(device["firmware"]), 2)
|
|
for firmware in device["firmware"]:
|
|
version = firmware["version"][FAMILY]
|
|
self.assertIn("Downloads only", version["notes"])
|
|
self.assertTrue(all(file["type"] == "download" for file in version["files"]))
|
|
# Even a merged ESP32 image must not enable a download-only card.
|
|
for name in ("example-merged.bin", "example.bin", "example.zip", "example.uf2"):
|
|
self.assertEqual(catalogs.file_entry(Path(name), "noflash", FAMILY,
|
|
"mikecarper/MeshCore")["type"], "download")
|
|
# Real flasher/chip mismatches still fail instead of being guessed.
|
|
template["device"][1]["type"] = "esp32"
|
|
with self.assertRaisesRegex(ValueError, "exact hardware card|platform differs"):
|
|
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]
|
|
with patch.object(sys, "argv", argv), patch.object(package, "collect_local_release",
|
|
return_value=([{"manifest": row, "files": files}], [],
|
|
{"frequency": "910.525", "bandwidth": "62.5", "sf": "7", "cr": "5"})):
|
|
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_page_descriptions_follow_actual_platforms_roles_and_tool_types(self):
|
|
def record(target, platform="ESP32_PLATFORM", methods=("lora",)):
|
|
return {"manifest": {"target": target, "platform": platform,
|
|
"ota_update_methods": list(methods)}}
|
|
|
|
nrf = [record("sample_repeater_lora_ota", "NRF52_PLATFORM"),
|
|
record("sample_room_server_lora_ota", "NRF52_PLATFORM"),
|
|
record("sample_sensor_lora_ota", "NRF52_PLATFORM")]
|
|
label, purpose = package.release_group_description("lora-ota", nrf)
|
|
self.assertEqual(label, "nRF52 Repeater, Room Server and Sensor firmware")
|
|
self.assertIn("LoRa OTA reception", purpose)
|
|
# Historical matrices also had ESP32 and mixed-platform LoRa pages.
|
|
esp = [record("legacy_repeater_lora_ota")]
|
|
self.assertEqual(package.release_group_description("lora-ota", esp)[0], "ESP32 Repeater firmware")
|
|
self.assertEqual(package.release_group_description("lora-ota", esp + nrf)[0], "LoRa OTA firmware")
|
|
self.assertNotIn("LoRa", package.release_group_description("lora-ota", [
|
|
record("legacy_repeater_lora_ota", methods=("wifi",))])[1])
|
|
full = [record("sample_repeater"), record("sample_room_server"), record("sample_sensor")]
|
|
self.assertEqual(package.release_group_description("full-profiles", full)[0],
|
|
"ESP32 Repeater, Room Server and Sensor firmware")
|
|
for files, title in (([Path("board-migration.zip")], "Migration tools"),
|
|
([Path("board_bootloader.hex")], "Bootloader tools"),
|
|
([Path("board-migration.zip"), Path("board_bootloader.hex")],
|
|
"Migration and bootloader tools")):
|
|
with self.subTest(title=title):
|
|
self.assertEqual(package.release_group_description("utility", [], files)[0], title)
|
|
utility = [record("sample_sensor"), record("sample_terminal_chat")]
|
|
self.assertEqual(package.release_group_description("utility", utility)[0], "Sensor and Terminal firmware")
|
|
self.assertEqual(package.release_group_description("utility", utility, [Path("board-migration.zip")])[0],
|
|
"Sensor and Terminal firmware and upgrade tools")
|
|
|
|
def test_staged_notes_lead_with_picker_and_link_human_role_choices(self):
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
base = Path(temp)
|
|
companion, companion_files = self.record(base / "input", "Station_G2_companion_radio_full")
|
|
repeater, repeater_files = self.record(base / "input", "Station_G2_repeater", build_profile="full")
|
|
records = [{"manifest": companion, "files": companion_files},
|
|
{"manifest": repeater, "files": repeater_files}]
|
|
extras = [base / "board-migration.zip", base / "board_bootloader.hex"]
|
|
for path in extras:
|
|
path.write_bytes(b"exact-board tool fixture\n")
|
|
before = {path: path.read_bytes() for path in companion_files + repeater_files + extras}
|
|
output = base / "stage"
|
|
argv = ["package", "--input", str(base / "input"), "--output", str(output),
|
|
"--local-release", "--stable", "--version", "1.17.1.9", "--commit", SOURCE]
|
|
with patch.object(sys, "argv", argv), patch.object(package, "collect_local_release",
|
|
return_value=(records, extras,
|
|
{"frequency": "910.525", "bandwidth": "62.5", "sf": "7", "cr": "5"})):
|
|
package.main()
|
|
plan = json.loads((output / "release-plan.json").read_text())
|
|
self.assertEqual([group["key"] for group in plan["groups"]], ["companion", "full-profiles", "utility"])
|
|
guide = "https://mikecarper.github.io/MeshCore/releases/1.17.1.9/"
|
|
for group in plan["groups"]:
|
|
notes = (output / (group["key"] + "-notes.md")).read_text(encoding="ascii")
|
|
self.assertTrue(notes.startswith("[Open the firmware picker](https://mikecarper.github.io/MeshCore/firmware_picker/)"))
|
|
self.assertIn("[What's new in 1.17.1.9](" + guide + ")", notes)
|
|
self.assertIn("[Installation and upgrade guide](" + guide + ")", notes)
|
|
self.assertIn("[Companion firmware](https://github.com/mikecarper/MeshCore/releases/tag/" + FAMILY + ")", notes)
|
|
self.assertIn("[ESP32 Repeater firmware](https://github.com/mikecarper/MeshCore/releases/tag/full-profiles-" + FAMILY + ")", notes)
|
|
self.assertIn("[Migration and bootloader tools](https://github.com/mikecarper/MeshCore/releases/tag/utility-" + FAMILY + ")", notes)
|
|
self.assertNotIn("[full-profiles]", notes)
|
|
self.assertNotIn("qualified firmware profiles", notes)
|
|
self.assertIn("**Testing:**", notes)
|
|
self.assertIn("Physical coverage varies by board and update route", notes)
|
|
self.assertIn("Saved radio settings take precedence", notes)
|
|
self.assertIn(SOURCE, notes)
|
|
self.assertIn(FAMILY, notes)
|
|
self.assertEqual((output / group["key"] / "BUILD-NOTES.txt").read_text(), notes)
|
|
for line in (output / group["key"] / "SHA256SUMS.txt").read_text().splitlines():
|
|
expected, name = line.split(" ", 1)
|
|
self.assertEqual(hashlib.sha256((output / group["key"] / name).read_bytes()).hexdigest(), expected)
|
|
self.assertEqual({path: path.read_bytes() for path in before}, before)
|
|
|
|
def test_split_pages_keep_tags_and_explain_one_version_with_human_links(self):
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
base = Path(temp)
|
|
records = []
|
|
for index in range(3):
|
|
target = f"board_{index}_repeater_lora_ota"
|
|
row, files = self.record(base / "input", target, platform="NRF52_PLATFORM")
|
|
# Exercise the real 900-file split with many tiny report assets.
|
|
for number in range(447):
|
|
path = base / "input" / f"{target}-report-{number}.txt"
|
|
path.write_bytes(b"qualification report fixture\n")
|
|
files.append(path)
|
|
self.assertEqual(len(files), 451)
|
|
records.append({"manifest": row, "files": files})
|
|
output = base / "stage"
|
|
argv = ["package", "--input", str(base / "input"), "--output", str(output),
|
|
"--local-release", "--stable", "--version", "1.17.1.9", "--commit", SOURCE]
|
|
with patch.object(sys, "argv", argv), patch.object(package, "collect_local_release",
|
|
return_value=(records, [],
|
|
{"frequency": "910.525", "bandwidth": "62.5", "sf": "7", "cr": "5"})):
|
|
package.main()
|
|
plan = json.loads((output / "release-plan.json").read_text())
|
|
self.assertEqual([group["key"] for group in plan["groups"]], ["lora-ota", "lora-ota-2", "lora-ota-3"])
|
|
for number, group in enumerate(plan["groups"], 1):
|
|
self.assertEqual(group["tag"], group["key"] + "-" + FAMILY)
|
|
self.assertEqual(group["title"], f"MeshCore 1.17.1.9 - nRF52 Repeater firmware (part {number} of 3)")
|
|
notes = (output / (group["key"] + "-notes.md")).read_text(encoding="ascii")
|
|
self.assertIn("split for download limits and contain the same firmware version", notes)
|
|
for other in range(1, 4):
|
|
self.assertIn(f"[nRF52 Repeater firmware (part {other} of 3)]", notes)
|
|
self.assertEqual(group["asset_count"], 457)
|
|
|
|
def test_local_release_preserves_selected_profiles_and_verifies_complete_inventory(self):
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
directory = Path(temp)
|
|
row, files = self.record(directory / "firmware/companion", "Station_G2_companion_radio_usb")
|
|
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": 1, "migration_board_role_count": 1,
|
|
"firmware": [{"target": row["target"], "artifact_target": row["artifact_target"],
|
|
"profile": "auto", "platform": "ESP32_PLATFORM",
|
|
"files": [str(p.relative_to(directory)) for p in files]}]}
|
|
(directory / "manifest.json").write_text(json.dumps(manifest))
|
|
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())
|
|
checksum = directory / "SHA256SUMS.txt"
|
|
checksum.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()))
|
|
with patch.object(package, "collect_artifacts", return_value=[{"manifest": row, "files": files}]), \
|
|
patch("package_esp32_partition_migration.BOARDS", {"example": {}}), \
|
|
patch("build_esp32_partition_migration.verify_archive"):
|
|
records, extras, _ = package.collect_local_release(directory, VERSION, SOURCE)
|
|
self.assertEqual(len(records), 1)
|
|
self.assertEqual(extras, [archive, bundle])
|
|
with self.assertRaisesRegex(ValueError, "another source"):
|
|
package.collect_local_release(directory, VERSION, "b" * 40)
|
|
(directory / "unqualified.bin").write_bytes(b"unexpected")
|
|
with self.assertRaisesRegex(ValueError, "inventory is incomplete"):
|
|
package.collect_local_release(directory, VERSION, SOURCE)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|