diff --git a/scripts/publish-pack.py b/scripts/publish-pack.py new file mode 100644 index 0000000..f5757ad --- /dev/null +++ b/scripts/publish-pack.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +""" +Linux-side port of Build-Pack.ps1 + Deploy-Brass.ps1 (publish step). + +Reads pack/pack.lock.json + walks pack/overrides/, rebuilds manifest.json, +mirrors every override file to the live web root, and republishes the +manifest. The launcher will then see fresh hashes on its next sync. + +Run on glados with passwordless sudo. Idempotent. + +Web root: /srv/services/sites/bns (mounted into Caddy as /srv/sites/bns). +""" +import hashlib +import json +import shutil +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +REPO = Path("/home/matt/dev/brass-and-sigil") +LOCK_PATH = REPO / "pack" / "pack.lock.json" +OVERRIDES = REPO / "pack" / "overrides" +WEB_ROOT = Path("/srv/services/sites/bns/pack") +WEB_FILES = WEB_ROOT / "files" +WEB_MANIFEST = WEB_ROOT / "manifest.json" +SELF_HOST_BASE = "https://bns.sijbers.uk/pack/files" + +MANAGED_ROOTS = ["mods", "config", "resourcepacks", "shaderpacks", "kubejs", "defaultconfigs", "patchouli_books"] + +# bnstoolkit ships client+server surface (screens, HUD, network bridge), +# so it must NOT inherit the default "server-only" tag that Build-Pack.ps1 +# stamps on every pack/overrides/mods/*.jar. brassandsigil_tweaks is +# data-only (worldgen modifiers / recipe overrides) so server-side is +# correct for it. +CLIENT_AND_SERVER_LOCAL_MODS = {"bnstoolkit"} + + +def sha1_of(p: Path) -> str: + h = hashlib.sha1() + with p.open("rb") as f: + for chunk in iter(lambda: f.read(1 << 16), b""): + h.update(chunk) + return h.hexdigest() + + +def main(): + with LOCK_PATH.open() as f: + lock = json.load(f) + + files = [] + total_bytes = 0 + + # 1. Mods from the lockfile (Modrinth-hosted) + for m in lock["mods"]: + entry = { + "path": m["path"], + "url": m["url"], + "sha1": m["sha1"], + "size": m["size"], + } + side = m.get("side") + if side and side != "both": + entry["side"] = side + files.append(entry) + total_bytes += m["size"] + + # 2. Local overrides + for root in MANAGED_ROOTS: + root_dir = OVERRIDES / root + if not root_dir.exists(): + continue + for p in sorted(root_dir.rglob("*")): + if not p.is_file(): + continue + rel = p.relative_to(OVERRIDES).as_posix() + sha1 = sha1_of(p) + size = p.stat().st_size + entry = { + "path": rel, + "url": f"{SELF_HOST_BASE}/{rel}", + "sha1": sha1, + "size": size, + } + if root == "mods": + # See CLIENT_AND_SERVER_LOCAL_MODS — bnstoolkit must be loaded + # on both sides; the other locally-built mods are server-only. + stem = p.name.split("-")[0].split("_")[0] # before first - or _ + if not any(p.name.startswith(prefix) for prefix in CLIENT_AND_SERVER_LOCAL_MODS): + entry["side"] = "server" + files.append(entry) + total_bytes += size + + # 3. Compose manifest matching the existing schema + manifest = { + "name": lock["name"], + "version": lock["version"], + "minecraft": {"version": lock["minecraft"]}, + "loader": {"type": lock["loader"]["type"], "version": lock["loader"]["version"]}, + "files": files, + } + for opt in ("defaultServer", "defaultShader", "panelUrl"): + if opt in lock: + manifest[opt] = lock[opt] + + # Carry the launcher metadata from the existing published manifest + # (we can't read a Windows .exe's FileVersion from Linux easily, and + # the launcher version isn't related to pack content). + try: + with WEB_MANIFEST.open() as f: + existing = json.load(f) + for k in ("launcherVersion", "launcherUrl"): + if k in existing: + manifest[k] = existing[k] + except Exception: + pass + + # 4. Mirror override files to web root (uses sudo + cp -a). Only update + # files whose hash differs from what's already published to keep the + # rsync footprint tiny. + print(f"\nMirroring {sum(1 for r in MANAGED_ROOTS if (OVERRIDES / r).exists())} override roots to {WEB_FILES}\n") + changed = 0 + skipped = 0 + for root in MANAGED_ROOTS: + root_dir = OVERRIDES / root + if not root_dir.exists(): + continue + target_root = WEB_FILES / root + for p in root_dir.rglob("*"): + if not p.is_file(): + continue + rel = p.relative_to(OVERRIDES) + target = WEB_FILES / rel + try: + if target.exists() and sha1_of(target) == sha1_of(p): + skipped += 1 + continue + except PermissionError: + # Need sudo to read + pass + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(p, target) + changed += 1 + print(f" changed: {changed}, skipped (matching hash): {skipped}\n") + + # 5. Write the manifest atomically (write to .new, then move) + new_manifest = WEB_MANIFEST.with_suffix(".json.new") + bak_manifest = WEB_MANIFEST.with_suffix(".json.bak") + if WEB_MANIFEST.exists(): + shutil.copy2(WEB_MANIFEST, bak_manifest) + with new_manifest.open("w") as f: + json.dump(manifest, f, indent=2, ensure_ascii=False) + f.write("\n") + new_manifest.replace(WEB_MANIFEST) + + print(f"Manifest written: {WEB_MANIFEST}") + print(f" Version: {lock['version']}") + print(f" Files: {len(files)}") + print(f" Total: {total_bytes / 1024 / 1024:.1f} MB download") + print() + # Quick verify + rel = "kubejs/server_scripts/economy/00_tier_system.js" + entry = next((f for f in files if f["path"] == rel), None) + if entry: + print(f" {rel} -> sha1={entry['sha1'][:12]}... size={entry['size']}") + + +if __name__ == "__main__": + main()