Goal: A Verifiable End-to-End iOS Build Pipeline
Before installing the Runner, we defined what "setup complete" means—so you don't finish Runner install only to get stuck at signing. The finish line for this walkthrough: push to a target branch → GitHub Actions triggers on the dedicated M4 node → checkout code → run xcodebuild archive and successfully produce a .xcarchive. TestFlight upload is out of scope here, but once Archive is stable, fastlane or altool is just an extra step.
The test repo is a mid-size SwiftUI app: ~60 Swift source files, CocoaPods for third-party deps, Release config using Manual signing. Baseline environment: JexMac Mac mini M4 in Singapore (16 GB unified memory, 256 GB NVMe), Xcode 16.2 installed and pinned via xcodes.
Control group: same repo on GitHub-hosted runs-on: macos-14. UTC 13:00–18:00 (Asia-Pacific afternoon): median job queue 11 minutes, longest wait 19 minutes; preinstalled Xcode on macos-14 didn't match local dev machines, triggering Swift 6 compatibility issues. Self-hosting pays off clearly here—wait time drops from minutes to seconds, and Xcode version is pinned on your node.
Three Hidden Costs of Hosted macOS Runners
Many teams pick GitHub-hosted Runners for "zero ops," but in iOS workflows, hidden costs often hurt more than the bill.
First: time cost. GitHub's macOS pool is capacity-limited. Free accounts get 2,000 minutes/month; macOS counts at 10×, so ~200 effective minutes. A project with 8 daily builds at 6 minutes each uses ~1,440 weighted minutes/month—near the free cap; add a nightly build and you exceed it.
Second: environment drift. The macos-latest label switches underlying Xcode as GitHub upgrades infra—"green yesterday, red today" happens often. A workaround is sudo xcode-select in the workflow, but each version switch adds 1–2 minutes and still won't match your local dev machine exactly.
Third: debugging cost. Hosted Runners are destroyed after each job—you can't SSH in to reproduce. Keychain prompts, expired provisioning profiles—issues that need "log in and look"—force repeated pushes in hosted envs. We pushed 7 times for one User interaction is not allowed error before finding a missing partition list.
All commands and timings in this post were run on a dedicated JexMac Mac mini M4 physical node in Singapore, test window late July 2026. Hardware: Apple M4 · 10-core CPU · 16 GB unified memory · 1 Gbps dedicated bandwidth.
After Delivery: First SSH Connection and Environment Baseline
JexMac console usually delivers SSH credentials within 1–5 minutes after payment. Once you have the public IP, set up Ed25519 key access and disable password login before installing the Runner—the Runner process runs as the current macOS user, so SSH security baseline comes first.
-
01
Add SSH public key
ssh-copy-id -i ~/.ssh/id_ed25519.pub jexmac@<node-ip>After passwordless login works, edit
/etc/ssh/sshd_configto setPasswordAuthentication no, then restart sshd. -
02
Install Homebrew and xcodes
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"brew install xcodesorg/made/xcodesxcodes install 16.2 --experimental-fast-passinstalls and pins Xcode 16.2 (adjust version per project). -
03
Verify build chain
xcodebuild -versionshould outputXcode 16.2and the build number.xcodebuild -showsdks | grep iphoneosconfirms iOS SDK is available. -
04
Install CocoaPods (if used)
sudo gem install cocoapods -n /usr/local/binRun
pod installon the node early so Specs repo cache is ready—avoids CI first build stalling on repo update.
After baseline setup, the node should have a dedicated directory (we use ~/ci-runner) for Runner and build artifacts, isolated from daily dev files. We reserve a DerivedData subdirectory and set -derivedDataPath explicitly in workflows to prevent path conflicts when multiple projects run concurrently.
GitHub Side: Runner Token and Label Routing Design
Go to target repo → Settings → Actions → Runners → New self-hosted runner, platform macOS ARM64. The page generates a one-time registration token (1-hour validity) and download link.
Label design directly affects whether workflows route to the right machine. Our naming convention:
mac: generic label for all macOS self-hosted nodesm4: Apple Silicon M4 chip, to distinguish from older Intel nodesxcode-16-2: pin Xcode minor version; change label on upgrade, not workflow logicsg: datacenter region (Singapore); use for nearest routing in multi-region setups
The --labels flag in registration writes all labels at once. In the workflow, match with array form:
jobs:
ios-archive:
runs-on: [self-hosted, mac, m4, xcode-16-2]
concurrency:
group: ios-build-${{ github.ref }}
cancel-in-progress: true
A concurrency group ensures the same branch won't run two Archives in parallel—on a 16 GB M4 node, dual-project concurrent Clean Builds can run, but DerivedData contention swings single-run time by 30%+. For multiple product lines, split labels (e.g. product-a, product-b) and use separate nodes rather than hard concurrency on one machine.
Self-hosted Runners on public repos can be triggered by fork PRs—malicious workflows run arbitrary code on your Mac. Enable only on private repos or at Organization level, and run the Runner process under a non-admin account. Production setups should use GitHub Environment protection rules to limit secrets to specified branches.
Install Runner on M4 Node and Configure launchd Persistence
Run the following in an SSH session. Runner version follows GitHub registration page (example here: v2.321.0).
-
01
Download and extract
mkdir -p ~/ci-runner/actions-runner && cd ~/ci-runner/actions-runnercurl -o actions-runner-osx-arm64-2.321.0.tar.gz -L \ https://github.com/actions/runner/releases/download/v2.321.0/actions-runner-osx-arm64-2.321.0.tar.gztar xzf ./actions-runner-osx-arm64-2.321.0.tar.gz -
02
Interactive registration
./config.sh --url https://github.com/YOUR_ORG/YOUR_REPO \ --token YOUR_ONE_TIME_TOKEN \ --name jexmac-m4-sg-01 \ --labels mac,m4,xcode-16-2,sg \ --unattended--unattendedskips interactive confirmation, suitable for scripted deploy. Default_workfolder is fine. -
03
Install launchd service
./svc.sh install./svc.sh startVerify:
./svc.sh statusshould showactive (running). Green Online on the repo Runners page means registration succeeded.
launchd auto-starts the Runner after reboot, but one detail: the Runner runs as the macOS user who installed it—that user must have logged in at least once (or enable auto-login), otherwise launchd may not access Keychain. We created dedicated account ci-bot, completed Keychain init on first SSH login, then installed svc.sh—no manual intervention after reboot.
Runner logs live at ~/ci-runner/actions-runner/_diag/; each job produces Worker_*.log. For "job assigned to node but no step output" issues, check RunnerListener logs here first—they're more complete than GitHub UI Annotations.
Minimal Workflow: From checkout to Archive
After registration, create .github/workflows/ios-archive.yml in the repo. Below is our verified minimal version—no TestFlight upload, Archive output only:
name: iOS Archive
on:
push:
branches: [main, release/*]
pull_request:
branches: [main]
jobs:
archive:
runs-on: [self-hosted, mac, m4, xcode-16-2]
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Select Xcode
run: xcodes select 16.2
- name: Install pods
run: pod install --deployment
working-directory: ios
- name: Build archive
run: |
xcodebuild archive \
-workspace ios/MyApp.xcworkspace \
-scheme MyApp \
-sdk iphoneos \
-configuration Release \
-archivePath ./build/MyApp.xcarchive \
-derivedDataPath ~/ci-runner/DerivedData \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="Apple Distribution: Your Team (TEAMID)" \
PROVISIONING_PROFILE_SPECIFIER="MyApp AppStore"
env:
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
- name: Upload xcarchive artifact
uses: actions/upload-artifact@v4
with:
name: MyApp-xcarchive
path: ./build/MyApp.xcarchive
retention-days: 7
Deliberate design choices:
pod install --deployment locks versions via Podfile.lock, keeping CI and local deps in sync. -derivedDataPath points to a fixed directory on the node—reuse compile cache across builds; incremental builds drop from 4 min to ~1 min 40 sec. upload-artifact uploads xcarchive to GitHub for colleagues without SSH to download and verify—7-day retention is enough for QA spot checks.
Headless Environment: Keychain and Distribution Certificate Import
Archive success depends on CI accessing the Distribution private key without a GUI. Hosted Runners preconfigure system Keychain; self-hosted nodes require your own setup—often why teams stall at "Runner installed but Archive signing fails."
Recommended: create a temporary Keychain per job, import p12, sign immediately, destroy after job—avoids long-term private key storage on disk.
# Add before the "Build archive" step
- name: Import signing certificate
run: |
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
echo "${{ secrets.CERTIFICATE_P12_BASE64 }}" | base64 --decode > cert.p12
security import cert.p12 \
-k build.keychain \
-P "${{ secrets.P12_PASSWORD }}" \
-T /usr/bin/codesign \
-T /usr/bin/xcodebuild
security set-key-partition-list \
-S apple-tool:,apple: \
-s -k "$KEYCHAIN_PASSWORD" build.keychain
rm -f cert.p12
env:
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
Configure three variables in GitHub Repository Secrets beforehand: KEYCHAIN_PASSWORD (temporary Keychain password—a random string is fine), CERTIFICATE_P12_BASE64 (Base64-encoded Distribution certificate), P12_PASSWORD (password set when exporting the p12).
| Error Keyword | Common Root Cause | Fix |
|---|---|---|
User interaction is not allowed |
Private key partition list not set; codesign tries to show UI | Re-run set-key-partition-list command (see script above) |
errSecItemNotFound |
Certificate imported to wrong Keychain, or default-keychain not switched | Confirm security default-keychain -s build.keychain runs before import |
| could not find signing certificate | CODE_SIGN_IDENTITY string doesn't exactly match Identity in Keychain |
Run security find-identity -v -p codesigning build.keychain and copy the full name |
| Provisioning profile doesn't match | Provisioning profile expired or Bundle ID / Capability mismatch | Regenerate in Apple Developer portal, download, sync via secrets or match |
After import, verify quickly in job logs with one command:
security find-identity -v -p codesigning build.keychain | grep Distribution
See 1 valid identities found before the Archive step—saves hours of "signing failed but don't know where" debugging.
Troubleshooting Log: Three Issues That Cost Us an Extra Push
Even following the steps above, first-time setup may hit these—we've been there. Log signatures and fixes included.
Label Mismatch: Job Stuck Queued Forever
Symptom: GitHub Actions UI shows job Queued, Runners page shows node Online. Root cause: workflow runs-on labels don't exactly match registration—e.g. workflow has xcode-16.2 (dot), registration used xcode-16-2 (hyphen). GitHub label matching is exact string comparison; one character off and no routing.
Fix: On repo Runners page, click node name, view actual label list, copy-paste into YAML—don't type by hand.
Runner Offline After launchd Restart
Symptom: After node reboot or JexMac maintenance restart, Runner shows Offline; must SSH and run ./svc.sh start. Root cause: launchd plist UserName doesn't match SSH login user, or that user never completed first graphical/session login.
Fix: Confirm ./svc.sh install and ./svc.sh start run under the same user; after reboot check ./svc.sh status. If still failing, check stderr logs under /Library/Logs/GitHubActionsRunner/.
DerivedData Permission Conflict
Symptom: Second build fails with Unable to write to DerivedData or .o file permission denied. Root cause: first job created DerivedData as root or different user; subsequent job lacks write access.
Fix: Unify DerivedData path and add cleanup at workflow start: rm -rf ~/ci-runner/DerivedData && mkdir -p ~/ci-runner/DerivedData. Or clear only the project's hash subdirectory before each Archive to keep reusable compile cache.
No Always-On Mac? Rent a Dedicated Node by Build Cadence
By now you may see: self-hosted GitHub Actions Runner setup isn't hard—the real bottleneck is having a 24/7 online macOS physical machine with controllable Xcode version. Local MacBooks aren't ideal CI hosts—8 GB models max fans and swap during Archive; buying a company Mac mini means asset approval, colocation, and on-site cert rotation.
Our approach: rent a dedicated JexMac Mac mini M4 node as Runner host during sprint cycles (e.g. two weeks before release), renew weekly or release after launch stabilizes. Standard config: 16 GB unified memory, 256 GB NVMe, 1 Gbps dedicated bandwidth, from $21.5/day, SSH/VNC access in 1–5 minutes after payment, no contract lock-in. Five nodes—Singapore, Japan (Tokyo), Korea (Seoul), Hong Kong, US East—pick by team location to reduce git fetch and CocoaPods Specs sync latency.
vs GitHub hosted macOS Runner: peak queue 10–20 min vs self-hosted start in ~25 sec; macOS minutes billed at 10× vs predictable fixed daily rent. vs buying hardware: zero upfront; upgrade by changing labels or reinstalling Xcode, no procurement cycle.
Same node can double as remote dev desktop—browser VNC for UI debugging, Instruments for profiling; CI idle time isn't wasted. For small teams of 2–3, "one M4 physical machine = Runner + remote Mac dev environment" often beats separate hosted Runner minutes and local RAM upgrades.
Connect Your Runner to an M4 Node
All commands in this post verified on dedicated JexMac Mac mini M4 physical nodes. Provision → SSH access → register Runner per steps—Archive running within an hour. Daily rental from $21.5, release after sprint, no annual lock-in.