4293e90a97
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.
173 lines
6.2 KiB
Python
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())
|