name: nightly build run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.request) || '' }} # The opt-in Nightly channel (docs/release-flow.md "Nightly"; Ludovic, 2026-10-27). Once a night, and when a trusted # reviewer asks (`/nightly 2640 2642`, nightly-command.yml), it builds main plus the pull requests the reviewers # picked, merged in order (tools/ci/nightly_set.py). With none picked, it builds the newest union branch on origin # (union/-: main plus pull requests the contribution gate proved together), or main when no union is # newer. It publishes the result as the `nightly` pre-release. Launchers install it only for players who turn Nightly # on. The previous nightly stays a week as nightly-. # # Why the signing key never meets the pull requests' code: # - `pick` merges the picked heads onto main with `git merge-tree --write-tree`: git objects only, no working tree, # so none of their files reaches the disk and no hook, filter or script of theirs runs. It refuses any pull # request that changes a path CODEOWNERS gives to Ludovic (this folder, tools/ci/, the signing and update code), # pushes the merge as nightly/build-- and saves the set on the nightly-state branch. # - This file, game-build.yml and every script below run from main's copy: a schedule runs the default branch, and # `uses: ./…` takes the caller's commit. The union's own .github/ is never read. # - `build` (game-build.yml, `trusted: false`) never reaches a release's tools: its macOS jobs run on the runner # that already runs pull requests' parity jobs, never the release runner; its Rust cache has its own key and is # never saved; and releases don't read the machine's shared sccache. # - `build` (game-build.yml) checks out and compiles the merge (or the union) with no secrets: its token reads contents only and # isn't kept in the checkout; the macOS programs are signed ad hoc (Developer ID needs the runner's keychain, # which stays locked for nightlies). # - `sign` is the only job with RELEASE_SIGNING_KEY. It checks out nothing but main's tools/ci/sign_release.sh, which # runs the runner's sha256sum and OpenSSL over the archives: it hashes them, never runs them. The signed message # names the channel ("horizon release nightly"), so a nightly's signature can't pass for a release's. # - `publish` verifies the signature with main's horizon-release-sign, checks the smoke test, and only then moves # the tag. Like sign, it never executes what build produced. # - The source ships with the build: `pick` writes horizon-source.tar.gz with `git archive` (git objects again, nothing # run), `sign` signs it with the builds, and `publish` attaches it and tags the commit nightly-source/- # for good (the dated release and the build branch go after 7 days; native mods pin the commit: docs/native-mods.md). # gh workflow run "nightly build" # now (skips when the pick is the same commit as last night's) # gh workflow run "nightly build" -f force=true # build and publish even if unchanged # gh workflow run "nightly build" -f dry_run=true # build and sign with a throwaway key, publish nothing on: schedule: - cron: '17 20 * * *' # 20:17 UTC: after the day's waves, before the 03:07 Paris parity audit takes the Mac mini workflow_dispatch: inputs: force: description: Build and publish even if tonight's pick is the commit already published type: boolean default: false dry_run: description: Build and sign with a throwaway key, publish nothing type: boolean default: false issue: description: The issue or pull request whose `/nightly` comment asked for this build (answered there) type: string default: '' request: description: The command's id, in the run's name so it can link the run type: string default: '' prs: description: A dry run's own set, `2640@ 2642@` (the saved set stays) type: string default: '' permissions: contents: read concurrency: # One build at a time. A newer request replaces one still waiting: it builds the set as it is by then, so no pick # is lost. group: nightly-build cancel-in-progress: false jobs: pick: runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: write # nightly/build-- and the nightly-state branch (main is protected) issues: write # says in the thread when the picked pull requests conflict pull-requests: write outputs: build: ${{ steps.pick.outputs.build }} sha: ${{ steps.pick.outputs.sha }} short: ${{ steps.pick.outputs.short }} source: ${{ steps.pick.outputs.source }} date: ${{ steps.pick.outputs.date }} version: ${{ steps.pick.outputs.version }} nightly: ${{ steps.pick.outputs.nightly }} source_tag: ${{ steps.pick.outputs.source_tag }} dry_run: ${{ steps.mode.outputs.dry_run }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 persist-credentials: false - id: mode # A dry run never publishes; a hand-started run off main is always one. run: echo "dry_run=${{ github.event_name == 'workflow_dispatch' && (inputs.dry_run || github.ref != 'refs/heads/main') }}" >> "$GITHUB_OUTPUT" - id: pick env: GH_TOKEN: ${{ github.token }} FORCE: ${{ (inputs.force || steps.mode.outputs.dry_run == 'true') && 'yes' || '' }} DRY_RUN: ${{ steps.mode.outputs.dry_run }} NIGHTLY_ISSUE: ${{ inputs.issue }} NIGHTLY_PRS: ${{ inputs.prs }} run: | python3 tools/ci/nightly.py pick ${FORCE:+--force} | tee -a "$GITHUB_STEP_SUMMARY" { echo; cat nightly-notes.md; } >> "$GITHUB_STEP_SUMMARY" - name: Old nightly/build-* branches deleted (scheduled runs only) # Ludovic, 2026-10-27: older than 7 days, but never the newest build or one a published nightly points to; # nothing but nightly/build-* (nightly.py stale_builds, tests/test_nightly.py). if: github.event_name == 'schedule' env: GH_TOKEN: ${{ github.token }} run: | { echo "### nightly/build-* branches"; python3 tools/ci/nightly.py prune-builds --days 7; } | tee -a "$GITHUB_STEP_SUMMARY" - name: The source of the build (horizon-source.tar.gz) if: steps.pick.outputs.build == 'true' env: SHA: ${{ steps.pick.outputs.sha }} run: git archive --format=tar.gz --prefix="horizon-${SHA:0:7}/" -o horizon-source.tar.gz "$SHA" - uses: actions/upload-artifact@v4 if: steps.pick.outputs.build == 'true' with: name: horizon-source path: horizon-source.tar.gz if-no-files-found: error retention-days: 3 - uses: actions/upload-artifact@v4 with: name: nightly-notes path: nightly-notes.md retention-days: 3 build: needs: pick if: needs.pick.outputs.build == 'true' uses: ./.github/workflows/game-build.yml with: ref: ${{ needs.pick.outputs.sha }} version: ${{ needs.pick.outputs.version }} nightly: ${{ needs.pick.outputs.nightly }} developer_id: false trusted: false # it can hold unreviewed pull requests: their own cache key, never saved, not the release runner sign: needs: [pick, build] runs-on: ubuntu-latest timeout-minutes: 10 # The key's only job: main's sign_release.sh hashes the archives and signs with the runner's OpenSSL. environment: ${{ needs.pick.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-* merge-multiple: true - name: Sign the checksums (SHA256SUMS, SHA256SUMS.sig) for the nightly channel env: RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }} RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }} EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }} run: | bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \ dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz dist/horizon-source.tar.gz - 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.pick.outputs.dry_run == 'true' with: name: ephemeral.pub path: dist/ephemeral.pub retention-days: 3 publish: needs: [pick, build, sign] runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: write steps: - uses: actions/checkout@v4 with: persist-credentials: false - uses: actions/download-artifact@v4 with: path: dist merge-multiple: true - name: The signature, checked as launchers check it env: EPHEMERAL: ${{ needs.pick.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 nightly --publishing "${key[@]}" dist - name: The smoke test drew a world # No release-blocker check: a nightly is how blockers get tested. run: python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json - name: Publish as the `nightly` pre-release if: needs.pick.outputs.dry_run != 'true' env: GH_TOKEN: ${{ github.token }} SHA: ${{ needs.pick.outputs.sha }} DATE: ${{ needs.pick.outputs.date }} SOURCE_TAG: ${{ needs.pick.outputs.source_tag }} run: | set -euo pipefail python3 tools/ci/nightly.py rotate signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig) # A pre-release, never GitHub's latest: launchers on the release channel never see it. gh release create nightly --prerelease --latest=false --target "$SHA" \ --title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \ "dist/horizon-linux-x86_64.tar.gz#Nightly game files: Linux (horizon-linux-x86_64.tar.gz)" \ "dist/horizon-windows-x64.zip#Nightly game files: Windows (horizon-windows-x64.zip)" \ "dist/horizon-macos-arm64.zip#Nightly game files: macOS (horizon-macos-arm64.zip)" \ "dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \ "dist/horizon-source.tar.gz#Source of this Nightly, commit ${SHA:0:7} (horizon-source.tar.gz; kept for good as the tag $SOURCE_TAG)" \ dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json python3 tools/ci/nightly.py keep-source "$SHA" "$DATE" python3 tools/ci/nightly.py prune --days 7 - name: Dry run, the nightly that would be published if: needs.pick.outputs.dry_run == 'true' run: | echo "Would publish nightly at ${{ needs.pick.outputs.sha }} (${{ needs.pick.outputs.source }})" ls -l dist answer: # Tells the thread that asked for this build how it ended. A conflict `pick` already explained there. needs: [pick, build, sign, publish] if: always() && inputs.issue != '' && needs.pick.result == 'success' runs-on: ubuntu-latest timeout-minutes: 5 permissions: issues: write pull-requests: write steps: - env: GH_TOKEN: ${{ github.token }} ISSUE: ${{ inputs.issue }} BUILD: ${{ needs.pick.outputs.build }} PUBLISHED: ${{ needs.publish.result }} DRY_RUN: ${{ needs.pick.outputs.dry_run }} NAME: Nightly ${{ needs.pick.outputs.date }} · ${{ needs.pick.outputs.short }} RUN: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | if [[ "$BUILD" != true ]]; then text="nothing new: the published Nightly is already this build." elif [[ "$PUBLISHED" == success && "$DRY_RUN" == true ]]; then text="the dry run built and signed $NAME; nothing was published. $RUN" elif [[ "$PUBLISHED" == success ]]; then text="published $NAME: https://github.com/$GITHUB_REPOSITORY/releases/tag/nightly. Launchers with Nightly on install it at their next start." else text="the build failed, nothing was published: $RUN" fi gh api "repos/$GITHUB_REPOSITORY/issues/$ISSUE/comments" -f body="🤖 Nightly: $text"