Files
RustNotMC/packaging/macos

The macOS launcher: Horizon.app in a .dmg

package.sh BINARY VERSION OUTDIR (run by .github/workflows/launcher.yml on macOS) makes:

  • Horizon.app: Contents/MacOS/horizon-launcher, Contents/Info.plist (this folder's, bundle id dev.horizon.launcher, macOS 11 or later), Contents/Resources/horizon.icns (packaging/icons, from cargo run -p xtask -- icons).
  • horizon-launcher-macos-arm64.dmg: the installer. Opening it shows the app and a link to Applications over background.png / background@2x.png (drawn from background.html); the player drags one onto the other. Built by dmgbuild, which writes the Finder layout without a GUI session.
  • horizon-launcher-macos-arm64.zip: the same app, zipped with ditto. The launcher updates itself from this: inside Horizon.app it replaces the whole bundle (copied beside it, then swapped in by renaming, then restarted); a bare horizon-launcher binary, as launchers before the app shipped, finds the binary inside the zip and replaces only itself.

Signing and notarization

Releases are signed with Ludovic's Developer ID ("Developer ID Application: Ludovic Marie (YYJU63HSD4)"), with the hardened runtime and a secure timestamp, then notarized by Apple and stapled, so Gatekeeper opens them on any Mac without a warning, offline too:

  • the launcher (package.sh): Horizon.app is signed, notarized and stapled first, so the zip the launcher updates itself from and the .dmg both hold it stapled; then the .dmg is signed, notarized and stapled. The script ends with what Gatekeeper says of each, which must read accepted and source=Notarized Developer ID.
  • the game (.github/workflows/release.yml): horizon-client, horizon-ctl and horizon-prepare are signed right after they are built (so the checks and the 1500-frame smoke run use them as players will), and the zip is notarized. Programs outside a bundle can't hold a ticket: Gatekeeper asks Apple online the first time each one runs.

No entitlements are needed. The launcher checks an update's signature before swapping the new Horizon.app in, and again where it lands; when it doesn't hold, the old app is put back.

The switch is the repository variable MACOS_SIGN_IDENTITY: set to the identity's name, the builds sign and notarize; empty (as in forks), they are signed ad hoc as before.

The signing keychain on the Mac mini

The release runner (ludovics-mac-mini-release, label horizon-release, MAC_RUNNER) is a LaunchAgent whose jobs run in their own security session, where the login keychain is locked. So signing uses a keychain of its own, ~/Library/Keychains/horizon-signing.keychain-db, holding only the Developer ID identity, Apple's "Developer ID Certification Authority" and the notarytool profile horizon-notary (the App Store Connect team key "App Store Connect API - GitHub CI (DISPO)"). Its password is the item "Horizon signing keychain" in the Agents vault. keychain.sh unlock (each job's first macOS step) reads it with the fleet's 1Password service account, unlocks the keychain, checks the identity and the profile are there and hands both to the later steps; keychain.sh lock ends the job. Nothing secret is in the repository or on GitHub, and nothing is printed.

Setting it up again (a new Mac, a renewed certificate):

  1. packaging/macos/keychain.sh create: the keychain, locked after two hours idle and on sleep.
  2. In Keychain Access on the Mac (the login keychain, My Certificates), right-click "Developer ID Application: Ludovic Marie (YYJU63HSD4)", Export…, save it as a .p12 with a password; then in the Mac's Terminal packaging/macos/keychain.sh import FILE.p12. It asks for that password, imports the identity and its intermediate, removes any other identity, lets codesign use the key without a prompt and deletes the .p12. (The login keychain also holds other people's Apple Development and Distribution identities: never those.)
  3. packaging/macos/keychain.sh notary: the notarytool profile, from the App Store Connect key (written to a private temporary file and deleted).
  4. The repository variable MACOS_SIGN_IDENTITY.

The certificate and its intermediate expire on 1 February 2027: renew the certificate in the Apple Developer account before then and repeat step 2.

Signing by hand, as the workflow does:

export MACOS_SIGN_IDENTITY="Developer ID Application: Ludovic Marie (YYJU63HSD4)"
export MACOS_SIGN_KEYCHAIN=~/Library/Keychains/horizon-signing.keychain-db MACOS_NOTARY_PROFILE=horizon-notary
packaging/macos/keychain.sh unlock
packaging/macos/package.sh target/release/horizon-launcher 0.0.0 out
packaging/macos/keychain.sh lock

Gatekeeper with an ad hoc build

An ad hoc build (a fork's, or package.sh without MACOS_SIGN_IDENTITY) is consistent and runs on Apple Silicon, but Gatekeeper doesn't know the developer. The first time it is opened after downloading:

  • macOS 14 and earlier: right-click (or Control-click) Horizon in Applications, choose Open, then Open in the dialog.
  • macOS 15 and later: open it once (it is refused), then System Settings › Privacy & Security, Open Anyway next to "Horizon was blocked", and confirm.

After that it opens normally, and its own updates don't ask again (they aren't downloaded by a browser, so they carry no quarantine flag).