mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-26 14:07:58 +00:00
232 lines
10 KiB
Python
232 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Build verified in-place Wi-Fi and LoRa ESP32 partition-migration ZIPs.
|
|
|
|
Run in Linux/WSL with PlatformIO installed. PlatformIO steps are deliberately
|
|
serial: build.sh cleans the shared .pio/build tree between Full targets.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import zipfile
|
|
|
|
from package_esp32_partition_migration import BOARDS
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def git_output(*args: str) -> str:
|
|
# This Windows checkout uses core.autocrlf=input. WSL often lacks that
|
|
# global setting and would misreport unchanged CRLF files as dirty.
|
|
return subprocess.check_output(
|
|
["git", "-c", "core.autocrlf=input", *args], cwd=ROOT, text=True
|
|
).strip()
|
|
|
|
|
|
def build_steps(boards: list[str], version: str, radio_preset: str,
|
|
profile: str, jobs: int) -> list[tuple[list[str], bool]]:
|
|
steps: list[tuple[list[str], bool]] = []
|
|
for name in boards:
|
|
steps.append(([
|
|
"bash", "build.sh", "build-firmware", BOARDS[name]["target"],
|
|
"--full-exact", "--firmware-version", version,
|
|
"--radio-preset", radio_preset, "--profile", profile,
|
|
"--require-ota", "--resume",
|
|
], True))
|
|
# Full builds can clear .pio/build; every bridge must be built afterwards.
|
|
built_bridges: set[str] = set()
|
|
for name in boards:
|
|
for key in ("wifi_bridge", "lora_bridge", "expander_bridge"):
|
|
bridge = BOARDS[name].get(key)
|
|
if bridge and bridge not in built_bridges:
|
|
steps.append((["pio", "run", "-e", bridge,
|
|
"-j", str(jobs)], False))
|
|
built_bridges.add(bridge)
|
|
return steps
|
|
|
|
|
|
def verify_archive(path: Path, board: str, version: str, source: str) -> None:
|
|
with zipfile.ZipFile(path) as archive:
|
|
damaged = archive.testzip()
|
|
if damaged:
|
|
raise ValueError(f"{path}: damaged ZIP entry {damaged}")
|
|
manifest = json.loads(archive.read("manifest.json"))
|
|
if (manifest["board"] != board or manifest["firmware_version"] != version
|
|
or manifest["source_commit"] != source):
|
|
raise ValueError(f"{path}: package identity does not match recipe")
|
|
for filename, expected in manifest["files"].items():
|
|
data = archive.read(filename)
|
|
if (len(data) != expected["bytes"]
|
|
or hashlib.sha256(data).hexdigest() != expected["sha256"]):
|
|
raise ValueError(f"{path}: invalid {filename}")
|
|
|
|
|
|
def bundle_release(output_root: Path, package_dir: Path, boards: list[str],
|
|
version: str, source: str, radio_preset: str,
|
|
profile: str) -> Path:
|
|
"""Atomically publish the complete, exact-source migration set."""
|
|
packages = []
|
|
for board in boards:
|
|
matches = list(package_dir.glob(f"{board}-{version}-{source}-migration.zip"))
|
|
if len(matches) != 1:
|
|
raise ValueError(f"release is missing {board}")
|
|
verify_archive(matches[0], board, version, source)
|
|
data = matches[0].read_bytes()
|
|
packages.append((matches[0].name, data))
|
|
manifest = {
|
|
"format": "meshcore-esp32-partition-migration-release-v1",
|
|
"scope": "configured-legacy-layout-board-role-recipes",
|
|
"historical_firmware_binaries_included": False,
|
|
"source_commit": source,
|
|
"firmware_version": version,
|
|
"radio_preset": radio_preset,
|
|
"profile": profile,
|
|
"boards": boards,
|
|
"board_roles": {name: BOARDS[name]["role"] for name in boards},
|
|
"roles": sorted({BOARDS[name]["role"] for name in boards}),
|
|
"packages": {name: {"bytes": len(data),
|
|
"sha256": hashlib.sha256(data).hexdigest()}
|
|
for name, data in packages},
|
|
}
|
|
archive = output_root / f"esp32-partition-migration-{version}-{source}-release.zip"
|
|
with tempfile.NamedTemporaryFile(prefix="migration-release-", suffix=".zip",
|
|
dir=output_root, delete=False) as temporary:
|
|
temporary_path = Path(temporary.name)
|
|
try:
|
|
with zipfile.ZipFile(temporary_path, "w", compression=zipfile.ZIP_STORED) as result:
|
|
result.writestr("release-manifest.json", json.dumps(manifest, indent=2) + "\n")
|
|
for name, data in packages:
|
|
result.writestr(name, data)
|
|
with zipfile.ZipFile(temporary_path) as result:
|
|
if result.testzip() or set(result.namelist()) != {"release-manifest.json"} | {
|
|
name for name, _ in packages}:
|
|
raise ValueError("release ZIP failed integrity check")
|
|
if archive.exists():
|
|
if hashlib.sha256(archive.read_bytes()).digest() != \
|
|
hashlib.sha256(temporary_path.read_bytes()).digest():
|
|
raise ValueError(f"existing release differs: {archive}")
|
|
else:
|
|
temporary_path.replace(archive)
|
|
finally:
|
|
temporary_path.unlink(missing_ok=True)
|
|
return archive
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--version", help="firmware release version")
|
|
parser.add_argument("--radio-preset",
|
|
help="radio preset passed to build.sh")
|
|
parser.add_argument("--profile", choices=("default", "cascade"),
|
|
default="default", help="embedded runtime profile")
|
|
parser.add_argument("--board", action="append", choices=tuple(BOARDS),
|
|
help="repeat to select board/role recipes (default: all configured recipes)")
|
|
parser.add_argument("--role", choices=("repeater", "room-server", "sensor"),
|
|
help="build every configured board for this role")
|
|
parser.add_argument("--jobs", type=int, default=4,
|
|
help="workers within each serial PlatformIO build")
|
|
parser.add_argument("--output-root", type=Path,
|
|
help="release folder (default: .releases/esp32-expanded-<commit>)")
|
|
parser.add_argument("--dry-run", action="store_true",
|
|
help="show the build order without writing files")
|
|
parser.add_argument("--list-boards", action="store_true",
|
|
help="print configured board/role keys and exit")
|
|
args = parser.parse_args()
|
|
if args.list_boards:
|
|
for name in BOARDS:
|
|
print(name)
|
|
return
|
|
if not args.version or not args.radio_preset:
|
|
parser.error("--version and --radio-preset are required for a build")
|
|
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._+\-]*", args.version):
|
|
parser.error("--version must be a filename-safe version token")
|
|
if args.jobs < 1:
|
|
parser.error("--jobs must be positive")
|
|
if args.board and args.role:
|
|
parser.error("choose --board or --role, not both")
|
|
if os.name == "nt" and not args.dry_run:
|
|
parser.error("run this recipe inside Linux/WSL, where bash and pio are available")
|
|
|
|
source = git_output("rev-parse", "--short=8", "HEAD")
|
|
if args.role:
|
|
boards = [name for name, spec in BOARDS.items() if spec["role"] == args.role]
|
|
else:
|
|
boards = list(dict.fromkeys(args.board or BOARDS))
|
|
requested_root = args.output_root or Path(".releases") / f"esp32-expanded-{source}"
|
|
output_root = (requested_root if requested_root.is_absolute()
|
|
else ROOT / requested_root).resolve()
|
|
build_dir = output_root / "builds"
|
|
package_dir = output_root / "packages"
|
|
steps = build_steps(boards, args.version, args.radio_preset, args.profile, args.jobs)
|
|
for command, full in steps:
|
|
prefix = f"OUTPUT_DIR={shlex.quote(str(build_dir))} " if full else ""
|
|
print(prefix + shlex.join(command), flush=True)
|
|
print(f"Package {', '.join(boards)} into {package_dir}", flush=True)
|
|
if set(boards) == set(BOARDS):
|
|
print(f"Bundle all configured board/role recipes (not historical binaries) in {output_root}", flush=True)
|
|
if args.dry_run:
|
|
return
|
|
|
|
if git_output("status", "--porcelain", "--untracked-files=normal"):
|
|
parser.error("commit or remove working-tree changes before building a versioned package")
|
|
for executable in ("bash", "pio"):
|
|
if not shutil.which(executable):
|
|
parser.error(f"{executable} is unavailable in this environment")
|
|
|
|
output_root.mkdir(parents=True, exist_ok=True)
|
|
for command, full in steps:
|
|
environment = os.environ.copy()
|
|
if full:
|
|
environment["OUTPUT_DIR"] = str(build_dir)
|
|
subprocess.run(command, cwd=ROOT, env=environment, check=True)
|
|
|
|
# Package in a fresh directory so an interruption never leaves a partial
|
|
# release ZIP at the final path. Existing identical ZIPs are reusable.
|
|
with tempfile.TemporaryDirectory(prefix="migration-package-", dir=output_root) as temporary:
|
|
staging = Path(temporary) / "packages"
|
|
command = [
|
|
sys.executable, "-B", "scripts/package_esp32_partition_migration.py",
|
|
"--build-dir", str(build_dir), "--output-dir", str(staging),
|
|
"--version", args.version, "--source", source,
|
|
]
|
|
for name in boards:
|
|
command.extend(("--board", name))
|
|
subprocess.run(command, cwd=ROOT, check=True)
|
|
archives = sorted(staging.glob("*.zip"))
|
|
if len(archives) != len(boards):
|
|
raise ValueError("packager did not produce one ZIP per selected board")
|
|
package_dir.mkdir(parents=True, exist_ok=True)
|
|
for name in boards:
|
|
matches = [path for path in archives if path.name.startswith(name + "-")]
|
|
if len(matches) != 1:
|
|
raise ValueError(f"expected exactly one {name} ZIP")
|
|
staged = matches[0]
|
|
verify_archive(staged, name, args.version, source)
|
|
destination = package_dir / staged.name
|
|
if destination.exists():
|
|
if hashlib.sha256(destination.read_bytes()).digest() != \
|
|
hashlib.sha256(staged.read_bytes()).digest():
|
|
raise ValueError(f"existing package differs: {destination}")
|
|
else:
|
|
staged.replace(destination)
|
|
print(f"Ready: {destination}", flush=True)
|
|
if set(boards) == set(BOARDS):
|
|
print("Release: " + str(bundle_release(
|
|
output_root, package_dir, boards, args.version, source,
|
|
args.radio_preset, args.profile)), flush=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|