name: game build # Builds the game for every platform from `ref`: the Java exporters, then Linux and the Linux server and Windows on # GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json). Called by # release.yml (the releases) and nightly-build.yml (the opt-in Nightly), always from main's copy of this file: the # caller's commit, never the one being built. It holds no secrets: signing the checksums is the callers' own job, # which never runs what this one built. See docs/release-flow.md. on: workflow_call: inputs: ref: description: The commit to build type: string required: true version: description: The version built in (HORIZON_VERSION), e.g. 0.2.81 or nightly-2026-10-28 type: string required: true nightly: description: A nightly's " " (HORIZON_NIGHTLY), shown by the game; empty for a release type: string default: '' trusted: # Only release.yml says true: it builds main. A nightly can hold pull requests nobody reviewed yet, whose # build scripts and tests run here and could rewrite target/, the cargo home or a compiler cache. So an # untrusted build never runs on the release runner, never saves the Rust cache (its key is its own anyway), # and a trusted one never reads the machine's shared sccache, which pull requests' jobs write # (tests/test_nightly.py pins all of it). description: The source is main (a release); false for anything else (a nightly) type: boolean default: false developer_id: description: Sign the macOS programs with Developer ID and notarize the zip (the runner's keychain) type: boolean default: true permissions: contents: read jobs: exporters: # This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it. An untrusted build # goes to the Mac mini runner that already runs pull requests' parity jobs (macOS + horizon-game: # ludovics-mac-mini), never the release runner (MAC_RUNNER: ludovics-mac-mini-release) nor parity-main's. runs-on: ${{ inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-game"]') }} # Three full preparations (two isolated, one batched) besides the compile. timeout-minutes: 60 steps: - uses: actions/checkout@v4 with: ref: ${{ inputs.ref }} persist-credentials: false clean: true - uses: dtolnay/rust-toolchain@stable - run: bash tools/ci/runner-env.sh env: HORIZON_NO_SCCACHE: ${{ inputs.trusted && '1' || '' }} - run: bash tools/ci/vanilla-data.sh - name: Compile and validate original exporters against the pinned jars shell: bash run: | set -euo pipefail # target/ is gone after the clean checkout, and vanilla-data.sh builds the preparer only when it # prepares afresh. cargo build --release --locked -p horizon-prepare # Compiled here, never taken from the runner's shared preparations: pull requests' parity jobs # write those (vanilla-data.sh links .local/exporters to them). rm -rf .local/exporters bundle=$(target/release/horizon-prepare compile-exporters --root "$PWD" | tail -1) mkdir -p exporters cp -R "$bundle/server" "$bundle/client" "$bundle/manifest.json" exporters/ # Only our classes and their manifest leave this runner. stray=$(find exporters -type f ! -path exporters/manifest.json \ ! \( -name '*.class' \( -path 'exporters/server/dev/horizonlab/parity/*' -o -path 'exporters/client/dev/horizonlab/parity/*' \) \)) [ -z "$stray" ] || { echo "::error::not one of our classes: $stray"; exit 1; } python3 tools/check_prepare_exporters.py --binary target/release/horizon-prepare - uses: actions/upload-artifact@v4 with: name: original-exporters path: exporters/ if-no-files-found: error retention-days: 3 build: needs: exporters name: build ${{ matrix.name }} strategy: fail-fast: false matrix: include: - name: linux os: ubuntu-22.04 dir: horizon-linux-x86_64 asset: horizon-linux-x86_64.tar.gz # The server build too: packaging/server/install.sh installs it on Linux. server_dir: horizon-server-linux-x86_64 server_asset: horizon-server-linux-x86_64.tar.gz - name: windows os: windows-latest dir: horizon-windows-x64 asset: horizon-windows-x64.zip exe: .exe # The C runtime linked in, so the game runs without the Visual C++ redistributable. rustflags: -C target-feature=+crt-static - name: macos mac: true dir: horizon-macos-arm64 asset: horizon-macos-arm64.zip runs-on: ${{ matrix.mac && (inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-game"]')) || matrix.os }} # Notarizing the zip can take a few minutes. timeout-minutes: ${{ matrix.mac && 60 || 40 }} env: RUSTFLAGS: ${{ matrix.rustflags }} # The release's version, built in (horizon_core::release): --version, user agents, crash reports and # telemetry say it. No version-bump commits. HORIZON_VERSION: ${{ inputs.version }} # A nightly's date and commit ("2026-10-28 0c02067"), which the game shows; empty for a release. HORIZON_NIGHTLY: ${{ inputs.nightly }} defaults: run: shell: bash steps: - uses: actions/checkout@v4 with: ref: ${{ inputs.ref }} persist-credentials: false # Always from a clean checkout, the Mac mini's runner too: its work folder is shared with pull requests' # parity jobs, and nothing they leave (target/, an untracked .cargo/config.toml, a build script's output) # may end up in a release. target/ is rebuilt from scratch; .local/ is relinked by vanilla-data.sh, which # keeps its preparations outside the checkout. clean: true - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 if: runner.environment == 'github-hosted' with: # A release restores only what a release saved: an untrusted build has its own key and saves nothing. shared-key: ${{ inputs.trusted && 'release' || 'nightly' }}-${{ matrix.name }} save-if: ${{ inputs.trusted }} - name: ALSA and udev headers if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev - name: Build the client, horizon-ctl, horizon-prepare and horizon-server # `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds). run: cargo build --release --locked --features horizon-client/mods -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server - name: Every program the release folder takes was built run: | for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; } done - name: The signing keychain, unlocked if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id env: MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }} run: packaging/macos/keychain.sh unlock - name: Signed with Developer ID and the hardened runtime # Before the checks below, so they run the programs as players will. if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id env: MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }} run: | for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do codesign --force --options runtime --timestamp --keychain "$MACOS_SIGN_KEYCHAIN" \ --identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "target/release/$binary" codesign --verify --strict --verbose=2 "target/release/$binary" done - name: Only system libraries # Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard # loader and libraries every desktop distribution has. macOS: only the system's own libraries. run: | for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do file="target/release/$binary${{ matrix.exe }}" if [[ "$RUNNER_OS" == macOS ]]; then others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true) [[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; } else ${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file" fi done - name: Every program starts run: | set -euo pipefail bin=target/release "$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources' "$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null "$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir' # The version is the release's. for binary in horizon-prepare horizon-server; do out=$("$bin/$binary${{ matrix.exe }}" --version | tr -d '\r') echo "$out" [[ "$out" == "$binary $HORIZON_VERSION" ]] || { echo "::error::expected $binary $HORIZON_VERSION"; exit 1; } done - name: Tools from the runner if: runner.environment == 'self-hosted' env: HORIZON_NO_SCCACHE: ${{ inputs.trusted && '1' || '' }} run: bash tools/ci/runner-env.sh - name: Resources prepared from the runner's own Minecraft if: runner.environment == 'self-hosted' run: bash tools/ci/vanilla-data.sh - name: The game runs (offscreen, a new world, 1500 frames) if: runner.environment == 'self-hosted' run: | set -euo pipefail # From a scratch folder: the game keeps its saves and options in the folder it starts in. home="$RUNNER_TEMP/horizon-smoke" rm -rf "$home" && mkdir -p "$home" version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1) client="$PWD/target/release/horizon-client" resources="$PWD/.local/horizon-client/$version" (cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \ --world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \ --smoke-report "$home/smoke.json" > "$home/client.log" 2>&1) || { tail -40 "$home/client.log"; exit 1; } python3 - "$home/smoke.json" <<'EOF' import json, sys report = json.load(open(sys.argv[1])) print(json.dumps(report)) if report.get("error") is not None or report.get("frames", 0) < 1500 or not report.get("chunks") \ or not report.get("meshed_sections") or not report.get("player"): sys.exit("the smoke run did not draw a world") EOF # Published with the release; the publish job checks it again before it publishes anything. cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json" - uses: actions/upload-artifact@v4 if: runner.environment == 'self-hosted' with: name: smoke-test.json path: ${{ runner.temp }}/smoke-test.json if-no-files-found: error retention-days: 3 - uses: actions/download-artifact@v4 with: name: original-exporters path: exporters - name: Package run: | set -euo pipefail cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}" cd dist rm -f "${{ matrix.asset }}" case "${{ matrix.asset }}" in *.tar.gz) tar czf "${{ matrix.asset }}" "${{ matrix.dir }}" ;; *) if [[ "$RUNNER_OS" == Windows ]]; then 7z a -tzip "${{ matrix.asset }}" "${{ matrix.dir }}" > /dev/null else # Without resource forks and extended attributes: plain ditto adds an # AppleDouble `._` file beside every file that has any. ditto -c -k --norsrc --noextattr --noqtn --keepParent "${{ matrix.dir }}" "${{ matrix.asset }}" if unzip -Z1 "${{ matrix.asset }}" | grep -E '(^|/)(\._|__MACOSX/)'; then echo "::error::macOS metadata files in ${{ matrix.asset }}"; exit 1 fi fi ;; esac ls -l "${{ matrix.asset }}" - name: Notarized # A zip of programs can't be stapled: Gatekeeper asks Apple the first time each one runs. if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id run: | for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do codesign --verify --strict "dist/${{ matrix.dir }}/$binary" done packaging/macos/notarize.sh "dist/${{ matrix.asset }}" - uses: actions/upload-artifact@v4 with: name: ${{ matrix.asset }} path: dist/${{ matrix.asset }} if-no-files-found: error retention-days: 3 - name: The server build (the game without sound or controllers, as horizon-engine) if: matrix.server_asset run: | set -euo pipefail # After the client is packaged: target/release/horizon-client is rebuilt without the audio and # controllers features, so it needs neither libasound nor libudev (minimal server images lack them), # and with code mods like the client. cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}" folder="dist/${{ matrix.server_dir }}" python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare" "$folder/horizon-engine" --help | grep -q -- '--resources' [[ "$("$folder/horizon-server" --version)" == "horizon-server $HORIZON_VERSION" ]] cd dist rm -f "${{ matrix.server_asset }}" tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}" ls -l "${{ matrix.server_asset }}" - uses: actions/upload-artifact@v4 if: matrix.server_asset with: name: ${{ matrix.server_asset }} path: dist/${{ matrix.server_asset }} if-no-files-found: error retention-days: 3 - name: The signing keychain, locked again if: always() && runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id run: packaging/macos/keychain.sh lock