Ziel: Eine abnahmefähige iOS-Build-Kette durchziehen
Bevor wir den Runner installieren, definieren wir die Abnahmekriterien für „Anschluss fertig“—sonst ist der Runner da, aber Archive hängt an der Signierung. Endpunkt dieses Praxisberichts: Push auf den Zielbranch → GitHub Actions startet auf dem dedizierten M4-Knoten → Checkout → xcodebuild archive erzeugt erfolgreich ein .xcarchive. TestFlight-Upload liegt außerhalb des Umfangs; sobald Archive stabil läuft, sind fastlane oder altool nur noch Zusatzschritte.
Das Test-Repo ist eine mittelgroße SwiftUI-App: ca. 60 Swift-Quelldateien, CocoaPods für Drittanbieter-Abhängigkeiten, Release mit Manual Signing. Basisumgebung: JexMac Mac mini M4 am Knoten Singapur (16 GB Unified Memory, 256 GB NVMe), Xcode 16.2 per xcodes installiert und festgenagelt.
Kontrollgruppe: dasselbe Repo auf GitHub-gehostetem runs-on: macos-14. UTC 13:00–18:00 (Asien-Pazifik-Nachmittag): mittlere Job-Warteschlange 11 Minuten, längste Wartezeit 19 Minuten; vorinstalliertes Xcode auf macos-14 wich von lokalen Dev-Maschinen ab und löste Swift-6-Kompatibilitätsprobleme aus. Der Nutzen von Self-Hosting ist hier klar—Wartezeit sinkt von Minuten auf Sekunden, und die Xcode-Version legen Sie fest auf dem Knoten.
Drei versteckte Kosten gehosteter macOS-Runner
Viele Teams wählen GitHub-gehostete Runner wegen „Zero Ops“—im iOS-Kontext sind die versteckten Kosten oft kniffliger als die Rechnung.
Erstens: Zeitkosten. Der macOS-Pool von GitHub ist kapazitätsbegrenzt. Kostenlose Konten haben 2000 Minuten/Monat; macOS zählt mit Faktor 10, effektiv bleiben ca. 200 Minuten. Ein Projekt mit 8 Builds/Tag à 6 Minuten verbraucht ca. 1440 gewichtete Minuten/Monat—nahe am Free-Cap; ein Nightly-Build reicht oft fürs Überziehen.
Zweitens: Environment Drift. Das Label macos-latest wechselt mit GitHub-Infrastruktur-Upgrades das zugrunde liegende Xcode—„gestern grün, heute rot“ kommt oft vor. Workaround: sudo xcode-select im Workflow; jeder Versionswechsel kostet 1–2 Minuten und garantiert trotzdem keine volle Übereinstimmung mit der lokalen Dev-Maschine.
Drittens: Debug-Kosten. Gehostete Runner werden nach jedem Job zerstört—kein SSH zum Reproduzieren. Keychain-Dialoge, abgelaufene Provisioning Profiles: Fehler, bei denen man „draufschauen“ müsste, erzwingen im Hosted-Setup wiederholte Pushes. Für einen User interaction is not allowed-Fehler haben wir 7-mal gepusht, bis die fehlende Partition List klar war.
Alle Befehle und Zeitmessungen in diesem Beitrag liefen auf einem dedizierten physischen JexMac Mac mini M4 in Singapur; Testfenster Ende Juli 2026. Hardware: Apple M4 · 10-Core-CPU · 16 GB Unified Memory · 1 Gbit/s dedizierte Bandbreite.
Nach der Bereitstellung: Erste SSH-Verbindung und Umgebungsbasislinie
Die JexMac-Konsole liefert SSH-Zugangsdaten nach Zahlungsbestätigung meist innerhalb von 1–5 Minuten. Sobald die öffentliche IP da ist, Ed25519-Zugang einrichten und Passwort-Login deaktivieren—erst dann den Runner installieren. Der Runner läuft als aktueller macOS-Benutzer; die SSH-Sicherheitsbasis muss zuerst stehen.
-
01
SSH-Public-Key eintragen
ssh-copy-id -i ~/.ssh/id_ed25519.pub jexmac@<Knoten-IP>Nach erfolgreichem passwortlosem Login
/etc/ssh/sshd_configbearbeiten,PasswordAuthentication nosetzen und sshd neu starten. -
02
Homebrew und xcodes installieren
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"brew install xcodesorg/made/xcodesxcodes install 16.2 --experimental-fast-passinstalliert und nagelt Xcode 16.2 fest (Versionsnummer je nach Projekt anpassen). -
03
Build-Kette prüfen
xcodebuild -versionsollteXcode 16.2und die Build-Nummer ausgeben.xcodebuild -showsdks | grep iphoneosbestätigt, dass das iOS-SDK verfügbar ist. -
04
CocoaPods installieren (falls genutzt)
sudo gem install cocoapods -n /usr/local/binEinmal
pod installauf dem Knoten vorab ausführen, damit der Specs-Repo-Cache bereit ist—so hängt der erste CI-Build nicht am Repo-Update.
Nach der Basislinie sollte auf dem Knoten ein eigenes Verzeichnis existieren (hier ~/ci-runner) für Runner und Build-Artefakte, getrennt von Alltagdateien. Darunter legen wir ein DerivedData-Unterverzeichnis an und setzen im Workflow explizit -derivedDataPath, um Pfadkonflikte bei parallelen Projekten zu vermeiden.
GitHub-Seite: Runner-Token und Label-Routing
Ziel-Repo öffnen → Settings → Actions → Runners → New self-hosted runner, Plattform macOS ARM64. Die Seite erzeugt ein einmaliges Registrierungs-Token (1 Stunde gültig) und den Download-Link.
Das Label-Design entscheidet, ob Workflows die richtige Maschine treffen. Unsere Namenskonvention:
mac: allgemeines Label für alle selbst gehosteten macOS-Knotenm4: Apple Silicon M4, zur Abgrenzung älterer Intel-Knotenxcode-16-2: Xcode-Minor-Version festnageln; bei Upgrade Label ändern, nicht die Workflow-Logiksg: Rechenzentrumsregion (Singapur); bei Multi-Region für näheres Routing nutzbar
Der Parameter --labels bei der Registrierung schreibt alle Labels auf einmal. Im Workflow per Array matchen:
jobs:
ios-archive:
runs-on: [self-hosted, mac, m4, xcode-16-2]
concurrency:
group: ios-build-${{ github.ref }}
cancel-in-progress: true
Eine concurrency-Gruppe verhindert, dass derselbe Branch zwei Archives parallel fährt—auf einem 16-GB-M4-Knoten können zwei Clean Builds parallel laufen, aber DerivedData-Konkurrenz lässt die Einzelzeit um 30 %+ schwanken. Bei mehreren Produktlinien Labels splitten (z. B. product-a, product-b) und mehrere Knoten nutzen, statt auf einer Maschine hart zu parallelisieren.
Selbst gehostete Runner in öffentlichen Repos können von Fork-PRs ausgelöst werden—böswillige Workflows führen beliebigen Code auf Ihrem Mac aus. Nur in privaten Repos oder auf Organisationsebene aktivieren und den Runner unter einem Nicht-Admin-Konto laufen lassen. In Produktion GitHub-Environment-Schutzregeln nutzen, damit Secrets nur auf festgelegten Branches verfügbar sind.
Runner auf dem M4-Knoten installieren und launchd-Persistenz konfigurieren
Die folgenden Schritte in einer SSH-Sitzung ausführen. Runner-Version gemäß GitHub-Registrierungsseite (Beispiel hier: v2.321.0).
-
01
Herunterladen und entpacken
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
Interaktive Registrierung
./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--unattendedüberspringt interaktive Bestätigung und eignet sich für skriptierte Deployments. Work-Folder Standard_workist in Ordnung. -
03
launchd-Dienst installieren
./svc.sh install./svc.sh startPrüfen:
./svc.sh statussollteactive (running)zeigen. Grüner Online-Status auf der Repo-Runners-Seite bedeutet erfolgreiche Registrierung.
launchd startet den Runner nach Neustart automatisch, aber ein Detail: Der Runner läuft als der macOS-Benutzer der Installation—dieser Benutzer muss mindestens einmal eingeloggt gewesen sein (oder Auto-Login aktivieren), sonst greift launchd ggf. nicht auf die Keychain zu. Wir haben das Konto ci-bot angelegt, Keychain beim ersten SSH-Login initialisiert und danach svc.sh installiert—danach kein manueller Eingriff nach Reboot nötig.
Runner-Logs liegen unter ~/ci-runner/actions-runner/_diag/; nach jedem Job entsteht Worker_*.log. Bei „Job dem Knoten zugewiesen, aber keine Step-Ausgabe“ zuerst die RunnerListener-Logs hier prüfen—vollständiger als Annotations in der GitHub-UI.
Minimaler Workflow: Von checkout bis Archive
Nach der Registrierung im Repo .github/workflows/ios-archive.yml anlegen. Unten unsere verifizierte Minimalversion—ohne TestFlight-Upload, Fokus auf Archive-Ausgabe:
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
Bewusst beibehaltene Designentscheidungen:
pod install --deployment sperrt Versionen über Podfile.lock und hält CI und lokale Abhängigkeiten synchron. -derivedDataPath zeigt auf ein festes Verzeichnis auf dem Knoten—Compile-Cache über Builds hinweg; Inkrementalbuilds sinken von 4 Min. auf ca. 1 Min. 40 Sek. upload-artifact lädt das xcarchive zu GitHub hoch, damit Kollegen ohne SSH herunterladen und prüfen können—7 Tage Aufbewahrung reichen für einen QA-Stichproben-Check.
Umgebung ohne GUI: Keychain und Distribution-Zertifikat importieren
Ob der Archive-Schritt gelingt, hängt davon ab, ob CI den Distribution-Private-Key ohne GUI erreichen kann. Gehostete Runner vorkonfigurieren die System-Keychain; selbst gehostete Knoten müssen Sie selbst verwalten—häufiger Grund, warum Teams bei „Runner installiert, Archive meldet Signierfehler“ hängen bleiben.
Empfohlen: Pro Job eine temporäre Keychain anlegen, p12 importieren, sofort signieren, am Job-Ende zerstören—so bleibt der Private Key nicht dauerhaft auf der Festplatte.
# Vor dem Schritt "Build archive" einfügen
- 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 }}
In GitHub Repository Secrets müssen drei Variablen vorab konfiguriert sein: KEYCHAIN_PASSWORD (temporäres Keychain-Passwort—ein Zufallsstring reicht), CERTIFICATE_P12_BASE64 (Base64-kodiertes Distribution-Zertifikat), P12_PASSWORD (Passwort beim Export der p12).
| Fehler-Stichwort | Häufige Ursache | Lösung |
|---|---|---|
User interaction is not allowed |
Private-Key-Partition-List nicht gesetzt; codesign versucht, eine UI zu öffnen | set-key-partition-list erneut ausführen (siehe Skript oben) |
errSecItemNotFound |
Zertifikat in die falsche Keychain importiert, oder default-keychain nicht umgestellt | Sicherstellen, dass security default-keychain -s build.keychain vor dem Import läuft |
| could not find signing certificate | Zeichenkette CODE_SIGN_IDENTITY stimmt nicht exakt mit der Identity in der Keychain überein |
security find-identity -v -p codesigning build.keychain ausführen und den vollständigen Namen kopieren |
| Provisioning profile doesn't match | Provisioning Profile abgelaufen oder Bundle ID / Capability stimmt nicht überein | Im Apple-Developer-Portal neu erzeugen, herunterladen und per Secrets oder match synchronisieren |
Nach erfolgreichem Import schnell im Job-Log prüfen mit einem Befehl:
security find-identity -v -p codesigning build.keychain | grep Distribution
Erst wenn 1 valid identities found erscheint, den Archive-Schritt starten—spart viel Zeit bei „Signierung fehlgeschlagen, aber unklar wo“.
Fehlerbehebung aus der Praxis: Drei Probleme, die uns einen Extra-Push kosteten
Auch bei Beachtung der Schritte oben kann der Erstanschluss hier hängen—echte Stolpersteine von uns, mit Log-Merkmalen und Fixes.
Label-Mismatch: Job bleibt ewig in der Warteschlange
Symptom: GitHub-Actions-UI zeigt Job-Status Queued, auf der Runners-Seite ist der Knoten Online. Ursache: Die Labels unter runs-on im Workflow stimmen nicht exakt mit der Registrierung überein—z. B. Workflow xcode-16.2 (Punkt), Registrierung xcode-16-2 (Bindestrich). GitHub-Label-Matching ist exakter String-Vergleich; ein Zeichen Unterschied verhindert Routing.
Lösung: Auf der Repo-Runners-Seite den Knotennamen anklicken, die echte Label-Liste anzeigen und per Copy-Paste ins YAML übernehmen—nicht abtippen.
Runner Offline nach launchd-Neustart
Symptom: Nach Knoten-Reboot oder Wartungsneustart seitens JexMac zeigt der Runner Offline; manuell per SSH ./svc.sh start nötig. Ursache: UserName in der launchd-plist stimmt nicht mit dem SSH-Login-Benutzer überein, oder dieser Benutzer hat nie die erste Grafik-/Session-Anmeldung abgeschlossen.
Lösung: Sicherstellen, dass ./svc.sh install und ./svc.sh start unter demselben Benutzer laufen; nach Reboot ./svc.sh status prüfen. Bei weiterem Fehler stderr-Logs unter /Library/Logs/GitHubActionsRunner/ ansehen.
DerivedData-Berechtigungskonflikt
Symptom: Beim zweiten Build Unable to write to DerivedData oder permission denied bei .o-Dateien. Ursache: Der erste Job hat DerivedData als root oder anderer Benutzer angelegt; Folgejobs haben keine Schreibrechte.
Lösung: DerivedData-Pfad vereinheitlichen und am Workflow-Anfang bereinigen: rm -rf ~/ci-runner/DerivedData && mkdir -p ~/ci-runner/DerivedData. Alternativ vor jedem Archive nur das Hash-Unterverzeichnis des Projekts löschen und den wiederverwendbaren Compile-Cache behalten.
Kein dauerhaft laufender Mac: Dedizierten Knoten nach Build-Rhythmus mieten
An diesem Punkt wird klar: Die technische Hürde für selbst gehostete GitHub-Actions-Runner ist überschaubar—der echte Engpass ist eine rund um die Uhr online erreichbare macOS-Physikmaschine mit kontrollierbarer Xcode-Version. Lokale MacBooks eignen sich schlecht als CI-Host—bei 8 GB RAM laufen Archive mit vollem Lüfter und häufigem Swap; Firmenkauf eines Mac mini bedeutet Anlagenfreigabe, Colocation und Vor-Ort-Wartung bei Zertifikatsrotation.
Unser Vorgehen: In Sprint-Phasen (z. B. zwei Wochen vor Release) einen dedizierten JexMac Mac mini M4 als Runner-Host mieten, nach stabilem Launch wöchentlich verlängern oder freigeben. Standard: 16 GB Unified Memory, 256 GB NVMe, 1 Gbit/s dedizierte Bandbreite, ab $21.5/Tag, SSH/VNC in 1–5 Minuten nach Zahlung, ohne Vertragssperre. Fünf Knoten—Singapur, Japan (Tokio), Korea (Seoul), Hongkong, US-Ost—nach Teamstandort wählen, um Latenz bei git fetch und CocoaPods-Specs-Sync zu senken.
Vergleich mit GitHub-gehostetem macOS-Runner: Peak-Warteschlange 10–20 Min. vs. Self-Hosting Start in ca. 25 Sek.; macOS-Minuten mit Faktor 10 vs. planbare Tagesmiete. Vergleich mit Eigenkauf: kein Upfront; Upgrade per Label-Wechsel oder Xcode-Neuinstallation, ohne Beschaffungszyklus.
Derselbe Knoten kann parallel als Remote-Dev-Desktop dienen—Browser-VNC für UI-Debug, Instruments für Profiling; CI-Idle-Zeit bleibt nutzbar. Für Teams aus 2–3 Personen ist „eine M4-Physikmaschine = Runner + Remote-Mac-Dev-Umgebung“ oft günstiger als getrennte Hosted-Runner-Minuten plus lokales RAM-Upgrade.
Runner mit Ihrem M4-Knoten verbinden
Alle Befehle im Beitrag wurden auf dedizierten physischen JexMac Mac mini M4-Knoten verifiziert. Bereitstellen → SSH → Runner registrieren—Archive oft innerhalb einer Stunde. Tagesmiete ab $21.5, nach Sprint freigeben, ohne Jahresbindung.