From 99822be4dbf6fbb2eafa72e8edd0e1a47b4a096a Mon Sep 17 00:00:00 2001 From: maci Date: Thu, 29 Oct 2026 15:56:45 +0800 Subject: [PATCH] build: package verified releases for Moonraker ZIP updates --- docs/BUILDS.md | 20 ++++++++++++++++ docs/PROVENANCE.md | 15 ++++++++++++ tools/release/prepare.py | 36 +++++++++++++++++++++++++++++ tools/release/tests/test_prepare.py | 30 +++++++++++++++++++++++- 4 files changed, 100 insertions(+), 1 deletion(-) diff --git a/docs/BUILDS.md b/docs/BUILDS.md index 91f13f6..bf605a6 100644 --- a/docs/BUILDS.md +++ b/docs/BUILDS.md @@ -231,3 +231,23 @@ recommends Zero 2 W and Pi 3/4/5, and advises against Pi 1, Pi 2 and original Zero hosts because their processing capacity can cause print stalls. Klipper also requires double-precision floating-point hardware on low-end hosts. A phobos software-float executable does not remove those Klipper requirements. + +## Moonraker ZIP packages + +`tools/release/prepare.py` also wraps each verified source-build artifact in a +native update-manager ZIP. Its stable, platform-specific name is +`phobos-.zip` (for the Adventurer 5M/Cortex-A7: +`phobos-arm-linux-musleabihf.zip`). The version remains in the release URL and +`release_info.json`, not in the installed executable's filename. + +The flat archive contains `phobos` (0755), `release_info.json`, `.version`, and +`LICENSE`. Metadata carries `project_owner`, `project_name`, `version`, and +`asset_name`; that last field lets Phobos select the same architecture on its +next update. The binary bytes are unchanged. ZIP timestamps and permissions are +fixed, and the combined SHA256SUMS covers both raw and ZIP assets. + +Extract the verified ZIP into a dedicated application directory such as +`/data/phobos`, never `/data` itself: Moonraker replaces the configured application +directory during a ZIP update. Forge-X's optional updater entry uses that dedicated +directory and an explicit asset name. Selecting/running Phobos as the API daemon +is a separate operation; installing or updating the package does not switch it. diff --git a/docs/PROVENANCE.md b/docs/PROVENANCE.md index 4059a86..80289ec 100644 --- a/docs/PROVENANCE.md +++ b/docs/PROVENANCE.md @@ -2530,3 +2530,18 @@ or production-install claim was added. No printer was contacted, no VM or foreign executable was started, and no image or live database was opened or changed. Existing releases were not retagged or overwritten. Graphify is absent from PATH, so the optional graph update was skipped. + +### 2026-10-29 — native ZIP release packaging + +Added deterministic, platform-specific Moonraker ZIP packages to the verified +release assembler. ZIP payloads retain the source-built executable bytes and +include executable permissions, GPLv3 licensing, version/repository metadata and +a stable architecture-specific `asset_name` used by Phobos's own ZIP updater. +The release manifest now covers raw binaries and ZIPs. Packaging tests check +metadata, permissions, deterministic output and refusal to overwrite artifacts. + +For the v0.3.0 Forge-X package, the retained Cortex-A7 source-build output was +checked against the existing published SHA256SUMS (binary SHA256 +`0069056fcff9baf8c1edc612869180e863883eba2808b7805a88b1a84a2f30ef`). +The ZIP repackages that exact published payload. A redundant fresh tag rebuild +reached the build helper's 600-second timeout; it was not used as package evidence. diff --git a/tools/release/prepare.py b/tools/release/prepare.py index dbf3df3..9ef6260 100644 --- a/tools/release/prepare.py +++ b/tools/release/prepare.py @@ -5,6 +5,9 @@ import argparse import csv import hashlib +import json +import stat +import zipfile import re import shutil import subprocess @@ -50,6 +53,35 @@ def absolute_link(match: re.Match[str]) -> str: return re.sub(r"\]\(([^)]+)\)", absolute_link, notes.read_text()) +def package_zip(root: Path, binary: Path, output: Path, version: str, repository: str) -> Path: + """Wrap an already verified source-build artifact for Moonraker's ZIP updater.""" + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version): + raise ValueError("invalid package version") + if (not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository) + or any(part in (".", "..") for part in repository.split("/"))): + raise ValueError("invalid repository name") + if not re.fullmatch(rf"phobos-v{re.escape(version)}-[A-Za-z0-9_.+-]+", binary.name): + raise ValueError("invalid binary asset name") + owner, project = repository.split("/") + name = "phobos-" + binary.name.removeprefix(f"phobos-v{version}-") + ".zip" + metadata = {"project_owner": owner, "project_name": project, "version": "v" + version, "asset_name": name} + destination = output / name + entries = { + "phobos": (binary.read_bytes(), 0o755), + "release_info.json": ((json.dumps(metadata, indent=2) + "\n").encode(), 0o644), + ".version": (("v" + version + "\n").encode(), 0o644), + "LICENSE": ((root / "LICENSE").read_bytes(), 0o644), + } + with zipfile.ZipFile(destination, "x", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: + for name, (content, mode) in entries.items(): + entry = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + entry.create_system = 3 + entry.external_attr = (stat.S_IFREG | mode) << 16 + entry.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(entry, content) + return destination + + def assemble(root: Path, source: Path, output: Path, repository: str) -> None: if not output.resolve().is_relative_to((root / ".scratch").resolve()): raise ValueError("output must be inside project .scratch") @@ -91,6 +123,10 @@ def assemble(root: Path, source: Path, output: Path, repository: str) -> None: raise ValueError(f"asset checksum mismatch: {profile}") shutil.copyfile(directory / asset, output / asset) checksums.append(f"{actual} {asset}\n") + zipped = package_zip(root, output / asset, output, version, repository) + with zipped.open("rb") as stream: + zipped_hash = hashlib.file_digest(stream, "sha256").hexdigest() + checksums.append(f"{zipped_hash} {zipped.name}\n") metadata.append(entry) (output / "SHA256SUMS").write_text("".join(checksums)) with (output / "profiles.tsv").open("w") as stream: diff --git a/tools/release/tests/test_prepare.py b/tools/release/tests/test_prepare.py index 0586676..de552af 100644 --- a/tools/release/tests/test_prepare.py +++ b/tools/release/tests/test_prepare.py @@ -1,4 +1,6 @@ import hashlib +import json +import zipfile import subprocess from pathlib import Path @@ -33,7 +35,7 @@ def test_assemble_copies_every_asset_and_combines_manifests(tmp_path: Path) -> N root, source = fixture_artifacts(tmp_path) output = tmp_path / "output" prepare.assemble(root, source, output, "maci0/phobos") - assert len((output / "SHA256SUMS").read_text().splitlines()) == 8 + assert len((output / "SHA256SUMS").read_text().splitlines()) == 16 assert len((output / "profiles.tsv").read_text().splitlines()) == 9 version = prepare.package_version(root) assert (output / f"phobos-v{version}-armv7-soft").read_bytes() == b"armv7-soft\n" @@ -112,3 +114,29 @@ def test_notes_reject_invalid_repository_and_missing_target(tmp_path: Path) -> N notes.write_text("[missing](../../missing.md)\n") with pytest.raises(ValueError, match="target is missing"): prepare.release_notes(tmp_path, "1.2.3", "maci0/phobos") + + +def test_zip_package_is_deterministic_executable_and_moonraker_compatible(tmp_path: Path) -> None: + root = prepare.project_root(Path(__file__).resolve()) + version = prepare.package_version(root) + binary = tmp_path / f"phobos-v{version}-arm-linux-musleabihf" + binary.write_bytes(b"verified source-build fixture") + first = tmp_path / "first" + second = tmp_path / "second" + first.mkdir() + second.mkdir() + a = prepare.package_zip(root, binary, first, version, "maci0/phobos") + b = prepare.package_zip(root, binary, second, version, "maci0/phobos") + assert a.read_bytes() == b.read_bytes() + with zipfile.ZipFile(a) as archive: + assert archive.testzip() is None + assert set(archive.namelist()) == {"phobos", "release_info.json", ".version", "LICENSE"} + assert archive.read("phobos") == binary.read_bytes() + assert (archive.getinfo("phobos").external_attr >> 16) & 0o777 == 0o755 + assert json.loads(archive.read("release_info.json")) == { + "project_owner": "maci0", "project_name": "phobos", "version": f"v{version}", + "asset_name": "phobos-arm-linux-musleabihf.zip" + } + assert archive.read(".version") == f"v{version}\n".encode() + with pytest.raises(FileExistsError): + prepare.package_zip(root, binary, first, version, "maci0/phobos")