name: launcher # Builds the launcher on Linux, macOS and Windows, and releases it by itself (docs/release-flow.md): after a # push to main that changes the launcher or a crate it builds in (the paths below), # every 10 minutes (a release waiting for merges to settle goes out) and by hand, tools/release_train.py decides as # for the game. # A release is launcher@X.Y.Z on the release line in .github/release-line.txt (docs/versioning.md); without it # launcher-vX.Y.Z, the patch after the last launcher tag or the next minor when a merged pull request is labelled # feature. The version and the tag are given to the build (HORIZON_LAUNCHER_VERSION, HORIZON_LAUNCHER_TAG): no bump # commits. # Never marked latest. Pushes to launcher branches only build. RELEASES_PAUSED=1 stops releases. # macOS: with the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with # Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md); # unset (forks), they are signed ad hoc. # gh workflow run launcher --ref # dry run: builds everything, publishes nothing # gh workflow run launcher --ref main -f dry_run=false -f force=true on: push: branches: [main, launcher, 'launcher-*'] paths: # Every crate the launcher builds in, as tools/release_train.py's LAUNCHER_PATHS. - 'launcher/**' - 'crates/horizon-bench/**' - 'crates/horizon-friends/**' - 'crates/horizon-mods/**' - 'crates/horizon-port-pack/**' - 'crates/horizon-prepare/**' - 'crates/horizon-release-sign/**' - 'crates/horizon-telemetry/**' - 'crates/horizon-theme/**' - '.github/workflows/launcher.yml' schedule: - cron: '7-59/10 * * * *' workflow_dispatch: inputs: dry_run: description: Build everything and write the notes, but publish nothing type: boolean default: true force: description: Release even without launcher changes, before merges settle or while paused type: boolean default: false permissions: contents: read concurrency: group: launcher-${{ github.ref == 'refs/heads/main' && 'release' || github.ref }} cancel-in-progress: false env: # Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md). HORIZON_TELEMETRY_URL: "off" jobs: decide: runs-on: ubuntu-latest timeout-minutes: 10 outputs: release: ${{ steps.decide.outputs.release }} tag: ${{ steps.decide.outputs.tag }} head: ${{ steps.decide.outputs.head }} version: ${{ steps.decide.outputs.version }} dry_run: ${{ steps.mode.outputs.dry_run }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - id: mode # Off main every run is a dry run (a launcher branch's push only builds). run: echo "dry_run=${{ github.ref != 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && inputs.dry_run) }}" >> "$GITHUB_OUTPUT" - id: decide env: GH_TOKEN: ${{ github.token }} RELEASES_PAUSED: ${{ vars.RELEASES_PAUSED }} # A push or a run off main builds its own commit; otherwise main's newest commit with green checks. HEAD_SHA: ${{ (github.event_name == 'push' || steps.mode.outputs.dry_run == 'true') && github.sha || '' }} FORCE: ${{ inputs.force && 'yes' || '' }} run: | python3 tools/release_train.py decide --kind launcher ${HEAD_SHA:+--head "$HEAD_SHA"} ${FORCE:+--force} | tee decision.json echo "### $(jq -r '.reason' decision.json)" >> "$GITHUB_STEP_SUMMARY" tag=$(jq -r '.tag' decision.json) version=${tag#launcher-v} echo "version=${version#launcher@}" >> "$GITHUB_OUTPUT" # A dry run (and a launcher branch's push) builds whatever the decision. if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then echo "release=true" >> "$GITHUB_OUTPUT" echo "head=$GITHUB_SHA" >> "$GITHUB_OUTPUT" fi build: needs: decide if: needs.decide.outputs.release == 'true' name: build ${{ matrix.name }} strategy: fail-fast: false matrix: include: - name: linux os: ubuntu-22.04 asset: horizon-launcher-linux-x86_64.tar.gz binary: horizon-launcher - name: macos mac: true asset: horizon-launcher-macos-arm64.zip binary: horizon-launcher - name: windows os: windows-latest asset: horizon-launcher-windows-x64.zip binary: horizon-launcher.exe # The C runtime linked in: without it the launcher asks for VCRUNTIME140.dll, # which only the Visual C++ redistributable installs. rustflags: -C target-feature=+crt-static runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }} # Notarizing the app and then the .dmg can take a few minutes each. timeout-minutes: ${{ matrix.mac && 60 || 40 }} env: RUSTFLAGS: ${{ matrix.rustflags }} # Horizon's Azure app for Microsoft sign-in (docs/microsoft-account.md): a repository # variable, not a secret. Unset, the launcher says sign-in is not configured. HORIZON_MSA_CLIENT_ID: ${{ vars.HORIZON_MSA_CLIENT_ID }} # The mod browser's CurseForge source (docs/mod-browser.md#curseforge): a repository secret. Unset, the # launcher searches Modrinth only. HORIZON_CURSEFORGE_API_KEY: ${{ secrets.HORIZON_CURSEFORGE_API_KEY }} # The release's version and tag, built in (launcher/src/backend.rs VERSION and own_tag: the tag says which # line it is on, docs/versioning.md); a launcher branch's build keeps Cargo.toml's. HORIZON_LAUNCHER_VERSION: ${{ (github.event_name != 'push' || github.ref == 'refs/heads/main') && needs.decide.outputs.version || '' }} HORIZON_LAUNCHER_TAG: ${{ (github.event_name != 'push' || github.ref == 'refs/heads/main') && needs.decide.outputs.tag || '' }} defaults: run: working-directory: launcher shell: bash steps: - uses: actions/checkout@v4 with: ref: ${{ needs.decide.outputs.head }} 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 may end up in a launcher release (target/ is rebuilt from scratch). clean: true - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 if: runner.environment == 'github-hosted' with: workspaces: launcher shared-key: launcher-${{ matrix.name }} - name: Linux libraries GPUI links to if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y libxkbcommon-x11-dev libwayland-dev libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev - name: Metal shaders compiled now, or at run time without the Metal toolchain # GPUI compiles its Metal shaders when it builds; the Mac mini has Xcode without the Metal toolchain # (xcodebuild -downloadComponent MetalToolchain), so there they are compiled when the launcher starts. if: runner.os == 'macOS' run: | if ! xcrun metal --version > /dev/null 2>&1; then echo "::notice::No Metal toolchain on this runner: GPUI compiles its shaders at run time" echo "LAUNCHER_FEATURES=gpui/runtime_shaders" >> "$GITHUB_ENV" fi # Every test target (the library, the binary, tests/), on each system the launcher ships for. - run: cargo test --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"} - run: cargo build --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"} - name: The version is the release's run: | out=$(target/release/${{ matrix.binary }} --version) echo "$out" expected="${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}" [[ "$out" == "horizon-launcher $expected" ]] || { echo "::error::expected horizon-launcher $expected"; exit 1; } - name: Only system libraries run: | file=target/release/${{ matrix.binary }} case "$RUNNER_OS" in Windows) python ../tools/check_imports.py "$file" ;; macOS) others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true) [[ -z "$others" ]] || { echo "::error::links $others"; exit 1; } ;; esac - name: Smoke test env: HORIZON_LAUNCHER_NO_SELF_UPDATE: '1' HORIZON_GITHUB_TOKEN: ${{ github.token }} run: | set -euo pipefail export HORIZON_LAUNCHER_HOME="${RUNNER_TEMP//\\//}/horizon-launcher-smoke" export HORIZON_HOME="$HORIZON_LAUNCHER_HOME/game" rm -rf "$HORIZON_LAUNCHER_HOME" launcher="$PWD/target/release/${{ matrix.binary }}" # GitHub's API rate-limits shared runner addresses now and then: try a few times. retry() { for i in 1 2 3 4; do "$@" && return 0; sleep $((i * 15)); done; return 1; } retry "$launcher" versions retry "$launcher" install latest "$launcher" versions --installed "$launcher" links --json clients=("$HORIZON_LAUNCHER_HOME"/versions/v*/horizon-client*) client="${clients[0]}" echo "installed client: $client" # The game's Linux build is the game's to keep portable (release.yml checks it): a # release linked to a Nix store is a warning here, not a broken launcher. soft() { if [[ "$RUNNER_OS" == "Linux" ]]; then "$@" || echo "::warning::$* failed"; else "$@"; fi; } if [[ "$RUNNER_OS" == "Windows" ]]; then python ../tools/check_imports.py "$client" elif [[ "$RUNNER_OS" == "Linux" ]]; then soft python3 ../tools/check_imports.py "$client" fi # The client starts: --help prints before any window or GPU is needed. help() { "$client" --help > "$RUNNER_TEMP/help.txt" 2>&1 && head -3 "$RUNNER_TEMP/help.txt" && grep -q -- '--resources' "$RUNNER_TEMP/help.txt"; } soft help rm -rf "$HORIZON_LAUNCHER_HOME" - name: The signing keychain, unlocked if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' env: MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }} run: ../packaging/macos/keychain.sh unlock - name: Package env: MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }} run: | rm -rf dist out && mkdir -p out if [[ "$RUNNER_OS" == "macOS" ]]; then # Horizon.app, zipped (self-update) and in a .dmg (installer), signed (and notarized with # Developer ID): packaging/macos. python3 -m venv "$RUNNER_TEMP/dmg" && "$RUNNER_TEMP/dmg/bin/pip" install --quiet dmgbuild==1.6.5 version=${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)} DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh target/release/${{ matrix.binary }} "$version" out rm -rf out/Horizon.app else mkdir -p dist cp target/release/${{ matrix.binary }} dist/ if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then (cd dist && tar czf ../out/${{ matrix.asset }} ${{ matrix.binary }}) else (cd dist && 7z a ../out/${{ matrix.asset }} ${{ matrix.binary }}) fi fi ls -l out - uses: actions/upload-artifact@v4 with: name: ${{ matrix.asset }} path: launcher/out/* retention-days: 3 - name: The signing keychain, locked again if: always() && runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' run: ../packaging/macos/keychain.sh lock sign: needs: [decide, build] runs-on: ubuntu-latest timeout-minutes: 10 # The only job that holds the release signing key, a secret of the `release` environment, which only main's real # releases enter (dry runs sign with a throwaway key). It runs no code from the repository but # tools/ci/sign_release.sh, which calls only the runner's sha256sum and OpenSSL (docs/release-flow.md). environment: ${{ needs.decide.outputs.dry_run != 'true' && 'release' || '' }} steps: - uses: actions/checkout@v4 with: sparse-checkout: tools/ci/sign_release.sh sparse-checkout-cone-mode: false persist-credentials: false - uses: actions/download-artifact@v4 with: path: dist pattern: horizon-launcher-* merge-multiple: true - name: Sign the checksums (SHA256SUMS, SHA256SUMS.sig) env: RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }} RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }} EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }} TAG: ${{ needs.decide.outputs.tag }} run: bash tools/ci/sign_release.sh "$TAG" dist/horizon-launcher-* - uses: actions/upload-artifact@v4 with: name: signature path: dist/SHA256SUMS* if-no-files-found: error retention-days: 3 - uses: actions/upload-artifact@v4 if: needs.decide.outputs.dry_run == 'true' with: name: ephemeral.pub path: dist/ephemeral.pub retention-days: 3 release: needs: [decide, build, sign] runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: write steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Notes env: GH_TOKEN: ${{ github.token }} run: | python3 tools/release_train.py notes --kind launcher --tag "${{ needs.decide.outputs.tag }}" \ --head "${{ needs.decide.outputs.head }}" --out notes > /dev/null { echo "## $(cat notes/title.txt)"; echo; cat notes/body.md; } >> "$GITHUB_STEP_SUMMARY" - uses: actions/download-artifact@v4 with: path: dist merge-multiple: true - name: The signature, checked as launchers check it env: TAG: ${{ needs.decide.outputs.tag }} EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }} run: | key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)") cargo run --locked --quiet -p horizon-release-sign -- verify --tag "$TAG" --publishing "${key[@]}" dist # Never marked latest: Horizon's own releases stay the repository's latest. - name: Publish ${{ needs.decide.outputs.tag }} if: needs.decide.outputs.dry_run != 'true' env: GH_TOKEN: ${{ github.token }} TAG: ${{ needs.decide.outputs.tag }} run: | signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig) gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \ --notes-file notes/body.md --latest=false dist/horizon-launcher-* dist/SHA256SUMS "${signature[@]}" - name: Dry run, the release that would be published if: needs.decide.outputs.dry_run == 'true' run: | echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }}: $(cat notes/title.txt)" ls -l dist - uses: actions/upload-artifact@v4 with: name: launcher-release-notes path: notes/ retention-days: 3