Add DEV workshop deployment workflow
Deploy DEV Workshop / deploy-dev-workshop (push) Failing after 1h15m47s

This commit is contained in:
2026-07-14 12:44:06 -03:00
parent c2abf1d936
commit 1ddc33f4a9
5 changed files with 483 additions and 3 deletions
+116
View File
@@ -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 }}
+3 -3
View File
@@ -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/
+349
View File
@@ -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/<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())
+3
View File
@@ -0,0 +1,3 @@
protocol = 1;
name = "Brazilian Armed Forces (BRAF) Mod";
author = "BRAF Team";
+12
View File
@@ -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;