#!/usr/bin/env python3 """Build and package a portable Windows ZIP for sprimo-app.""" from __future__ import annotations import argparse import hashlib import json import os import shutil import subprocess import sys import tempfile from dataclasses import dataclass from pathlib import Path from typing import Iterable ROOT = Path(__file__).resolve().parents[1] DIST = ROOT / "dist" BIN_REL = ROOT / "target" / "release" / "sprimo-app.exe" ASSETS_REL = ROOT / "assets" class PackagingError(RuntimeError): """Raised when packaging preconditions are not met.""" @dataclass(frozen=True) class PackageLayout: version: str zip_path: Path checksum_path: Path def run(cmd: list[str], cwd: Path = ROOT) -> None: result = subprocess.run(cmd, cwd=cwd, check=False) if result.returncode != 0: raise PackagingError(f"command failed ({result.returncode}): {' '.join(cmd)}") def read_version() -> str: cmd = ["cargo", "metadata", "--format-version", "1", "--no-deps"] proc = subprocess.run( cmd, cwd=ROOT, check=False, capture_output=True, text=True, ) if proc.returncode != 0: raise PackagingError(f"failed to read cargo metadata: {proc.stderr.strip()}") data = json.loads(proc.stdout) root_pkg = data.get("workspace_root") if not root_pkg: raise PackagingError("workspace metadata missing workspace_root") # Prefer workspace package version. manifest_path = ROOT / "Cargo.toml" manifest = manifest_path.read_text(encoding="utf-8") marker = "version = " for line in manifest.splitlines(): stripped = line.strip() if stripped.startswith(marker): return stripped.split("=", 1)[1].strip().strip('"') # Fallback to first package version from metadata. packages = data.get("packages", []) if packages: return packages[0]["version"] raise PackagingError("could not determine version") def ensure_release_binary() -> Path: if not BIN_REL.exists(): run(["cargo", "build", "--release", "-p", "sprimo-app"]) if not BIN_REL.exists(): raise PackagingError(f"release binary missing: {BIN_REL}") return BIN_REL def ensure_assets() -> None: required = [ ASSETS_REL / "sprite-packs" / "default" / "manifest.json", ASSETS_REL / "sprite-packs" / "default" / "sprite.png", ] missing = [path for path in required if not path.exists()] if missing: joined = ", ".join(str(path) for path in missing) raise PackagingError(f"required assets missing: {joined}") def iter_stage_files(stage_root: Path) -> Iterable[Path]: for path in stage_root.rglob("*"): if path.is_file(): yield path def sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: while True: chunk = handle.read(1024 * 1024) if not chunk: break digest.update(chunk) return digest.hexdigest() def package() -> PackageLayout: version = read_version() ensure_assets() binary = ensure_release_binary() DIST.mkdir(parents=True, exist_ok=True) artifact_name = f"sprimo-windows-x64-v{version}" zip_path = DIST / f"{artifact_name}.zip" checksum_path = DIST / f"{artifact_name}.zip.sha256" with tempfile.TemporaryDirectory(prefix="sprimo-package-") as temp_dir: stage = Path(temp_dir) / artifact_name stage.mkdir(parents=True, exist_ok=True) shutil.copy2(binary, stage / "sprimo-app.exe") shutil.copytree(ASSETS_REL, stage / "assets", dirs_exist_ok=True) readme = stage / "README.txt" readme.write_text( "Sprimo portable package\n" "Run: sprimo-app.exe\n" "Assets are expected at ./assets relative to the executable.\n", encoding="utf-8", ) archive_base = DIST / artifact_name if zip_path.exists(): zip_path.unlink() if checksum_path.exists(): checksum_path.unlink() shutil.make_archive(str(archive_base), "zip", root_dir=stage.parent, base_dir=stage.name) checksum = sha256_file(zip_path) checksum_path.write_text(f"{checksum} {zip_path.name}\n", encoding="utf-8") return PackageLayout(version=version, zip_path=zip_path, checksum_path=checksum_path) def smoke() -> None: layout = package() print(f"package created: {layout.zip_path}") print(f"checksum file: {layout.checksum_path}") with tempfile.TemporaryDirectory(prefix="sprimo-smoke-") as temp_dir: unzip_dir = Path(temp_dir) / "unpacked" shutil.unpack_archive(layout.zip_path, unzip_dir) root_candidates = [p for p in unzip_dir.iterdir() if p.is_dir()] if not root_candidates: raise PackagingError("smoke failed: package root directory missing") pkg_root = root_candidates[0] required = [ pkg_root / "sprimo-app.exe", pkg_root / "assets" / "sprite-packs" / "default" / "manifest.json", pkg_root / "assets" / "sprite-packs" / "default" / "sprite.png", ] missing = [path for path in required if not path.exists()] if missing: joined = ", ".join(str(path) for path in missing) raise PackagingError(f"smoke failed: expected files missing: {joined}") print("smoke check passed: package structure is valid") def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Sprimo Windows packaging helper") parser.add_argument("command", choices=["package", "smoke"], help="action to execute") return parser.parse_args() def main() -> int: args = parse_args() try: if args.command == "package": layout = package() print(f"created: {layout.zip_path}") print(f"sha256: {layout.checksum_path}") else: smoke() return 0 except PackagingError as exc: print(f"error: {exc}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())