1#!/usr/bin/env python32"""Build a deterministic, provenance-bound Decky plugin archive."""34from __future__ import annotations56import hashlib7import json8import os9import pathlib10import shutil11import stat12import subprocess13import sys14import zipfile15from typing import NoReturn161718REPOSITORY = "https://github.com/FelineStateMachine/goq"19PLUGIN_DIRECTORY = "goq-sigil"20FIXED_FILES = (21 "LICENSE",22 "README.md",23 "compatibility.json",24 "dist/index.js",25 "main.py",26 "package.json",27 "plugin.json",28)293031def fail(message: str) -> NoReturn:32 raise SystemExit(message)333435def read_json(path: pathlib.Path) -> dict:36 try:37 value = json.loads(path.read_text(encoding="utf-8"))38 except (OSError, UnicodeError, json.JSONDecodeError) as error:39 fail(f"invalid JSON metadata: {path.name}: {error}")40 if not isinstance(value, dict):41 fail(f"JSON metadata must be an object: {path.name}")42 return value434445def source_commit(repository: pathlib.Path) -> str:46 override = os.environ.get("GOQ_SOURCE_COMMIT")47 if override is not None:48 if os.environ.get("GOQ_PACKAGE_TESTING") != "1":49 fail("GOQ_SOURCE_COMMIT is reserved for package tests")50 commit = override51 else:52 try:53 commit = subprocess.run(54 ["git", "rev-parse", "HEAD"],55 cwd=repository,56 check=True,57 stdout=subprocess.PIPE,58 stderr=subprocess.DEVNULL,59 text=True,60 timeout=5,61 ).stdout.strip()62 except (OSError, subprocess.SubprocessError) as error:63 fail(f"unable to resolve source commit: {error}")64 if len(commit) != 40 or any(character not in "0123456789abcdef" for character in commit):65 fail("source commit must be 40 lowercase hexadecimal characters")66 return commit676869def prepare_frontend(repository: pathlib.Path, plugin: pathlib.Path) -> None:70 if os.environ.get("GOQ_PACKAGE_TESTING") == "1":71 return72 status = subprocess.run(73 ["git", "status", "--porcelain", "--untracked-files=all", "--", "decky"],74 cwd=repository,75 check=True,76 stdout=subprocess.PIPE,77 stderr=subprocess.PIPE,78 text=True,79 timeout=5,80 ).stdout81 if status:82 fail("Decky package inputs must match the source commit exactly")83 pnpm = shutil.which("pnpm")84 if pnpm is None:85 fail("pnpm 9.4.0 is required to build the Decky package")86 version = subprocess.run(87 [pnpm, "--version"],88 check=True,89 stdout=subprocess.PIPE,90 stderr=subprocess.PIPE,91 text=True,92 timeout=5,93 ).stdout.strip()94 if version != "9.4.0":95 fail("pnpm 9.4.0 is required to build the Decky package")96 subprocess.run(97 [pnpm, "run", "build"],98 cwd=plugin,99 check=True,100 stdin=subprocess.DEVNULL,101 timeout=120,102 )103104105def collect_files(plugin: pathlib.Path) -> list[pathlib.Path]:106 files = [plugin / relative for relative in FIXED_FILES]107 module_root = plugin / "py_modules/goq_sigil"108 files.extend(sorted(module_root.glob("*.py")))109 for path in files:110 try:111 metadata = path.lstat()112 except OSError as error:113 fail(f"required plugin file is missing: {path.relative_to(plugin)}: {error}")114 if not stat.S_ISREG(metadata.st_mode) or path.is_symlink():115 fail(f"plugin input must be a regular non-symlink: {path.relative_to(plugin)}")116 return files117118119def main() -> int:120 repository = pathlib.Path(__file__).resolve().parent.parent121 plugin = repository / "decky"122 package = read_json(plugin / "package.json")123 compatibility = read_json(plugin / "compatibility.json")124 version = package.get("version")125 if not isinstance(version, str) or compatibility.get("plugin_version") != version:126 fail("package and compatibility plugin versions must match")127 if package.get("name") != PLUGIN_DIRECTORY:128 fail("unexpected Decky package name")129 prepare_frontend(repository, plugin)130131 output_root = pathlib.Path(sys.argv[1]) if len(sys.argv) == 2 else repository / "artifacts"132 if len(sys.argv) > 2:133 fail("usage: package-decky-plugin.py [output-directory]")134 output_root.mkdir(parents=True, exist_ok=True)135 output = output_root / f"{PLUGIN_DIRECTORY}-v{version}.zip"136 commit = source_commit(repository)137 files = collect_files(plugin)138 entries: list[tuple[str, bytes]] = []139 content_digests: dict[str, str] = {}140 for path in files:141 relative = path.relative_to(plugin).as_posix()142 contents = path.read_bytes()143 entries.append((f"{PLUGIN_DIRECTORY}/{relative}", contents))144 content_digests[relative] = hashlib.sha256(contents).hexdigest()145 provenance = json.dumps(146 {147 "schema_version": 1,148 "repository": REPOSITORY,149 "source_commit": commit,150 "package_inputs_clean": True,151 "plugin_version": version,152 "compatibility": compatibility["sigil"],153 "files": content_digests,154 },155 sort_keys=True,156 separators=(",", ":"),157 ).encode("utf-8") + b"\n"158159 entries.append((f"{PLUGIN_DIRECTORY}/provenance.json", provenance))160 entries.sort(key=lambda entry: entry[0])161162 temporary = output.with_suffix(".zip.tmp")163 try:164 with zipfile.ZipFile(165 temporary, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9166 ) as archive:167 for name, contents in entries:168 info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))169 info.compress_type = zipfile.ZIP_DEFLATED170 info.create_system = 3171 info.external_attr = 0o100644 << 16172 archive.writestr(info, contents)173 os.replace(temporary, output)174 finally:175 temporary.unlink(missing_ok=True)176177 digest = hashlib.sha256(output.read_bytes()).hexdigest()178 print(f"archive={output}")179 print(f"sha256={digest}")180 print(f"source_commit={commit}")181 return 0182183184if __name__ == "__main__":185 raise SystemExit(main())