Everything needed to turn a fresh macOS install into a self-hosted GitHub Actions CI runner. Clone this repo onto the Mac once, run one script, done. Re-running the script is always safe — it only changes what needs changing (idempotent convergence), and a LaunchAgent re-runs it automatically after every reboot so the machine heals itself.
- GitHub Actions runner: latest release, registered to your repo/org, running as a per-user launchd service, kept up to date.
- Toolchain (Homebrew): openjdk (java), firebase-cli, fastlane, node, xcbeautify, git-lfs, aria2, xcodes, jq, python3, swiftlint, periphery — plus xcpretty (Ruby user gem; it has no Homebrew formula).
- Xcode: the newest stable release, the newest release of the previous minor train, and (if newer than stable) the newest beta/RC — each with simulator runtimes, SDKs and (Xcode 26+) the Metal toolchain.
- Job environment: CI jobs automatically get Homebrew on
PATH,JAVA_HOME/LANG/LC_ALL, and the latest stable Xcode viaDEVELOPER_DIR— configured through the runner's.env/.pathfiles (the runner's own mechanism for injecting env/PATH into every job). The globalxcode-selectalso tracks the latest stable Xcode, and Apple's WWDR intermediate certificate is installed for code signing. - Boot agent: a LaunchAgent that re-runs the whole setup at every login, unattended and sudo-free.
Step 1 happens once in GitHub; everything else on the Mac (Terminal.app on the machine itself or via Screen Sharing — launchd agents cannot be loaded over plain SSH).
-
Create a PAT that can manage self-hosted runners: classic PAT with
reposcope (repo-level runner) oradmin:org(org-level); fine-grained PAT with repo Administration: write or org Self-hosted runners: write. -
Have an Apple ID ready (a free developer account is enough) —
xcodesneeds it once to download Xcode. The Mac does not need to be signed into iCloud. -
Clone this repo (the
git clonetriggers the Xcode Command Line Tools install dialog on a fresh machine — accept it, then clone again):git clone https://github.com/SchmiedmayerLab/ci-setup.git ~/ci-setup cd ~/ci-setup
-
Check
config.toml(committed with the repo, shared by every runner Mac — it targets the SchmiedmayerLab org and holds no secrets; runner names default to each machine's hostname). Usually nothing to change here. -
Store the PAT in the Keychain:
./setup store-pat
store-patneeds nothing but macOS itself (no Homebrew/python bootstrap), so this works first thing on a factory-fresh machine. Run it bare to see the PAT requirements. -
Run the setup (expect a long first run — Xcode + simulators are tens of GB; keep ~150 GB of disk free):
./setup
The first run will interactively ask for: your Apple ID (Xcode downloads via
xcodes; the session is cached in the Keychain), and possibly sudo for Xcode's first-launch package installation. -
Enable auto-login for this user (System Settings → Users & Groups → Automatically log in as…; requires FileVault to be off). The runner and the boot agent are per-user LaunchAgents — they start at login, so the machine must log in by itself after a reboot/power failure.
Verify: the runner shows as Idle under the repo/org's
Settings → Actions → Runners, and ./setup status reports
everything green.
./setup (command converge, the default) converges every phase and
skips whatever is already correct:
| Phase | Re-run behaviour |
|---|---|
| Homebrew packages | installs missing, upgrades outdated (deferred while a job runs), otherwise no-op |
| Passwordless xcode-select | sudoers rule (/etc/sudoers.d/xcode) installed on the first interactive run: sudo xcode-select -s and sudo xcodebuild -runFirstLaunch work without a password — for CI jobs and for unattended converges finishing a new Xcode's first-launch setup |
| Xcode releases | installs newly released stable/previous/beta versions, refreshes simulators/SDKs/Metal toolchain; deletes Xcodes outside that set and simulator runtimes no kept Xcode uses (never a version newer than the ones it knows) |
| Runner software | updates when a newer release exists (checksum-verified); the runner also self-updates between runs |
| Registration | re-registers only when config.toml changed (name/labels/URL/group/work dir); otherwise untouched |
Job env (.env/.path) |
rewritten only on change; service restarted only then |
| Service / boot agent | (re)installed only when missing or changed |
A lock file guarantees a manual run and the boot-time run never overlap.
converge installs ~/Library/LaunchAgents/com.selfhosted-runner.setup.plist,
which runs setup converge --non-interactive at every login and every
6 hours, so long-lived login sessions still pick up new Xcode releases,
runner updates, and config changes. No quiet-hour scheduling is needed (the
team spans too many time zones for one to exist): disruptive steps defer
themselves while a job is running, and the multi-minute Xcode/runtime
cleanup additionally pauses the runner service so no job can be scheduled
onto the machine mid-change — GitHub simply queues it or picks another
runner, and the service comes back up at the end of the converge. Unattended
runs first git pull --ff-only this repo (re-executing themselves if the
setup changed), so every run uses the latest committed version; a failed
pull just means converging with the current checkout. In this
mode the setup never prompts and never uses sudo (beyond the
passwordless sudoers rule); anything that would
need either (a brand-new Xcode's first-launch step, an expired Apple ID
session, a re-registration without a usable PAT) is skipped with a warning
and left for the next manual run — an unattended run never tears down a
working runner. Output lands in ~/Library/Logs/ci-runner-setup.log.
Setting boot.install_agent = false in config.toml removes the agent on
the next converge.
./setup # converge (default)
./setup status # show runner/service/Xcode state
./setup store-pat # print PAT requirements, prompt for it (hidden)
./setup store-pat TOKEN # store the given PAT directly
./setup uninstall # deregister runner, remove services (asks first)
./setup converge --skip-xcode # useful while iteratingThe runner runs two hook scripts around every job (wired via
ACTIONS_RUNNER_HOOK_JOB_STARTED/_COMPLETED in the runner's .env;
disable with runner.cleanup_hooks = false in config.toml):
hooks/job-started.sh— shuts down and erases all simulators so every job starts from a pristine device state, and exportsselfhosted=trueinto$GITHUB_ENVso workflows can detect the self-hosted runner (if: env.selfhosted == 'true').hooks/job-completed.sh— wipes the runner's work directory (all checkouts and build products), clears Periphery's cache, and resets the simulators again.
jobs:
build:
# ARM64 on Apple Silicon, X64 on an Intel Mac; plus labels from config.toml
runs-on: [self-hosted, macOS, ARM64]
steps:
- uses: actions/checkout@v4
- run: xcodebuild build -scheme MyApp # uses the wired DEVELOPER_DIRJobs needing a specific Xcode can override it:
env: { DEVELOPER_DIR: /Applications/Xcode-26.0.0-Beta.5.app/Contents/Developer }.
- Never attach self-hosted runners to a public repository — fork PRs could execute arbitrary code on this machine.
- The PAT lives exclusively in the login Keychain (
github-runner-pat);config.tomlcarries no secrets and is safe to commit. Be aware that CI jobs run as the same user and can read that Keychain item while the session is unlocked (always, on an auto-login CI box) — use a fine-grained PAT limited to runner administration on exactly this repo/org so a compromised job can't do more than re-register runners. - Runner tarballs are SHA-256-verified against the official release notes; installation fails closed if no checksum is published.
- Registration/removal tokens are short-lived (1 h), never stored, and redacted from error messages/logs.
xcodeskeeps the Apple ID password/session in the login Keychain so unattended runs can install new Xcode releases (the old setup ranxcodes signoutinstead). Same caveat as the PAT: jobs run as this user — use a dedicated CI Apple ID with no other roles.
- Boot-time run did something odd →
~/Library/Logs/ci-runner-setup.log. - Runner offline after reboot → is auto-login enabled (step 7)? LaunchAgents only start once the user session exists.
xcodesasks for Apple ID again → sessions expire; run./setupinteractively once.- Service state →
cd ~/actions-runner && ./svc.sh status; runner logs in~/actions-runner/_diag/. - Changed
runner.dirin config.toml → run./setup uninstallwith the old config first, then converge; otherwise the old registration is orphaned.