Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/actions/collect-artifacts/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: Collect Artifacts
description: Stage compiler outputs for one game.
inputs:
game: {required: true}
preset: {required: true}
runs:
using: composite
steps:
- shell: bash
run: |
base="build/${{ inputs.preset }}"
[[ '${{ inputs.preset }}' == win32* ]] && config=$([[ '${{ inputs.preset }}' == *debug* ]] && echo Debug || echo Release) || config=''
game='${{ inputs.game }}'
[[ $game == Generals || $game == GeneralsMD ]] || { echo "Unknown game: $game" >&2; exit 1; }
destination="$base/$game/artifacts"; mkdir -p "$destination"
sources=("$base/Core" "$base/$game"); [[ -n $config ]] && sources=("$base/Core/$config" "$base/$game/$config")
found=false
for source in "${sources[@]}"; do
for file in "$source"/*.{exe,dll,pdb}; do [[ -f $file ]] && cp -f "$file" "$destination/" && found=true; done
done
$found || { echo "No artifacts found for $game" >&2; exit 1; }
36 changes: 36 additions & 0 deletions .github/actions/configure-build/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Configure Build
description: Configure one game with the selected CMake preset.
inputs:
game: {required: true}
preset: {required: true}
tools: {required: true}
extras: {required: true}
ffmpeg: {required: true}
retail-compatible: {required: true}
runs:
using: composite
steps:
- shell: bash
run: |
onoff() { [[ "$1" == true ]] && echo ON || echo OFF; }
game='${{ inputs.game }}'; preset='${{ inputs.preset }}'
case "$game" in
Generals) zero_hour=false; generals=true ;;
GeneralsMD) zero_hour=true; generals=false ;;
*) echo "Unknown game: $game" >&2; exit 1 ;;
esac
flags=("-DRTS_BUILD_ZEROHOUR=$(onoff "$zero_hour")"
"-DRTS_BUILD_GENERALS=$(onoff "$generals")"
"-DRTS_BUILD_CORE_TOOLS=$(onoff '${{ inputs.tools }}')"
"-DRTS_BUILD_CORE_EXTRAS=$(onoff '${{ inputs.extras }}')"
"-DRTS_BUILD_OPTION_FFMPEG=$(onoff '${{ inputs.ffmpeg }}')")
[[ $game == Generals ]] && flags+=("-DRTS_BUILD_GENERALS_TOOLS=$(onoff '${{ inputs.tools }}')" "-DRTS_BUILD_GENERALS_EXTRAS=$(onoff '${{ inputs.extras }}')")
[[ $game == GeneralsMD ]] && flags+=("-DRTS_BUILD_ZEROHOUR_TOOLS=$(onoff '${{ inputs.tools }}')" "-DRTS_BUILD_ZEROHOUR_EXTRAS=$(onoff '${{ inputs.extras }}')")
case '${{ inputs.retail-compatible }}' in
retail) compatibility=ON ;;
noretail) compatibility=OFF ;;
*) echo 'Unknown compatibility mode' >&2; exit 1 ;;
esac
flags+=("-DRTS_BUILD_OPTION_RETAIL_COMPATIBLE_GAME=$compatibility")
[[ -n ${SCCACHE_PATH:-} ]] && flags+=("-DCMAKE_C_COMPILER_LAUNCHER=$SCCACHE_PATH" "-DCMAKE_CXX_COMPILER_LAUNCHER=$SCCACHE_PATH")
cmake --preset "$preset" "${flags[@]}"
13 changes: 13 additions & 0 deletions .github/actions/setup-sccache/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
name: Set Up sccache
description: Install the pinned sccache compiler wrapper and enable its GitHub Actions cache backend.
runs:
using: composite
steps:
- name: Install sccache
uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.17.0

- name: Enable GitHub Actions Cache Backend
shell: bash
run: echo 'SCCACHE_GHA_ENABLED=true' >> "$GITHUB_ENV"
26 changes: 26 additions & 0 deletions .github/actions/setup-vc6/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Set Up VC6
description: Restore or install the verified VC6 toolchain and export its environment.
runs:
using: composite
steps:
- name: Cache VC6 Installation
id: cache-vc6
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ${{ runner.tool_cache }}/MSVC600/001c4bafdcf2ef4b474d693acccd35a91e848f40
key: msvc600-permanent-cache-v2

- name: Install Verified VC6 Toolchain
if: ${{ steps.cache-vc6.outputs.cache-hit != 'true' }}
env:
VC6_SCRIPT: ${{ github.action_path }}/setup.py
VC6_ROOT: ${{ runner.tool_cache }}/MSVC600/001c4bafdcf2ef4b474d693acccd35a91e848f40
shell: bash
run: python "$VC6_SCRIPT" --install --root "$VC6_ROOT"

- name: Set Up VC6 Environment
env:
VC6_SCRIPT: ${{ github.action_path }}/setup.py
VC6_ROOT: ${{ runner.tool_cache }}/MSVC600/001c4bafdcf2ef4b474d693acccd35a91e848f40
shell: bash
run: python "$VC6_SCRIPT" --export-environment --root "$VC6_ROOT"
86 changes: 86 additions & 0 deletions .github/actions/setup-vc6/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Install the pinned portable VC6 toolchain and export its build environment."""

import argparse
import hashlib
import os
from pathlib import Path, PureWindowsPath
import shutil
import tempfile
import urllib.request
import zipfile


COMMIT = "001c4bafdcf2ef4b474d693acccd35a91e848f40"
EXPECTED_HASH = "D0EE1F6DCEF7DB3AD703120D9FB4FAD49EBCA28F44372E40550348B1C00CA583"
def environment(base, current=None):
"""Return VC6 variables, retaining the runner's existing search paths."""
current = {key.upper(): value for key, value in (os.environ if current is None else current).items()}
base = PureWindowsPath(base) / "VC6SP6"
common = base / "Common"
dev = common / "msdev98"
vc = base / "VC98"
return {
"VSCommonDir": str(common),
"MSDevDir": str(dev),
"MSVCDir": str(vc),
"VcOsDir": "WINNT",
"PATH": ";".join(map(str, [dev / "BIN", vc / "BIN", common / "TOOLS" / "WINNT", common / "TOOLS", current.get("PATH", "")])),
"INCLUDE": ";".join(map(str, [vc / "ATL" / "INCLUDE", vc / "INCLUDE", vc / "MFC" / "INCLUDE", current.get("INCLUDE", "")])),
"LIB": ";".join(map(str, [vc / "LIB", vc / "MFC" / "LIB", current.get("LIB", "")])),
}


def install(root):
"""Verify the download before extracting any files into the install root."""
root = Path(root).resolve()
target = root / "VC6SP6"
extracted = root / f"MSVC600-{COMMIT}"
if target.exists() or extracted.exists():
raise FileExistsError(f"Refusing to overwrite an existing VC6 installation in {root}")
with tempfile.NamedTemporaryFile(
suffix=".zip", delete=False, dir=os.environ.get("RUNNER_TEMP")) as temporary:
archive = Path(temporary.name)
try:
print("Downloading VC6 Portable Installation", flush=True)
urllib.request.urlretrieve(f"https://github.com/itsmattkc/MSVC600/archive/{COMMIT}.zip", archive)
digest = hashlib.sha256()
with archive.open("rb") as hash_source:
for chunk in iter(lambda: hash_source.read(1024 * 1024), b""):
digest.update(chunk)
actual_hash = digest.hexdigest().upper()
print(f"Downloaded file SHA256: {actual_hash}")
if actual_hash != EXPECTED_HASH:
raise ValueError(f"VC6 hash verification failed. Expected {EXPECTED_HASH}")
with zipfile.ZipFile(archive) as zip_source:
for member in zip_source.infolist():
name = PureWindowsPath(member.filename)
destination = (root / member.filename).resolve()
if (name.is_absolute() or name.drive or ".." in name.parts
or ":" in member.filename or not destination.is_relative_to(root)
or not name.parts or name.parts[0] != extracted.name):
raise ValueError(f"Unsafe VC6 archive path: {member.filename}")
zip_source.extractall(root)
shutil.move(str(extracted), str(target))
finally:
archive.unlink(missing_ok=True)


def main():
parser = argparse.ArgumentParser(description=__doc__)
operation = parser.add_mutually_exclusive_group(required=True)
operation.add_argument("--install", action="store_true")
operation.add_argument("--export-environment", action="store_true")
parser.add_argument("--root", type=Path, required=True)
args = parser.parse_args()
if args.install:
install(args.root)
else:
with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as output:
for key, value in environment(args.root).items():
if "\n" in value or "\r" in value:
raise ValueError(f"Invalid newline in environment variable {key}")
output.write(f"{key}={value}\n")


if __name__ == "__main__":
main()
58 changes: 58 additions & 0 deletions .github/actions/test-replays/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: Test Replays
description: Run GeneralsMD replay compatibility tests against isolated trimmed game data
inputs:
userdata:
description: Repository path containing Replays and Maps directories
required: true
build-artifacts-directory:
description: Directory containing the collected GeneralsMD build artifacts
required: true
runtime-directory:
description: Isolated directory in which to assemble and run the game
required: true
r2-access-key-id:
description: R2 access key used only on a game data cache miss
required: false
default: ''
r2-secret-access-key:
description: R2 secret key used only on a game data cache miss
required: false
default: ''
r2-endpoint-url:
description: R2 endpoint used only on a game data cache miss
required: false
default: ''
runs:
using: composite
steps:
- name: Cache Trimmed Game Data
id: cache-game-data
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ${{ runner.temp }}/generals-replay-data
key: gamedata-permanent-cache-v4

- name: Download Trimmed Game Data
if: steps.cache-game-data.outputs.cache-hit != 'true'
shell: bash
env:
REPLAY_DATA_SCRIPT: ${{ github.action_path }}/prepare.py
GAME_DATA_DIRECTORY: ${{ runner.temp }}/generals-replay-data
AWS_ACCESS_KEY_ID: ${{ inputs.r2-access-key-id }}
AWS_SECRET_ACCESS_KEY: ${{ inputs.r2-secret-access-key }}
AWS_ENDPOINT_URL: ${{ inputs.r2-endpoint-url }}
run: python "$REPLAY_DATA_SCRIPT" --game-data-directory "$GAME_DATA_DIRECTORY"

- name: Stage and Run Replay Compatibility Tests
shell: bash
env:
REPLAY_SCRIPT: ${{ github.action_path }}/run.py
USERDATA: ${{ inputs.userdata }}
BUILD_ARTIFACTS_DIRECTORY: ${{ inputs.build-artifacts-directory }}
RUNTIME_DIRECTORY: ${{ inputs.runtime-directory }}
GAME_DATA_DIRECTORY: ${{ runner.temp }}/generals-replay-data
run: |
python "$REPLAY_SCRIPT" --userdata "$USERDATA" \
--build-artifacts-directory "$BUILD_ARTIFACTS_DIRECTORY" \
--runtime-directory "$RUNTIME_DIRECTORY" \
--game-data-directory "$GAME_DATA_DIRECTORY"
50 changes: 50 additions & 0 deletions .github/actions/test-replays/prepare.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Download and verify trimmed game assets on a replay cache miss."""

import argparse
import hashlib
import os
from pathlib import Path
import subprocess
import tempfile


ARCHIVES = (
("Generals", "generals108_gamedata_trimmed.7z",
"37A351AA430199D1F05DEB9E404857DCE7B461A6AC272C5D4A0B5652CDB06372"),
("GeneralsMD", "zerohour104_gamedata_trimmed.7z",
"6837FE1E3009A4C239406C39B1598216C0943EE8ED46BB10626767029AC05E21"),
)


def prepare_game_data(game_data_directory):
if not all(os.environ.get(key) for key in (
"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_ENDPOINT_URL")):
raise RuntimeError("Game data cache is cold and required R2 credentials are unavailable. "
"Replay validation cannot run.")
game_data_directory = Path(game_data_directory)
with tempfile.TemporaryDirectory(prefix="replay-download-", dir=os.environ.get("RUNNER_TEMP")) as temporary:
for game, filename, expected_hash in ARCHIVES:
archive = Path(temporary) / filename
print(f"Downloading trimmed game data for {game}.")
subprocess.run(["aws", "s3", "cp", f"s3://github-ci/{filename}", str(archive),
"--endpoint-url", os.environ["AWS_ENDPOINT_URL"]], check=True)
digest = hashlib.sha256()
with archive.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
if digest.hexdigest().upper() != expected_hash:
raise RuntimeError(f"Hash verification failed for {game} game data")
destination = game_data_directory / game
destination.mkdir(parents=True, exist_ok=True)
subprocess.run(["7z", "x", str(archive), f"-o{destination}", "-y"], check=True)


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--game-data-directory", type=Path, required=True)
args = parser.parse_args()
prepare_game_data(args.game_data_directory)


if __name__ == "__main__":
main()
Loading
Loading