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.
This commit is contained in:
+172
@@ -0,0 +1,172 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user