Skip to content

fix(coop): serve the client on a mid-battle campaign resume #97

fix(coop): serve the client on a mid-battle campaign resume

fix(coop): serve the client on a mid-battle campaign resume #97

Workflow file for this run

name: CI validate (build + coop tests, no publish)
# PR merge gate on FREE GitHub-hosted runners. Every PR into main is proven on a
# clean build of all three shipped platforms + the coop test suite before it can
# merge. Required status checks in main's branch protection: build, coop-tests,
# test-gate, build-winxp, build-linux.
#
# build-winxp/build-linux mirror the jobs of the same name in ci-main.yml so a PR
# cannot land code that only compiles under MSVC - previously those two only ran
# after merge, and a break was found on main (with the nightly already broken)
# instead of on the PR. They differ from ci-main's copies in three ways, all
# deliberate: no version stamp (nothing is published), a placeholder
# rendezvous.json instead of the RELEASE_RENDEZVOUS_JSON secret (fork-authored
# code must never run with a real secret in its env), and the packaging stops at
# the asserted staged tree - no archive, no upload.
#
# Runs on pull_request_target (NOT pull_request) so the UFO_DATA_DEPLOY_KEY secret
# is available to fork PRs too - GitHub withholds all secrets from fork
# `pull_request` runs, which used to force fork PRs to be build-only.
# pull_request_target is safe here because:
# - the workflow definition always comes from the BASE repo, so a fork cannot
# edit this gate - only the code it exercises
# - GITHUB_TOKEN is contents:read everywhere (test-gate gets pull-requests:write
# only, to revoke labels)
# - the deploy key is used only in coop-tests, which runs only for PRs authored
# by repo owners/members/collaborators, or on the `labeled` event when a
# maintainer applies the `safe-to-test` label to an outside PR after reviewing
# its diff (review before labeling: PR test code runs while UFO data is on disk)
# - a new push to an outside PR revokes `safe-to-test` (test-gate job) and fails
# the gate, so every push must be re-reviewed + re-labeled before it can run
# with the secret - no test run ever uses a label granted to an older SHA
# - persist-credentials: false on the UFO_Data checkout, so PR-supplied test
# code cannot read the deploy key off disk after the fetch
on:
pull_request_target:
branches: [main]
types: [opened, synchronize, reopened, labeled]
workflow_dispatch:
# Hosted runs are isolated VMs, so per-PR concurrency + cancel-in-progress is safe
# and cheap (supersede stale gate runs when a PR gets new commits).
concurrency:
group: ci-validate-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions: { contents: read }
jobs:
build:
runs-on: windows-latest
timeout-minutes: 75
steps:
- uses: actions/checkout@v4
with:
# pull_request_target defaults to the BASE branch; explicitly build the
# PR head. Pinned to the event's SHA so labeled runs test exactly the
# commit the maintainer reviewed. Falls back to github.sha for dispatch.
ref: ${{ github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- uses: microsoft/setup-msbuild@v2
- name: Build x64 Release (serial)
timeout-minutes: 60
# Serial: /m can OOM the template-heavy TUs (C1060). Unique _MSPDBSRV_ENDPOINT_
# avoids the per-user mspdbsrv PDB-server collision (C1090 / D8040) if a runner
# is ever shared with another build.
env:
_MSPDBSRV_ENDPOINT_: ci_${{ github.run_id }}_${{ github.run_attempt }}
shell: cmd
working-directory: src
run: msbuild OpenXcom.2010.sln /v:minimal /p:Configuration=Release /p:Platform=x64
- uses: actions/upload-artifact@v4
with:
name: game-exe
path: bin/x64/Release/OpenXcom.exe
if-no-files-found: error
retention-days: 7
# Required check that makes the fork-PR policy ENFORCEABLE: outside PRs stay red
# until a maintainer reviews the diff and applies `safe-to-test` (which triggers
# the real coop-tests run). Without this job, a skipped coop-tests would satisfy
# branch protection and an unreviewed fork PR would be mergeable without tests.
test-gate:
if: github.event_name == 'pull_request_target'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
pull-requests: write # revoke stale safe-to-test labels
issues: write # labels ride the issues API
steps:
- name: Enforce coop-test policy for outside PRs
env:
GH_TOKEN: ${{ github.token }}
ASSOC: ${{ github.event.pull_request.author_association }}
ACTION: ${{ github.event.action }}
LABELED_NAME: ${{ github.event.label.name }}
HAS_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'safe-to-test') }}
PR: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
case "$ASSOC" in
OWNER|MEMBER|COLLABORATOR) echo "trusted author ($ASSOC); coop-tests run automatically"; exit 0;;
esac
if [ "$ACTION" = "labeled" ] && [ "$LABELED_NAME" = "safe-to-test" ]; then
echo "maintainer applied safe-to-test; coop-tests run for this SHA"; exit 0
fi
if [ "$ACTION" = "synchronize" ] && [ "$HAS_LABEL" = "true" ]; then
gh pr edit "$PR" --repo "$REPO" --remove-label safe-to-test
echo "new push revoked safe-to-test; the new diff needs re-review"
fi
echo "::error::Outside PR: a maintainer must review the diff and apply the 'safe-to-test' label to run the coop test gate."
exit 1
# Splits the suite into 4 runtime-balanced shards, ONCE, from the previous run's
# measured durations (Actions cache, falling back to main's cache and then to the
# checked-in tools/ci/test_weights.json). Every shard consumes this one plan, so a
# cache refresh mid-run cannot make two shards disagree about who runs what.
# No secrets here, so it needs no author gating - it only reads the PR's test list.
shard-plan:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- uses: actions/cache/restore@v4
with:
path: .ci_timings
key: coop-timings-${{ github.run_id }} # never hits; restore-keys does the work
restore-keys: coop-timings- # newest cache from this branch, else main
- shell: pwsh
run: ./tools/ci/plan_shards.ps1 -Of 4 -Timings .ci_timings/timings.json -Out shard-plan.json
- uses: actions/upload-artifact@v4
with: { name: shard-plan, path: shard-plan.json, if-no-files-found: error, retention-days: 7 }
coop-shard:
needs: [build, shard-plan]
# Trusted authors always; outside PRs only on the explicit `labeled` event so a
# label granted to an earlier SHA never authorizes a newer, unreviewed push
# (test-gate revokes the label on synchronize, but this run's event payload
# would still show it - hence gating on the event, not the label's presence).
if: >-
github.event_name == 'workflow_dispatch' ||
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) ||
(github.event.action == 'labeled' && github.event.label.name == 'safe-to-test')
runs-on: windows-latest
timeout-minutes: 30
strategy:
fail-fast: false # one shard's failure must not hide the others' results
matrix: { shard: [1, 2, 3, 4] }
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- uses: actions/download-artifact@v4
with:
name: game-exe
path: bin/x64/Release
- uses: actions/download-artifact@v4
with: { name: shard-plan }
- name: Fetch UFO data (private, licensed)
uses: actions/checkout@v4
with:
repository: OpenXcom-Coop/UFO_Data
ssh-key: ${{ secrets.UFO_DATA_DEPLOY_KEY }}
path: .ufo_data
persist-credentials: false
- name: Stage data next to exe
shell: pwsh
run: ./tools/ci/stage_data.ps1
- name: Coop test suite (headless, shard ${{ matrix.shard }}/4)
timeout-minutes: 25
shell: pwsh
env:
SDL_VIDEODRIVER: dummy
SDL_AUDIODRIVER: dummy
run: >-
./tools/ci/run_coop_suite.ps1 -PlanFile shard-plan.json
-Shard ${{ matrix.shard }} -TimingsOut timings-${{ matrix.shard }}.json
- uses: actions/upload-artifact@v4
if: always() # a failed shard still measured everything before it
with:
name: timings-${{ matrix.shard }}
path: timings-${{ matrix.shard }}.json
if-no-files-found: ignore
retention-days: 7
# Merge gate + aggregation. Keeps the name `coop-tests` because it is a required
# check in main's branch protection - a matrix job would publish as
# "coop-shard (1)" and the required check would never report. Skipped shards
# (outside PR without safe-to-test) stay non-failing here, exactly as before:
# test-gate is what keeps such a PR red.
coop-tests:
needs: coop-shard
if: always()
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Gate on every shard
if: needs.coop-shard.result == 'failure' || needs.coop-shard.result == 'cancelled'
run: |
echo "::error::coop-shard: ${{ needs.coop-shard.result }}"
exit 1
- uses: actions/checkout@v4
if: needs.coop-shard.result == 'success'
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- uses: actions/download-artifact@v4
if: needs.coop-shard.result == 'success'
with: { pattern: timings-*, path: .ci_new, merge-multiple: false }
continue-on-error: true
- uses: actions/cache/restore@v4
if: needs.coop-shard.result == 'success'
with:
path: .ci_timings
key: coop-timings-${{ github.run_id }}
restore-keys: coop-timings-
continue-on-error: true
- name: Merge shard timings for the next run's plan
if: needs.coop-shard.result == 'success'
shell: pwsh
continue-on-error: true # balance is an optimization; never fail the gate on it
run: |
New-Item -ItemType Directory -Force .ci_timings | Out-Null
./tools/ci/merge_timings.ps1 -In .ci_new -Prev .ci_timings/timings.json -Out .ci_timings/timings.json
- uses: actions/cache/save@v4
if: needs.coop-shard.result == 'success'
continue-on-error: true
with:
path: .ci_timings
key: coop-timings-${{ github.run_id }}
# 32-bit static WinXP binary, MinGW cross-compiled via MXE. Needs no artifact from
# any other job, so it runs alongside build + coop-tests and adds no wall-clock to
# the gate as long as the toolchain cache hits.
build-winxp:
runs-on: ubuntu-latest
container: ubuntu:20.04
# 120 covers a cached run many times over; the headroom is for the cold-cache
# case, where MXE has to build gcc itself (~2h).
timeout-minutes: 150
defaults:
run:
shell: bash
steps:
- name: Install build tools + MXE deps
# Retried: hosted runners share an egress to archive.ubuntu.com and
# intermittently get "Connection failed" part-way through the fetch (this is
# what broke nightly run 29942028942). Must run BEFORE checkout - the
# container has no git, which `submodules: recursive` needs.
env:
DEBIAN_FRONTEND: noninteractive
run: |
set -eu
apt_get() { apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 "$@"; }
ok=0
for n in 1 2 3 4; do
if apt_get update && apt_get install -y \
git make g++ build-essential \
autopoint gperf intltool libtool-bin lzip zip \
ca-certificates wget xz-utils bzip2 \
file patch perl pkg-config curl \
python2-minimal \
p7zip-full bison flex ruby \
gdk-pixbuf2.0-bin \
libssl-dev libcurl4-openssl-dev zlib1g-dev libexpat1-dev libncurses5-dev
then ok=1; break; fi
echo "apt attempt $n failed (mirror flake); retrying in $((n * 15))s"
sleep $((n * 15))
done
[ "$ok" = 1 ] || { echo "::error::apt-get failed after 4 attempts"; exit 1; }
ln -sf /usr/bin/python2.7 /usr/bin/python || true
ln -sf /usr/bin/python2.7 /usr/bin/python2 || true
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
submodules: recursive
# restore-only, deliberately: cache/save from a pull_request_target run writes
# to the BASE repo's cache scope, so a fork PR could otherwise hand main's
# release build a toolchain it authored. ci-main.yml owns the write; a cold
# cache here just means one slow gate run until main repopulates it.
- name: Restore MXE toolchain
id: cache-mxe
uses: actions/cache/restore@v4
with:
path: mxe
key: Linux-mxe-v7e39b555dee5e906b24a44940055dc28d4056d23-gcc11-jsoncpp-sdl_net-libsodium-v8
- name: Build MXE toolchain (only on cache miss)
if: steps.cache-mxe.outputs.cache-hit != 'true'
run: |
set -eux
# Keep in sync with ci-main.yml's build-winxp.
if [ ! -d mxe ]; then git clone https://github.com/mxe/mxe.git mxe; fi
cd mxe
git checkout 7e39b555dee5e906b24a44940055dc28d4056d23
# keep mingw-w64 VERSION + CHECKSUM in sync
sed -i 's/$(PKG)_VERSION := 8\.0\.2/$(PKG)_VERSION := 8.0.3/' src/mingw-w64.mk
sed -i 's/$(PKG)_CHECKSUM := f00cf1e2f975008855c93b9e0f8ec97decbf50f478e7ae4c7b5ffb9cdc54b743/$(PKG)_CHECKSUM := d702ef712c8f22035f81ddf6089542adf6bcb6ea669d4ebf4e311f87242882f0/' src/mingw-w64.mk
export MXE_PLUGIN_DIRS="$PWD/plugins/gcc11"
make MXE_TARGETS=i686-w64-mingw32.static JOBS=$(nproc) \
gcc cmake \
sdl sdl_net sdl_gfx sdl_mixer sdl_image \
yaml-cpp jsoncpp libsodium
- name: Configure + build (WinXP i686 static)
run: |
set -euxo pipefail
export PATH="$GITHUB_WORKSPACE/mxe/usr/bin:$PATH"
export MXE_PREFIX="$GITHUB_WORKSPACE/mxe/usr/i686-w64-mingw32.static"
export PKG_CONFIG_PATH="$MXE_PREFIX/lib/pkgconfig"
export PKG_CONFIG_LIBDIR="$MXE_PREFIX/lib/pkgconfig"
rm -rf build && mkdir -p build && cd build
i686-w64-mingw32.static-cmake \
-DCMAKE_BUILD_TYPE=Release \
-DDEV_BUILD=OFF \
-DBUILD_PACKAGE=OFF \
-DCMAKE_PREFIX_PATH="$MXE_PREFIX" \
-DDEPS_DIR= \
"$GITHUB_WORKSPACE"
make -j"$(nproc)"
- name: Assert the package tree (no secret, no archive)
run: |
set -euxo pipefail
# The real rendezvous.json comes from a secret at publish time; a PR only
# needs to prove the tree is shippable, so stand in the CI blackhole config.
cp tools/coop_test/rendezvous.ci.json build/bin/rendezvous.json
cp LICENSE.txt build/bin/
sh tools/ci/assert_package_dir.sh build/bin
# Native Linux (x86_64) build via GCC - the other platform ci-main.yml ships.
build-linux:
runs-on: ubuntu-latest
# Mirrors ci-main's build-linux: debian:11 (glibc 2.31) + jsoncpp 1.9.5 built
# from source, so the AppImage the gate builds matches the release and compiles
# - see the ci-main comment for the jsoncpp/glibc reasoning.
container: debian:11
timeout-minutes: 60
defaults:
run:
shell: bash
steps:
- name: Install deps
# Same mirror-flake retry as build-winxp - see the comment there. No sudo
# (root in the container); git must land BEFORE checkout, which the
# `submodules: recursive` step needs and the bare container lacks.
env:
DEBIAN_FRONTEND: noninteractive
run: |
set -eu
apt_get() { apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 "$@"; }
ok=0
for n in 1 2 3 4; do
if apt_get update && apt_get install -y \
git ca-certificates wget file pkg-config python3 \
cmake g++ make patchelf \
libsdl1.2-dev libsdl-mixer1.2-dev libsdl-gfx1.2-dev libsdl-image1.2-dev libsdl-net1.2-dev \
libyaml-cpp-dev libsodium-dev zlib1g-dev
then ok=1; break; fi
echo "apt attempt $n failed (mirror flake); retrying in $((n * 15))s"
sleep $((n * 15))
done
[ "$ok" = 1 ] || { echo "::error::apt-get failed after 4 attempts"; exit 1; }
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
submodules: recursive
- name: Build jsoncpp 1.9.5 from source
# bullseye's jsoncpp (1.9.4) makes the coop code's int64/uint64 -> Json::Value
# writes ambiguous on gcc; 1.9.5 does not. Build the same jsoncpp the code is
# built against on ubuntu-latest, into /usr/local. See ci-main for detail.
run: |
set -euxo pipefail
git clone --depth 1 --branch 1.9.5 https://github.com/open-source-parsers/jsoncpp.git /tmp/jsoncpp
cmake -S /tmp/jsoncpp -B /tmp/jsoncpp/build \
-DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=ON \
-DJSONCPP_WITH_TESTS=OFF -DJSONCPP_WITH_POST_BUILD_UNITTEST=OFF \
-DCMAKE_INSTALL_PREFIX=/usr/local
cmake --build /tmp/jsoncpp/build -j"$(nproc)"
cmake --install /tmp/jsoncpp/build
ldconfig
- name: Configure + build (Linux x86_64)
run: |
set -euxo pipefail
# Prefer the from-source jsoncpp in /usr/local (its .pc for -I, its lib for -l).
export PKG_CONFIG_PATH="/usr/local/lib/pkgconfig:${PKG_CONFIG_PATH:-}"
export LIBRARY_PATH="/usr/local/lib:${LIBRARY_PATH:-}"
JSONCPP_CFLAGS=$(pkg-config --cflags jsoncpp)
rm -rf build && mkdir -p build && cd build
# -pthread: coop code uses std::threads but CMake never links libpthread;
# separate lib on glibc 2.31 (debian:11), so force it. See ci-main.
cmake -DCMAKE_BUILD_TYPE=Release -DDEV_BUILD=OFF -DBUILD_PACKAGE=OFF \
-DCMAKE_CXX_FLAGS="$JSONCPP_CFLAGS -pthread" \
-DCMAKE_EXE_LINKER_FLAGS="-pthread" "$GITHUB_WORKSPACE"
make -j"$(nproc)"
- name: Build AppImage (linuxdeploy)
# Same as ci-main's step - proves the AppImage packaging on the PR, even
# though this gate uploads nothing (it stops at the asserted tree below).
run: |
set -euxo pipefail
export APPIMAGE_EXTRACT_AND_RUN=1
export PATH="$PWD:$PATH"
# so linuxdeploy's ldd resolves the from-source jsoncpp for bundling
export LD_LIBRARY_PATH="/usr/local/lib:${LD_LIBRARY_PATH:-}"
base=https://github.com/linuxdeploy
wget -q "$base/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
wget -q "$base/linuxdeploy-plugin-appimage/releases/download/continuous/linuxdeploy-plugin-appimage-x86_64.AppImage"
chmod +x linuxdeploy-x86_64.AppImage linuxdeploy-plugin-appimage-x86_64.AppImage
cp res/linux/icons/openxcom_128x128.png openxcom.png
OUTPUT=OpenXcoop-x86_64.AppImage \
./linuxdeploy-x86_64.AppImage \
--appdir AppDir \
--executable build/bin/openxcom \
--desktop-file res/linux/openxcom.desktop \
--icon-file openxcom.png \
--output appimage
test -f OpenXcoop-x86_64.AppImage
- name: Assert the package tree (no secret, no archive)
run: |
set -euxo pipefail
pkg=pkg-linux
mkdir -p "$pkg/UFO" "$pkg/TFTD"
cp OpenXcoop-x86_64.AppImage "$pkg/"
chmod +x "$pkg/OpenXcoop-x86_64.AppImage"
cp -r bin/common bin/standard "$pkg/"
# coop art only - never the whole UFO/TFTD dir (licensed retail data).
cp -r bin/UFO/multiplayer "$pkg/UFO/"
cp -r bin/TFTD/multiplayer "$pkg/TFTD/"
cp LICENSE.txt "$pkg/"
cp tools/coop_test/rendezvous.ci.json "$pkg/rendezvous.json"
sh tools/ci/assert_package_dir.sh "$pkg"
# Native Apple Silicon macOS validation appended after the existing Windows,
# WinXP, and Linux jobs. It builds the PR head without release secrets, validates
# the standalone .app, and uploads it only as a short-lived test artifact.
build-macos:
runs-on: macos-15
timeout-minutes: 90
env:
MACOSX_DEPLOYMENT_TARGET: '14.0'
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
fetch-depth: 0
- name: Install macOS build dependencies
shell: bash
run: |
set -euxo pipefail
brew install \
cmake pkgconf autoconf automake libtool \
sdl12-compat sdl2-compat sdl3 sdl_gfx \
jsoncpp libsodium libvorbis flac libmikmod \
libpng jpeg-turbo libtiff zlib
local_prefix="$GITHUB_WORKSPACE/.mac-deps"
mkdir -p "$local_prefix"
pc_paths=("$local_prefix/lib/pkgconfig")
include_paths=("$local_prefix/include" "$local_prefix/include/SDL")
lib_paths=("$local_prefix/lib")
for formula in sdl12-compat sdl_gfx jsoncpp libsodium libvorbis flac libmikmod libpng jpeg-turbo libtiff zlib; do
prefix="$(brew --prefix "$formula")"
[[ -d "$prefix/lib/pkgconfig" ]] && pc_paths+=("$prefix/lib/pkgconfig")
[[ -d "$prefix/share/pkgconfig" ]] && pc_paths+=("$prefix/share/pkgconfig")
[[ -d "$prefix/include" ]] && include_paths+=("$prefix/include")
[[ -d "$prefix/lib" ]] && lib_paths+=("$prefix/lib")
done
{
echo "MAC_DEPS_PREFIX=$local_prefix"
echo "PKG_CONFIG_PATH=$(IFS=:; echo "${pc_paths[*]}")"
echo "CPPFLAGS=$(printf -- '-I%s ' "${include_paths[@]}")"
echo "LDFLAGS=$(printf -- '-L%s ' "${lib_paths[@]}")"
echo "PATH=$local_prefix/bin:$PATH"
} >> "$GITHUB_ENV"
- name: Build SDL 1.2 companion libraries
shell: bash
run: |
set -euxo pipefail
src="$RUNNER_TEMP/sdl12-deps"
rm -rf "$src"
mkdir -p "$src"
cd "$src"
modern_guess="$(find "$(brew --prefix automake)/share" -name config.guess -type f | head -1)"
modern_sub="$(find "$(brew --prefix automake)/share" -name config.sub -type f | head -1)"
[[ -f "$modern_guess" && -f "$modern_sub" ]]
build_autotools() {
local name="$1"
local version="$2"
local url="$3"
local archive="${name}-${version}.tar.gz"
curl --fail --location --retry 5 --retry-all-errors \
--output "$archive" "$url"
tar -xzf "$archive"
cd "${name}-${version}"
while IFS= read -r -d '' f; do cp "$modern_guess" "$f"; done < <(find . -name config.guess -print0)
while IFS= read -r -d '' f; do cp "$modern_sub" "$f"; done < <(find . -name config.sub -print0)
CFLAGS="${CFLAGS:-} -Wno-implicit-function-declaration -Wno-error=implicit-function-declaration" \
OBJCFLAGS="${OBJCFLAGS:-} -Wno-implicit-function-declaration -Wno-error=implicit-function-declaration" \
SDL_CONFIG="$(brew --prefix sdl12-compat)/bin/sdl-config" \
./configure --prefix="$MAC_DEPS_PREFIX" --disable-dependency-tracking
make -j"$(sysctl -n hw.logicalcpu)"
make install
cd "$src"
}
build_autotools SDL_image 1.2.12 \
https://www.libsdl.org/projects/SDL_image/release/SDL_image-1.2.12.tar.gz
build_autotools SDL_mixer 1.2.12 \
https://www.libsdl.org/projects/SDL_mixer/release/SDL_mixer-1.2.12.tar.gz
build_autotools SDL_net 1.2.8 \
https://www.libsdl.org/projects/SDL_net/release/SDL_net-1.2.8.tar.gz
pkg-config --modversion sdl SDL_image SDL_gfx SDL_mixer SDL_net libsodium zlib jsoncpp
- name: Configure + build macOS arm64 bundle
shell: bash
run: |
set -euxo pipefail
jsoncpp_cflags="$(pkg-config --cflags jsoncpp)"
cmake -S . -B build-macos \
-DCMAKE_BUILD_TYPE=Release \
-DDEV_BUILD=OFF \
-DBUILD_PACKAGE=OFF \
-DCREATE_BUNDLE=ON \
-DCMAKE_OSX_ARCHITECTURES=arm64 \
-DCMAKE_OSX_DEPLOYMENT_TARGET="$MACOSX_DEPLOYMENT_TARGET" \
-DCMAKE_PREFIX_PATH="$MAC_DEPS_PREFIX;$(brew --prefix)" \
-DCMAKE_C_FLAGS="$CPPFLAGS" \
-DCMAKE_CXX_FLAGS="$CPPFLAGS $jsoncpp_cflags" \
-DCMAKE_EXE_LINKER_FLAGS="$LDFLAGS"
cmake --build build-macos --config Release --parallel 3
exe="build-macos/openxcom.app/Contents/MacOS/openxcom"
test -x "$exe"
file "$exe"
file "$exe" | grep -q 'arm64'
- name: Prepare + validate PR package (no release secret)
shell: bash
run: |
set -euxo pipefail
app="build-macos/openxcom.app"
resources="$app/Contents/Resources"
# sdl12-compat loads SDL2 with dlopen(), so CMake cannot discover it
# as a normal linked dependency. Bundle Homebrew's SDL2 and SDL3
# compatibility runtimes explicitly before signing the application.
bash tools/ci/bundle_macos_sdl_runtime.sh "$app"
cp tools/coop_test/rendezvous.ci.json "$resources/rendezvous.json"
codesign --force --deep --sign - "$app"
codesign --verify --deep --strict --verbose=2 "$app"
# Load the bundled SDL 1.2 compatibility library directly. Its startup
# path must now find the bundled SDL2 runtime, which in turn must find
# the bundled SDL3 runtime. This catches the exact failure seen on the
# Mac mini before an artifact is uploaded.
bundled_sdl12="$(find "$app/Contents/Frameworks" -maxdepth 1 -type f -name 'libSDL-1.2*.dylib' | head -1)"
test -n "$bundled_sdl12"
BUNDLED_SDL12="$bundled_sdl12" \
SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy \
python3 - <<'PY'
import ctypes
import os
sdl = ctypes.CDLL(os.environ["BUNDLED_SDL12"])
sdl.SDL_Init.argtypes = [ctypes.c_uint32]
sdl.SDL_Init.restype = ctypes.c_int
sdl.SDL_GetError.restype = ctypes.c_char_p
if sdl.SDL_Init(0) != 0:
error = sdl.SDL_GetError()
raise SystemExit(error.decode("utf-8", errors="replace") if error else "SDL_Init failed")
sdl.SDL_Quit()
print("Bundled SDL 1.2 -> SDL2 -> SDL3 runtime chain loaded successfully.")
PY
test -d "$resources/common"
test -d "$resources/standard"
test -f "$resources/UFO/multiplayer/base.png"
test ! -e "$resources/UFO/GEODATA"
bad=0
while IFS= read -r -d '' f; do
if file "$f" | grep -q 'Mach-O'; then
echo "--- $f"
dependencies="$(otool -L "$f")"
rpaths="$(otool -l "$f" | awk '/cmd LC_RPATH/ { getline; getline; print $2 }')"
printf '%s\n' "$dependencies"
if [[ -n "$rpaths" ]]; then
printf 'LC_RPATH: %s\n' "$rpaths"
fi
if printf '%s\n%s\n' "$dependencies" "$rpaths" | grep -E '/opt/homebrew|/usr/local|/Users/runner/work'; then
echo "::error::unbundled dependency or runtime path in $f"
bad=1
fi
fi
done < <(find "$app" -type f -print0)
[[ "$bad" -eq 0 ]]
- name: Package macOS app for validation
shell: bash
run: ditto -c -k --sequesterRsrc --keepParent build-macos/openxcom.app openxcom-pr-arm64.zip
- uses: actions/upload-artifact@v4
with:
name: validate-macos-app
path: openxcom-pr-arm64.zip
if-no-files-found: error
retention-days: 7