Files
Sprimo/scripts/package_windows.py
2026-02-12 22:58:33 +08:00

198 lines
6.0 KiB
Python

#!/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())