Files
mpq-packager/package.py
T
paste 4293e90a97 Initial: MPQ patch packager (Gitea Action, StormLib-based)
A BigWigs-style CI packager for WoW MPQ patch archives. Reads an mpq.yaml
manifest, stages files, builds a vanilla-1.12-compatible patch-X.mpq via
StormLib, and (in the example workflow) attaches it to a Gitea release on tag.
2026-06-25 21:36:50 -05:00

173 lines
6.2 KiB
Python

#!/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-<letter>.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; converted to \)
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())