fix(coop): serve the client on a mid-battle campaign resume #97
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |