#!/usr/bin/env python3 """Stage a repo into an MPQ patch according to a manifest, then invoke mpqpack. This is the orchestration layer (the "BigWigs release.sh" equivalent). It: 1. reads a manifest (mpq.yaml) describing what to pack and where it lands inside the archive, 2. copies the selected files into a temp staging tree, honouring ignore globs, 3. optionally substitutes @keyword@ tokens (version/revision/date) in text files, 4. names the output `patch-.mpq` per the vanilla 1.12 loader constraint (single-character suffix only), and 5. calls the `mpqpack` binary to build the archive with StormLib. Manifest schema (all keys optional except `contents`): name: GlueXML # cosmetic, used in logs / release naming patch-letter: Z # single char A-Z; high letters sort last & win max-files: 4096 # hash-table sizing hint substitute: enabled: true extensions: [".lua", ".xml", ".toc"] contents: - src: . # path in the repo (file or dir) dest: Interface/GlueXML # in-MPQ path (forward slashes become backslashes) ignore: # glob patterns, matched against repo-relative paths - ".git/**" - ".gitea/**" - "mpq.yaml" - "*.md" """ from __future__ import annotations import argparse import fnmatch import os import re import shutil import subprocess import sys import tempfile from pathlib import Path try: import yaml except ImportError: sys.exit("package.py: PyYAML is required (pip install pyyaml)") DEFAULT_IGNORES = [".git", ".git/**", ".gitea", ".gitea/**", "mpq.yaml"] def log(msg: str) -> None: print(f"package.py: {msg}", flush=True) def matches_any(rel_posix: str, patterns: list[str]) -> bool: """True if rel_posix matches a glob, or sits under an ignored directory.""" first = rel_posix.split("/", 1)[0] for pat in patterns: if fnmatch.fnmatch(rel_posix, pat): return True # bare directory name (e.g. ".git") ignores the whole subtree if "/" not in pat and "*" not in pat and first == pat: return True return False def stage_entry(repo: Path, stage: Path, entry: dict) -> int: src = (repo / entry.get("src", ".")).resolve() dest_rel = entry["dest"].replace("\\", "/").strip("/") ignore = DEFAULT_IGNORES + list(entry.get("ignore", [])) if not src.exists(): sys.exit(f"package.py: source not found: {src}") copied = 0 files = [src] if src.is_file() else sorted(p for p in src.rglob("*") if p.is_file()) for f in files: rel = f.name if src.is_file() else f.relative_to(src).as_posix() if src.is_dir() and matches_any(rel, ignore): continue target = stage / dest_rel / rel target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(f, target) copied += 1 log(f"staged {copied} files: {entry.get('src', '.')} -> {dest_rel.replace('/', chr(92))}") return copied def substitute_tokens(stage: Path, exts: list[str], tokens: dict[str, str]) -> None: pattern = re.compile("|".join(re.escape(k) for k in tokens)) exts = {e.lower() for e in exts} n = 0 for f in stage.rglob("*"): if not f.is_file() or f.suffix.lower() not in exts: continue try: text = f.read_text(encoding="utf-8") except UnicodeDecodeError: continue new = pattern.sub(lambda m: tokens[m.group(0)], text) if new != text: f.write_text(new, encoding="utf-8") n += 1 if n: log(f"substituted version tokens in {n} files") def main() -> int: ap = argparse.ArgumentParser(description="Build an MPQ patch from a manifest.") ap.add_argument("--manifest", default="mpq.yaml") ap.add_argument("--repo", default=".", help="repo root to package") ap.add_argument("--out-dir", default="dist") ap.add_argument("--version", default="", help="release version, e.g. v1.0.0") ap.add_argument("--revision", default="", help="git short hash") ap.add_argument("--date", default="", help="ISO build date") ap.add_argument("--mpqpack", default="mpqpack", help="path to mpqpack binary") args = ap.parse_args() repo = Path(args.repo).resolve() manifest_path = repo / args.manifest if not manifest_path.exists(): sys.exit(f"package.py: manifest not found: {manifest_path}") manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {} contents = manifest.get("contents") if not contents: sys.exit("package.py: manifest has no 'contents'") letter = str(manifest.get("patch-letter", "Z")).strip() if len(letter) != 1 or not letter.isalnum(): sys.exit(f"package.py: patch-letter must be a single char, got {letter!r}") out_dir = Path(args.out_dir).resolve() out_dir.mkdir(parents=True, exist_ok=True) out_mpq = out_dir / f"patch-{letter}.mpq" with tempfile.TemporaryDirectory(prefix="mpqstage-") as tmp: stage = Path(tmp) total = sum(stage_entry(repo, stage, e) for e in contents) if total == 0: sys.exit("package.py: nothing staged — check your manifest paths/ignores") sub = manifest.get("substitute", {}) if sub.get("enabled"): tokens = { "@project-version@": args.version, "@project-revision@": args.revision, "@project-date-iso@": args.date, } substitute_tokens(stage, sub.get("extensions", [".lua", ".xml", ".toc"]), tokens) max_files = str(manifest.get("max-files", total + 16)) log(f"building {out_mpq.name} from {total} files") rc = subprocess.run( [args.mpqpack, str(out_mpq), str(stage), max_files] ).returncode if rc != 0: sys.exit(f"package.py: mpqpack failed (exit {rc})") log(f"done -> {out_mpq}") # Expose the artifact path to a Gitea/GitHub Actions step. if gh_out := os.environ.get("GITHUB_OUTPUT"): with open(gh_out, "a", encoding="utf-8") as fh: fh.write(f"mpq-path={out_mpq}\n") fh.write(f"mpq-name={out_mpq.name}\n") return 0 if __name__ == "__main__": raise SystemExit(main())