350 lines
11 KiB
Python
350 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Build and stage the BRAF DEV Steam Workshop package.
|
|
|
|
The script keeps a persistent addon cache outside the checkout. Each run hashes
|
|
root-level braf_* addon folders, rebuilds only changed or missing PBOs with
|
|
Mikero pboProject, then stages a complete Arma 3 mod folder for upload.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from tempfile import TemporaryDirectory
|
|
from typing import Any
|
|
|
|
try:
|
|
from make.obsfuschatedList import obfuschatedList
|
|
except ModuleNotFoundError:
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
from make.obsfuschatedList import obfuschatedList
|
|
|
|
|
|
MANIFEST_VERSION = 1
|
|
DEFAULT_STAGE_DIR = Path("dist/dev/@braf_dev")
|
|
METADATA_FILES = ("mod.cpp", "meta.cpp")
|
|
MOD_ASSET_FIELDS = ("picture", "logo", "logoOver", "logoSmall")
|
|
IGNORED_DIRS = {".git", "__pycache__"}
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Build changed BRAF addons and stage the DEV workshop folder."
|
|
)
|
|
parser.add_argument(
|
|
"--cache-dir",
|
|
default=os.getenv("BRAF_CI_CACHE_DIR"),
|
|
help="Persistent cache directory. Defaults to BRAF_CI_CACHE_DIR.",
|
|
)
|
|
parser.add_argument(
|
|
"--stage-dir",
|
|
default=os.getenv("BRAF_CI_STAGE_DIR", str(DEFAULT_STAGE_DIR)),
|
|
help=f"Output mod folder. Defaults to {DEFAULT_STAGE_DIR}.",
|
|
)
|
|
parser.add_argument(
|
|
"--source-root",
|
|
default=os.getenv("BRAF_MIKERO_SOURCE_ROOT"),
|
|
help="Root passed to pboProject. Defaults to the repository checkout.",
|
|
)
|
|
parser.add_argument(
|
|
"--force-rebuild",
|
|
action="store_true",
|
|
help="Rebuild every buildable addon, ignoring matching cache hashes.",
|
|
)
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="Print planned work without running pboProject or writing cache/stage output.",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def repo_root() -> Path:
|
|
return Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def buildable_addons(root: Path) -> list[str]:
|
|
addons: list[str] = []
|
|
for path in sorted(root.glob("braf_*")):
|
|
if path.is_dir() and (path / "config.cpp").is_file():
|
|
addons.append(path.name)
|
|
return addons
|
|
|
|
|
|
def file_digest(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def addon_digest(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
for file_path in sorted(p for p in path.rglob("*") if p.is_file()):
|
|
if any(part in IGNORED_DIRS for part in file_path.relative_to(path).parts):
|
|
continue
|
|
rel = file_path.relative_to(path).as_posix()
|
|
digest.update(rel.encode("utf-8"))
|
|
digest.update(b"\0")
|
|
digest.update(file_digest(file_path).encode("ascii"))
|
|
digest.update(b"\0")
|
|
return digest.hexdigest()
|
|
|
|
|
|
def load_manifest(path: Path) -> dict[str, Any]:
|
|
if not path.is_file():
|
|
return {"version": MANIFEST_VERSION, "addons": {}}
|
|
|
|
with path.open("r", encoding="utf-8") as handle:
|
|
data = json.load(handle)
|
|
|
|
if data.get("version") != MANIFEST_VERSION or not isinstance(data.get("addons"), dict):
|
|
return {"version": MANIFEST_VERSION, "addons": {}}
|
|
|
|
return data
|
|
|
|
|
|
def write_manifest(path: Path, manifest: dict[str, Any]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = path.with_suffix(".tmp")
|
|
with tmp.open("w", encoding="utf-8") as handle:
|
|
json.dump(manifest, handle, indent=2, sort_keys=True)
|
|
handle.write("\n")
|
|
tmp.replace(path)
|
|
|
|
|
|
def cached_pbo_exists(cache_addons_dir: Path, addon: str) -> bool:
|
|
return (cache_addons_dir / f"{addon}.pbo").is_file()
|
|
|
|
|
|
def addon_cache_files(cache_addons_dir: Path, addon: str) -> list[Path]:
|
|
patterns = (f"{addon}.pbo", f"{addon}.pbo.*.bisign")
|
|
files: list[Path] = []
|
|
for pattern in patterns:
|
|
files.extend(sorted(cache_addons_dir.glob(pattern)))
|
|
return files
|
|
|
|
|
|
def remove_cached_addon(cache_addons_dir: Path, addon: str) -> None:
|
|
for path in addon_cache_files(cache_addons_dir, addon):
|
|
path.unlink()
|
|
|
|
|
|
def prepare_mikero_source_root(root: Path, cache_dir: Path, addons: list[str]) -> Path:
|
|
"""Create a P:/braf-like source root using symlinks.
|
|
|
|
The historic local build path was P:/braf/<addon>. Keeping the parent folder
|
|
named "braf" avoids changing Mikero's inferred addon prefix in CI.
|
|
"""
|
|
source_root = cache_dir / "mikero-source" / "braf"
|
|
source_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
wanted = set(addons)
|
|
for child in source_root.iterdir():
|
|
if child.name not in wanted:
|
|
if child.is_symlink() or child.is_file():
|
|
child.unlink()
|
|
elif child.is_dir():
|
|
shutil.rmtree(child)
|
|
|
|
for addon in addons:
|
|
dst = source_root / addon
|
|
src = root / addon
|
|
if dst.is_symlink() and dst.resolve() == src.resolve():
|
|
continue
|
|
if dst.exists() or dst.is_symlink():
|
|
if dst.is_dir() and not dst.is_symlink():
|
|
shutil.rmtree(dst)
|
|
else:
|
|
dst.unlink()
|
|
dst.symlink_to(src, target_is_directory=True)
|
|
|
|
return source_root
|
|
|
|
|
|
def run_pboproject(addon: str, source_root: Path, output_dir: Path) -> None:
|
|
addon_source = source_root / addon
|
|
if not addon_source.is_dir():
|
|
raise RuntimeError(f"Mikero source directory not found: {addon_source}")
|
|
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
pboproject = shutil.which("pboproject") or shutil.which("pboProject")
|
|
makepbo = shutil.which("makepbo") or shutil.which("MakePbo")
|
|
|
|
if pboproject:
|
|
cmd = [
|
|
pboproject,
|
|
"-P",
|
|
str(addon_source),
|
|
f"+M={output_dir}",
|
|
"+O" if addon in obfuschatedList else "-O",
|
|
]
|
|
elif makepbo:
|
|
cmd = [
|
|
makepbo,
|
|
"-P",
|
|
f"+@=braf\\{addon}",
|
|
str(addon_source),
|
|
str(output_dir / f"{addon}.pbo"),
|
|
]
|
|
else:
|
|
raise RuntimeError("Neither pboProject nor makepbo was found in PATH")
|
|
|
|
print(f"::group::Building {addon}")
|
|
print(" ".join(cmd))
|
|
try:
|
|
subprocess.run(cmd, check=True)
|
|
finally:
|
|
print("::endgroup::")
|
|
|
|
|
|
def promote_build_output(addon: str, build_dir: Path, cache_addons_dir: Path) -> None:
|
|
pbo = build_dir / f"{addon}.pbo"
|
|
if not pbo.is_file():
|
|
candidates = sorted(build_dir.glob("*.pbo"))
|
|
candidate_names = ", ".join(path.name for path in candidates) or "none"
|
|
raise RuntimeError(f"Expected {pbo.name} from pboProject; found: {candidate_names}")
|
|
|
|
cache_addons_dir.mkdir(parents=True, exist_ok=True)
|
|
remove_cached_addon(cache_addons_dir, addon)
|
|
|
|
for path in sorted(build_dir.glob(f"{addon}.pbo*")):
|
|
if path.is_file():
|
|
shutil.copy2(path, cache_addons_dir / path.name)
|
|
|
|
|
|
def parse_mod_assets(mod_cpp: Path) -> list[Path]:
|
|
if not mod_cpp.is_file():
|
|
return []
|
|
|
|
text = mod_cpp.read_text(encoding="utf-8", errors="ignore")
|
|
assets: list[Path] = []
|
|
for field in MOD_ASSET_FIELDS:
|
|
pattern = re.compile(rf"\b{re.escape(field)}\s*=\s*\"([^\"]*)\"", re.IGNORECASE)
|
|
for match in pattern.finditer(text):
|
|
value = match.group(1).strip()
|
|
if value:
|
|
assets.append(Path(value.replace("\\", "/")))
|
|
return assets
|
|
|
|
|
|
def copy_metadata(root: Path, stage_dir: Path) -> None:
|
|
for name in METADATA_FILES:
|
|
src = root / name
|
|
if not src.is_file():
|
|
raise RuntimeError(f"Required metadata file is missing: {name}")
|
|
shutil.copy2(src, stage_dir / name)
|
|
|
|
for rel_asset in parse_mod_assets(root / "mod.cpp"):
|
|
if rel_asset.is_absolute() or ".." in rel_asset.parts:
|
|
raise RuntimeError(f"Unsupported mod.cpp asset path: {rel_asset}")
|
|
src = root / rel_asset
|
|
if not src.is_file():
|
|
raise RuntimeError(f"mod.cpp references missing asset: {rel_asset}")
|
|
dst = stage_dir / rel_asset
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(src, dst)
|
|
|
|
|
|
def stage_package(
|
|
root: Path,
|
|
stage_dir: Path,
|
|
cache_addons_dir: Path,
|
|
addons: list[str],
|
|
) -> None:
|
|
if stage_dir.exists():
|
|
shutil.rmtree(stage_dir)
|
|
addons_dir = stage_dir / "addons"
|
|
addons_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
copy_metadata(root, stage_dir)
|
|
|
|
for addon in addons:
|
|
files = addon_cache_files(cache_addons_dir, addon)
|
|
if not any(path.name == f"{addon}.pbo" for path in files):
|
|
raise RuntimeError(f"Missing cached PBO for {addon}")
|
|
for path in files:
|
|
shutil.copy2(path, addons_dir / path.name)
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
root = repo_root()
|
|
|
|
if not args.cache_dir:
|
|
print("ERROR: --cache-dir or BRAF_CI_CACHE_DIR is required", file=sys.stderr)
|
|
return 2
|
|
|
|
cache_dir = Path(args.cache_dir).resolve()
|
|
cache_addons_dir = cache_dir / "addons"
|
|
manifest_path = cache_dir / "manifest.json"
|
|
stage_dir = (root / args.stage_dir).resolve()
|
|
|
|
addons = buildable_addons(root)
|
|
if args.source_root:
|
|
source_root = Path(args.source_root).resolve()
|
|
elif args.dry_run:
|
|
source_root = (cache_dir / "mikero-source" / "braf").resolve()
|
|
else:
|
|
source_root = prepare_mikero_source_root(root, cache_dir, addons).resolve()
|
|
|
|
current_hashes = {addon: addon_digest(root / addon) for addon in addons}
|
|
manifest = load_manifest(manifest_path)
|
|
manifest_addons = manifest.setdefault("addons", {})
|
|
|
|
removed_addons = sorted(set(manifest_addons) - set(addons))
|
|
for addon in removed_addons:
|
|
print(f"Removing stale addon from cache manifest: {addon}")
|
|
if not args.dry_run:
|
|
remove_cached_addon(cache_addons_dir, addon)
|
|
manifest_addons.pop(addon, None)
|
|
|
|
to_build: list[str] = []
|
|
for addon in addons:
|
|
cached_hash = manifest_addons.get(addon, {}).get("hash")
|
|
if (
|
|
args.force_rebuild
|
|
or cached_hash != current_hashes[addon]
|
|
or not cached_pbo_exists(cache_addons_dir, addon)
|
|
):
|
|
to_build.append(addon)
|
|
|
|
print(f"Buildable addons: {len(addons)}")
|
|
print(f"Addons to build: {', '.join(to_build) if to_build else 'none'}")
|
|
print(f"Cache dir: {cache_dir}")
|
|
print(f"Stage dir: {stage_dir}")
|
|
print(f"Mikero source root: {source_root}")
|
|
|
|
if args.dry_run:
|
|
return 0
|
|
|
|
for addon in to_build:
|
|
with TemporaryDirectory(prefix=f"{addon}-", dir=str(cache_dir)) as tmp:
|
|
build_dir = Path(tmp)
|
|
run_pboproject(addon, source_root, build_dir)
|
|
promote_build_output(addon, build_dir, cache_addons_dir)
|
|
manifest_addons[addon] = {"hash": current_hashes[addon]}
|
|
write_manifest(manifest_path, manifest)
|
|
|
|
for addon in addons:
|
|
if not cached_pbo_exists(cache_addons_dir, addon):
|
|
raise RuntimeError(f"Cache does not contain required PBO after build: {addon}")
|
|
|
|
stage_package(root, stage_dir, cache_addons_dir, addons)
|
|
write_manifest(manifest_path, manifest)
|
|
|
|
print(f"Staged {len(addons)} addons at {stage_dir}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|