diff --git a/.gitea/workflows/dev-workshop.yml b/.gitea/workflows/dev-workshop.yml new file mode 100644 index 0000000..e25e6c6 --- /dev/null +++ b/.gitea/workflows/dev-workshop.yml @@ -0,0 +1,116 @@ +name: Deploy DEV Workshop + +on: + push: + branches: + - main + +env: + STEAM_APP_ID: "107410" + BRAF_CI_CACHE_DIR: .ci-cache/dev-workshop + BRAF_CI_STAGE_DIR: dist/dev/@braf_dev + +jobs: + deploy-dev-workshop: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 + lfs: true + + - name: Restore PBO cache + uses: actions/cache@v4 + with: + path: .ci-cache/dev-workshop + key: braf-dev-workshop-${{ github.sha }} + restore-keys: | + braf-dev-workshop- + + - name: Install Mikero tools + shell: bash + run: | + set -euo pipefail + + sudo apt-get update + sudo apt-get install -y libogg0 libuchardet0 libvorbis0a libvorbisenc2 libvorbisfile3 + + curl -fsSL \ + https://github.com/arma-actions/mikero-tools/archive/refs/heads/master.tar.gz \ + -o /tmp/mikero-tools-action.tar.gz + + rm -rf /tmp/mikero-tools-action /opt/mikero-tools + mkdir -p /tmp/mikero-tools-action /opt/mikero-tools + tar -zxf /tmp/mikero-tools-action.tar.gz \ + --strip-components=1 \ + -C /tmp/mikero-tools-action + + tar -xf /tmp/mikero-tools-action/linux/*.tar \ + --strip-components=1 \ + -C /opt/mikero-tools + + echo "/opt/mikero-tools/bin" >> "$GITHUB_PATH" + echo "LD_LIBRARY_PATH=${LD_LIBRARY_PATH:-}:/opt/mikero-tools/lib" >> "$GITHUB_ENV" + + - name: Detect global build changes + id: changes + shell: bash + run: | + set -euo pipefail + + before="${{ github.event.before }}" + after="${{ github.sha }}" + + if [[ -z "${before}" || "${before}" == "0000000000000000000000000000000000000000" ]]; then + echo "force_rebuild=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + changed="$(git diff --name-only "${before}" "${after}" || printf '%s\n' '__force_rebuild__')" + printf '%s\n' "${changed}" + + if printf '%s\n' "${changed}" | grep -Eq '^(__force_rebuild__|\.gitea/workflows/|make/|mod\.cpp$|meta\.cpp$)'; then + echo "force_rebuild=true" >> "$GITHUB_OUTPUT" + else + echo "force_rebuild=false" >> "$GITHUB_OUTPUT" + fi + + - name: Build and stage DEV package + shell: bash + run: | + set -euo pipefail + + mkdir -p "$BRAF_CI_CACHE_DIR" + + args=( + --cache-dir "$BRAF_CI_CACHE_DIR" + --stage-dir "$BRAF_CI_STAGE_DIR" + ) + + if [[ "${{ steps.changes.outputs.force_rebuild }}" == "true" ]]; then + args+=(--force-rebuild) + fi + + python3 -m make.ci_dev_workshop "${args[@]}" + + - name: Prepare changelog + id: changelog + shell: bash + run: | + set -euo pipefail + short_sha="$(git rev-parse --short HEAD)" + subject="$(git log -1 --pretty=%s)" + echo "text=DEV ${short_sha}: ${subject}" >> "$GITHUB_OUTPUT" + + - name: Upload DEV Workshop item + uses: https://github.com/arma-actions/workshop-upload@v1 + env: + STEAM_USERNAME: ${{ secrets.STEAM_USERNAME }} + STEAM_PASSWORD: ${{ secrets.STEAM_PASSWORD }} + with: + appId: ${{ env.STEAM_APP_ID }} + itemId: ${{ secrets.STEAM_DEV_ITEM_ID }} + contentPath: ${{ env.BRAF_CI_STAGE_DIR }} + changelog: ${{ steps.changelog.outputs.text }} diff --git a/.gitignore b/.gitignore index bbd8ae0..b06c75b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,6 @@ braf_factions/ __pycache__ env* -braf_sar/.vscode/c_cpp_properties.json -braf_sar/.vscode/launch.json -braf_sar/.vscode/settings.json +*.vscode* +dist/ +.ci-cache/ diff --git a/make/ci_dev_workshop.py b/make/ci_dev_workshop.py new file mode 100644 index 0000000..bb5f9f8 --- /dev/null +++ b/make/ci_dev_workshop.py @@ -0,0 +1,349 @@ +#!/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/. 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()) diff --git a/meta.cpp b/meta.cpp new file mode 100644 index 0000000..f7d3438 --- /dev/null +++ b/meta.cpp @@ -0,0 +1,3 @@ +protocol = 1; +name = "Brazilian Armed Forces (BRAF) Mod"; +author = "BRAF Team"; diff --git a/mod.cpp b/mod.cpp new file mode 100644 index 0000000..3897a36 --- /dev/null +++ b/mod.cpp @@ -0,0 +1,12 @@ +name = "Brazilian Armed Forces (BRAF) Mod"; +author = "BRAF Team"; +tooltip = "Brazilian Armed Forces (BRAF) Mod"; +tooltipOwned = "Brazilian Armed Forces (BRAF) Mod"; +overview = "Modificacao para Arma 3 inspirada nas Forcas Armadas Brasileiras."; +actionName = "GitHub"; +action = ""; +picture = ""; +logo = ""; +logoOver = ""; +hideName = 0; +hidePicture = 0;