1–5 Min. Lieferung

Dedizierter M4-Runner—Schluss mit der macOS-Warteschlange

$21.5 Ab /Tag · Physische Maschine exklusiv
Mac mini mieten
M4 · 16 GB Xcode-Version festnageln launchd-Persistenz

FIELD NOTE · iOS-Build

Dedizierten M4-Knoten als GitHub Actions Runner registrieren: iOS-Pipeline-Anschluss in der Praxis

Kein dauerhaft laufender Mac, aber stabile iOS-Archive-Builds auf GitHub? Dieser Beitrag dokumentiert unseren kompletten Anschluss auf einem dedizierten physischen JexMac Mac mini M4: von der ersten SSH-Verbindung, Runner-Registrierung und launchd-Persistenz über Workflow-Label-Routing und festgenagelte Xcode-Version bis zum Import des Distribution-Zertifikats ohne GUI—jeder Schritt mit reproduzierbaren Befehlen und gemessenen Zeiten.

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.

4m 08s
Clean Archive gemessen (inkl. pod install)
< 25s
Selbst gehosteter Job: Warteschlange bis Start
0 swap
16 GB RAM, durchgehend ohne Swap
1
Dedizierte physische Maschine, keine Virtualisierung oder Überbuchung

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.

Hinweise zur Testumgebung

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.

  1. 01
    SSH-Public-Key eintragen

    ssh-copy-id -i ~/.ssh/id_ed25519.pub jexmac@<Knoten-IP>

    Nach erfolgreichem passwortlosem Login /etc/ssh/sshd_config bearbeiten, PasswordAuthentication no setzen und sshd neu starten.

  2. 02
    Homebrew und xcodes installieren

    /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

    brew install xcodesorg/made/xcodes

    xcodes install 16.2 --experimental-fast-pass installiert und nagelt Xcode 16.2 fest (Versionsnummer je nach Projekt anpassen).

  3. 03
    Build-Kette prüfen

    xcodebuild -version sollte Xcode 16.2 und die Build-Nummer ausgeben.

    xcodebuild -showsdks | grep iphoneos bestätigt, dass das iOS-SDK verfügbar ist.

  4. 04
    CocoaPods installieren (falls genutzt)

    sudo gem install cocoapods -n /usr/local/bin

    Einmal pod install auf 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-Knoten
  • m4: Apple Silicon M4, zur Abgrenzung älterer Intel-Knoten
  • xcode-16-2: Xcode-Minor-Version festnageln; bei Upgrade Label ändern, nicht die Workflow-Logik
  • sg: 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.

Sicherheitsgrenze bei öffentlichen Repos

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).

  1. 01
    Herunterladen und entpacken

    mkdir -p ~/ci-runner/actions-runner && cd ~/ci-runner/actions-runner

    curl -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.gz

    tar xzf ./actions-runner-osx-arm64-2.321.0.tar.gz

  2. 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 _work ist in Ordnung.

  3. 03
    launchd-Dienst installieren

    ./svc.sh install

    ./svc.sh start

    Prüfen: ./svc.sh status sollte active (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.

Physische Maschine exklusiv · 1–5 Min. Lieferung

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.

Standardkonfiguration
ChipApple M4 · 38 TOPS
CPU10 Kerne (4P + 6E)
Arbeitsspeicher16 GB Unified Memory
Netzwerk1 Gbit/s dedizierte Bandbreite
SLA99,9 % Verfügbarkeit
Lieferung1–5 Min. automatische Bereitstellung