874b6f20c2
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
333 lines
16 KiB
YAML
333 lines
16 KiB
YAML
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 <branch> # 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
|