Mac Mini M4 Build-System · GitLab Runner 19.x · macOS Sequoia · Tart · KMP

Für das Build-System des Mac Mini M4 wollte ich macOS-Builds für unsere Kotlin-Multiplatform-App so betreiben, dass jeder CI-Job in einer frischen, wegwerfbaren VM läuft und der Runner niemals unter einem Admin-Account arbeitet. Dieser Beitrag dokumentiert die komplette Einrichtung — vom dedizierten Service-User über die Tart-VM-Isolation bis zum signierten TestFlight-Deployment via Fastlane und GitLab Secure Files.

Vollständige Anleitung zur Einrichtung eines sicheren, isolierten macOS Build-Systems für Kotlin Multiplatform iOS Apps — mit dediziertem Service-User, Tart-VM-Isolation, signierten Builds und TestFlight-Deployment.


Inhaltsverzeichnis

  1. Dedizierter Service-User einrichten
  2. GitLab Runner installieren
  3. Ruby-Umgebung konfigurieren
  4. Build-Bot absichern
  5. Tart + gitlab-tart-executor einrichten
  6. Runner registrieren & konfigurieren
  7. Tart VM-Image mit Xcode & Ruby vorbereiten
  8. Signierte KMP iOS Apps bauen
  9. Fastlane einrichten
  10. GitLab CI/CD Variables setzen
  11. Vollständige .gitlab-ci.yml
  12. Referenz & Troubleshooting

1. Dedizierter Service-User einrichten

Warum zuerst? Alle nachfolgenden Schritte bauen auf diesem User auf. Der Runner, Tart und alle Build-Verzeichnisse laufen unter gitlab-runner — niemals unter einem Admin-Account. Das begrenzt den Schaden bei einem kompromittierten Build-Job auf /Users/gitlab-runner/.

User anlegen

sudo dscl . -create /Users/gitlab-runner
sudo dscl . -create /Users/gitlab-runner UserShell /bin/bash
sudo dscl . -create /Users/gitlab-runner RealName "GitLab Runner"
sudo dscl . -create /Users/gitlab-runner UniqueID 600
sudo dscl . -create /Users/gitlab-runner PrimaryGroupID 20
sudo dscl . -create /Users/gitlab-runner NFSHomeDirectory /Users/gitlab-runner
sudo createhomedir -c -u gitlab-runner

Wichtig: Den User nicht zur Admin-Gruppe hinzufügen. Er braucht keine erweiterten Rechte.

Build- und Cache-Verzeichnisse anlegen

sudo mkdir -p /Users/gitlab-runner/builds
sudo mkdir -p /Users/gitlab-runner/cache
sudo chown -R gitlab-runner:staff /Users/gitlab-runner/builds
sudo chown -R gitlab-runner:staff /Users/gitlab-runner/cache

User verifizieren

id gitlab-runner
ls -la /Users/gitlab-runner/

2. GitLab Runner installieren

Hinweis: GitLab pflegt die Homebrew-Formel nicht offiziell, sie funktioniert jedoch für die meisten Anwendungsfälle zuverlässig. Die Installation erfolgt unter dem Admin-Account, der Service läuft später unter gitlab-runner.

Via Homebrew (als Admin-User)

brew install gitlab-runner

Version prüfen:

gitlab-runner --version

Runner als Service unter gitlab-runner einrichten

sudo -u gitlab-runner gitlab-runner install \
  --user gitlab-runner \
  --working-directory /Users/gitlab-runner

sudo -u gitlab-runner gitlab-runner start

Status prüfen:

sudo -u gitlab-runner gitlab-runner status

Wichtig: Nur eine Methode verwenden (gitlab-runner install oder brew services), nicht beide gleichzeitig. Da wir den Service unter einem bestimmten User starten wollen, nutzen wir hier gitlab-runner install.

Nützliche Befehle

sudo -u gitlab-runner gitlab-runner stop      # Stoppen
sudo -u gitlab-runner gitlab-runner restart   # Neu starten
sudo -u gitlab-runner gitlab-runner status    # Status prüfen
sudo -u gitlab-runner gitlab-runner list      # Registrierte Runner anzeigen
brew upgrade gitlab-runner                    # Aktualisieren (als Admin)

3. Ruby-Umgebung konfigurieren

Problem: System-Ruby

macOS liefert Ruby 2.6 unter /Library/Ruby mit — Schreibrechte fehlen dort bewusst. Nie auf das System-Ruby schreiben.

ERROR: While executing gem ...
You don't have write permissions for the /Library/Ruby/Gems/2.6.0 directory.

Lösung: Ruby via Homebrew (für gitlab-runner User)

# Als Admin installieren
brew install ruby

# Binary für gitlab-runner User zugänglich machen
sudo ln -sf /opt/homebrew/opt/ruby/bin/ruby /usr/local/bin/ruby
sudo ln -sf /opt/homebrew/opt/ruby/bin/gem  /usr/local/bin/gem
sudo ln -sf /opt/homebrew/bin/bundle        /usr/local/bin/bundle

PATH für den gitlab-runner User dauerhaft setzen:

sudo -u gitlab-runner bash -c 'echo '\''export PATH="/opt/homebrew/opt/ruby/bin:/opt/homebrew/bin:$PATH"'\'' >> /Users/gitlab-runner/.zprofile'
sudo -u gitlab-runner bash -c 'echo '\''export PATH="/opt/homebrew/opt/ruby/bin:/opt/homebrew/bin:$PATH"'\'' >> /Users/gitlab-runner/.bash_profile'

Prüfen:

sudo -u gitlab-runner bash -l -c 'which ruby && ruby --version'
# → /opt/homebrew/opt/ruby/bin/ruby
# → ruby 3.x.x

Bundler installieren:

sudo -u gitlab-runner bash -l -c 'gem install bundler -v "~> 2.5" --no-document'

Runner PATH permanent in config.toml setzen

Da der Runner eine non-interactive Shell verwendet, PATH zusätzlich in der Runner-Konfiguration setzen (Details in Abschnitt 6):

[[runners]]
  environment = [
    "PATH=/opt/homebrew/opt/ruby/bin:/opt/homebrew/opt/openjdk@17/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
  ]

4. Build-Bot absichern

Ein Build-Bot hat Zugriff auf Code, Secrets und Netzwerk — er ist ein attraktives Angriffsziel.

Prioritäten

PrioritätMaßnahmeStatus nach Abschnitt 1
🔴 KritischSecrets nur als GitLab CI Variablesmanuell
🔴 KritischRunner auf Protected + Tagged setzenmanuell
🔴 KritischDedizierter Non-Admin Service-User✅ erledigt
🟡 WichtigmacOS Firewall aktivierenmanuell
🟡 WichtigTart für Job-Isolation→ Abschnitt 5
🟢 LangfristigSystem regelmäßig aktualisierenmanuell

macOS Sicherheitsfeatures prüfen

spctl --status      # Gatekeeper: "assessments enabled"
csrutil status      # SIP: "enabled"
fdesetup status     # FileVault-Status

Firewall aktivieren

Systemeinstellungen → Netzwerk → Firewall → Aktivieren

Netzwerk-Whitelist im Router:

  • Ausgehend: GitLab-Server, rubygems.org, npmjs.com, gradle-cdn, ghcr.io (für Tart-Images)
  • Eingehend: Nichts (kein SSH von außen)

Runner-Einstellungen in GitLab

In GitLab → Settings → CI/CD → Runners:

  • Protected aktivieren — nur Jobs von protected Branches
  • Tags verwenden — verhindert unautorisierten Zugriff fremder Projekte

System aktuell halten

sudo softwareupdate --schedule on
brew update && brew upgrade

Sicherheitsvergleich der Executor-Typen

Shell RunnerTart Runner
Isolation❌ keine✅ eigene VM pro Job
Dateisystem-ZugriffVoller User-ZugriffNur innerhalb der VM
Sicherheit bei KompromittierungGesamter User betroffenNur die VM
GeschwindigkeitSehr schnell~30s VM-Startzeit
Parallel Jobs⚠️ riskant✅ max. 2 gleichzeitig

5. Tart + gitlab-tart-executor einrichten

Konzept

GitLab → Runner (läuft als gitlab-runner) → gitlab-tart-executor → frische VM pro Job → Job läuft → VM gelöscht

Jeder CI-Job bekommt eine saubere, isolierte macOS-VM. Nach dem Job wird sie vollständig verworfen.

Installation (als Admin)

brew install cirruslabs/cli/gitlab-tart-executor

Binaries für gitlab-runner User zugänglich machen:

sudo ln -sf /opt/homebrew/bin/tart                  /usr/local/bin/tart
sudo ln -sf /opt/homebrew/bin/gitlab-tart-executor  /usr/local/bin/gitlab-tart-executor

Prüfen:

sudo -u gitlab-runner tart --version
sudo -u gitlab-runner gitlab-tart-executor --version

Tart-VM-Speicherort für gitlab-runner User einrichten

Tart speichert VM-Images standardmäßig unter ~/.tart. Das muss für den gitlab-runner User erreichbar sein:

sudo mkdir -p /Users/gitlab-runner/.tart
sudo chown -R gitlab-runner:staff /Users/gitlab-runner/.tart

6. Runner registrieren & konfigurieren

Runner registrieren (als gitlab-runner User)

sudo -u gitlab-runner gitlab-runner register
FeldWert
GitLab URLhttps://dein-gitlab.de/
Registration TokenGitLab → Settings → CI/CD → Runners
BeschreibungMac Mini M4 Tart Runner
Tagstart,macos,ios
Executorcustom

config.toml konfigurieren

Die Konfigurationsdatei liegt unter /Users/gitlab-runner/.gitlab-runner/config.toml:

sudo -u gitlab-runner nano /Users/gitlab-runner/.gitlab-runner/config.toml
concurrent = 2
check_interval = 0

[[runners]]
  name = "Mac Mini M4 Tart Runner"
  url = "https://dein-gitlab.de/"
  token = "DEIN_TOKEN"
  executor = "custom"
  builds_dir = "/Users/gitlab-runner/builds"
  cache_dir  = "/Users/gitlab-runner/cache"

  environment = [
    "PATH=/opt/homebrew/opt/ruby/bin:/opt/homebrew/opt/openjdk@17/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",
    "HOME=/Users/gitlab-runner"
  ]

  [runners.feature_flags]
    FF_RESOLVE_FULL_TLS_CHAIN = false

  [runners.custom]
    prepare_exec = "/usr/local/bin/gitlab-tart-executor"
    prepare_args = ["--user", "gitlab-runner", "prepare"]
    run_exec     = "/usr/local/bin/gitlab-tart-executor"
    run_args     = ["--user", "gitlab-runner", "run"]
    cleanup_exec = "/usr/local/bin/gitlab-tart-executor"
    cleanup_args = ["--user", "gitlab-runner", "cleanup"]

macOS Sequoia: Der --user-Parameter mit gitlab-runner umgeht den Local Network GUI-Popup, der sonst bei jedem VM-Start erscheint.

Runner neu starten

sudo -u gitlab-runner gitlab-runner restart
sudo -u gitlab-runner gitlab-runner status

7. Tart VM-Image mit Xcode & Ruby vorbereiten

Base-Image pullen (als gitlab-runner User)

sudo -u gitlab-runner tart pull ghcr.io/cirruslabs/macos-sequoia-xcode:latest
sudo -u gitlab-runner tart clone ghcr.io/cirruslabs/macos-sequoia-xcode:latest macos-ios-build
sudo -u gitlab-runner tart run macos-ios-build

In der VM einrichten

In der laufenden VM (per SSH oder GUI) folgendes ausführen:

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

# Ruby via Homebrew
brew install ruby
echo 'export PATH="/opt/homebrew/opt/ruby/bin:/opt/homebrew/bin:$PATH"' >> ~/.zprofile

# Fastlane + Bundler
source ~/.zprofile
gem install bundler fastlane --no-document

# Java für Gradle / KMP
brew install openjdk@17
echo 'export PATH="/opt/homebrew/opt/openjdk@17/bin:$PATH"' >> ~/.zprofile

# Xcode Command Line Tools & License
sudo xcodebuild -license accept
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer

# Prüfen
ruby --version      # → 3.x.x
java --version      # → openjdk 17.x.x
fastlane --version  # → fastlane x.x.x

# VM herunterfahren und Image sichern
sudo shutdown -h now

VM-Image verifizieren

sudo -u gitlab-runner tart list
# macos-ios-build sollte in der Liste erscheinen

8. Signierte KMP iOS Apps bauen

Gesamtarchitektur

GitLab CI
  → Tart VM (läuft als gitlab-runner)
    → Fastlane setup_ci (temporärer Keychain)
    → Fastlane match (Zertifikate aus GitLab Secure Files)
    → Gradle → Kotlin/Native Framework (linkReleaseFrameworkIosArm64)
    → Xcode / gym → .ipa signieren & archivieren
    → upload_to_testflight → App Store Connect / TestFlight

Voraussetzungen im Apple Developer Portal

  • Distribution Certificate (.cer + privater Schlüssel .p12)
  • App Store Provisioning Profile (.mobileprovision)
  • App Store Connect API Key (.p8 — für automatisches Upload ohne 2FA)

Xcode-Projekt auf manuelles Signing umstellen

Xcode → Target → Signing & Capabilities
  → Automatically manage signing: ❌ AUS
  → Provisioning Profile: match AppStore de.animexx.rpgapp

Manuelles Signing ist in CI zwingend erforderlich — automatisches Signing benötigt einen interaktiven Apple-Login.


9. Fastlane einrichten

Installation in der VM (bereits in Abschnitt 7 erledigt)

Lokal im Projekt für konsistente Versionen:

# Im Projektverzeichnis
bundle init

Gemfile:

source "https://rubygems.org"
gem "fastlane"
bundle install

Fastlane Match mit GitLab Secure Files initialisieren

GitLab Secure Files ist das empfohlene Storage-Backend — Zertifikate bleiben direkt in GitLab, kein separates Repository nötig.

PRIVATE_TOKEN=dein-gitlab-personal-access-token \
  bundle exec fastlane match init
# → Storage Backend wählen: gitlab_secure_files
# → Project path eingeben: deine-gruppe/rpg-app

fastlane/Matchfile:

storage_mode("gitlab_secure_files")
gitlab_project("deine-gruppe/rpg-app")
type("appstore")
app_identifier("de.animexx.rpgapp")

Zertifikate in GitLab Secure Files importieren

PRIVATE_TOKEN=dein-gitlab-personal-access-token \
  bundle exec fastlane match import
# Interaktiv: Pfade zu .cer, .p12 und .mobileprovision angeben

Fastfile erstellen

fastlane/Fastfile:

default_platform(:ios)

platform :ios do

  desc "iOS App bauen und signieren"
  lane :build_ios do

    # Temporären CI-Keychain erstellen und match konfigurieren
    # Erstellt einen isolierten Keychain der nach dem Job gelöscht wird
    setup_ci

    # App Store Connect API Key aus CI-Variables laden
    app_store_connect_api_key(
      key_id:                ENV["ASC_KEY_ID"],
      issuer_id:             ENV["ASC_ISSUER_ID"],
      key_content:           ENV["ASC_KEY_CONTENT"],
      is_key_content_base64: true
    )

    # Zertifikate & Profile aus GitLab Secure Files holen
    # readonly: true im CI — nie neue Zertifikate erzeugen
    match(
      type:     "appstore",
      readonly: is_ci
    )

    # Kotlin/Native iOS Framework bauen (KMP-Schritt)
    sh("cd .. && ./gradlew :shared:linkReleaseFrameworkIosArm64")

    # Xcode archivieren und .ipa exportieren
    gym(
      project:          "iosApp/iosApp.xcodeproj",
      scheme:           "iosApp",
      configuration:    "Release",
      export_method:    "app-store",
      output_directory: "./build",
      output_name:      "iosApp.ipa",
      clean:            true
    )

  end

  desc "Build + Upload zu TestFlight"
  lane :deploy_testflight do
    build_ios
    upload_to_testflight(
      skip_waiting_for_build_processing: true
    )
  end

end

10. GitLab CI/CD Variables setzen

GitLab → Projekt → Settings → CI/CD → Variables:

VariableInhaltMaskedProtected
ASC_KEY_IDKey ID aus App Store Connect
ASC_ISSUER_IDIssuer ID aus App Store Connect
ASC_KEY_CONTENT.p8 API Key (base64-kodiert)
MATCH_PASSWORDVerschlüsselungspasswort für Zertifikate

API Key base64-kodieren:

base64 -i AuthKey_XXXXXX.p8 | pbcopy

Alle vier Variables auf Protected setzen — sie werden dann nur an Jobs auf protected Branches/Tags weitergegeben.


11. Vollständige .gitlab-ci.yml

stages:
  - build
  - deploy

variables:
  GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle"

# Gemeinsame Basis für alle Tart-Jobs
.tart_base:
  tags:
    - tart       # Nur der Tart-Runner bearbeitet diese Jobs
  image: macos-ios-build   # Lokales Tart-Image aus Abschnitt 7

# iOS App bauen und .ipa als Artifact speichern
build-ios:
  extends: .tart_base
  stage: build
  before_script:
    - export PATH="/opt/homebrew/opt/ruby/bin:/opt/homebrew/opt/openjdk@17/bin:/opt/homebrew/bin:$PATH"
    - bundle install --quiet
  script:
    - bundle exec fastlane build_ios
  artifacts:
    name: "$CI_COMMIT_REF_NAME-$CI_COMMIT_SHORT_SHA"
    paths:
      - build/*.ipa
      - build/*.dSYM.zip
    expire_in: 7 days
  only:
    - main
    - tags

# .ipa zu TestFlight hochladen (nur bei Git-Tags)
deploy-testflight:
  extends: .tart_base
  stage: deploy
  before_script:
    - export PATH="/opt/homebrew/opt/ruby/bin:/opt/homebrew/opt/openjdk@17/bin:/opt/homebrew/bin:$PATH"
    - bundle install --quiet
  script:
    - bundle exec fastlane deploy_testflight
  only:
    - tags   # Nur bei versionierten Releases deployen
  when: manual  # Manuell auslösen für zusätzliche Kontrolle

12. Referenz & Troubleshooting

Häufige Fehler

gem: FilePermissionError /Library/Ruby/Gems/2.6.0

System-Ruby wird verwendet statt Homebrew-Ruby. Lösung:

sudo -u gitlab-runner bash -l -c 'which ruby'
# Muss /opt/homebrew/opt/ruby/bin/ruby sein

# Falls nicht: PATH für gitlab-runner User setzen (Abschnitt 3)
sudo -u gitlab-runner bash -c 'echo "export PATH=\"/opt/homebrew/opt/ruby/bin:/opt/homebrew/bin:\$PATH\"" >> /Users/gitlab-runner/.bash_profile'

apt-get: command not found

apt-get ist Linux-only. Auf macOS brew verwenden:

# ❌ Falsch (Linux):
- apt-get install -y ruby build-essential

# ✅ Richtig (macOS):
- brew install ruby

gitlab-runner: the service is not installed

sudo -u gitlab-runner gitlab-runner install \
  --user gitlab-runner \
  --working-directory /Users/gitlab-runner
sudo -u gitlab-runner gitlab-runner start

Local Network Permission Popup (macOS Sequoia)

Sicherstellen dass --user gitlab-runner in allen prepare_args, run_args und cleanup_args in der config.toml gesetzt ist (Abschnitt 6).

Tart: Permission denied beim VM-Start

# Eigentumsrechte prüfen
ls -la /Users/gitlab-runner/.tart/

# Falls falsch:
sudo chown -R gitlab-runner:staff /Users/gitlab-runner/.tart/

VM startet nicht / SSH-Timeout

sudo -u gitlab-runner tart list           # VMs anzeigen
sudo -u gitlab-runner tart stop macos-ios-build    # VM stoppen
sudo -u gitlab-runner tart delete macos-ios-build  # VM löschen
# Dann neu klonen (Abschnitt 7)

Code Signing schlägt fehl im CI

Checkliste:

  • Xcode-Projekt auf manuelles Signing umgestellt? (Abschnitt 8)
  • setup_ci steht am Anfang der Fastlane-Lane?
  • readonly: is_ci bei match gesetzt?
  • MATCH_PASSWORD CI-Variable vorhanden und korrekt?
  • Zertifikate nicht abgelaufen? (bundle exec fastlane match --verbose)

KMP Framework nicht gefunden

# Gradle-Task direkt testen
./gradlew :shared:linkReleaseFrameworkIosArm64 --info

# Framework-Pfad im Xcode-Projekt prüfen
# Build Phases → Link Binary With Libraries

Nützliche Befehle Übersicht

# GitLab Runner (immer als gitlab-runner User)
sudo -u gitlab-runner gitlab-runner status
sudo -u gitlab-runner gitlab-runner restart
sudo -u gitlab-runner gitlab-runner list

# Tart (als gitlab-runner User)
sudo -u gitlab-runner tart list
sudo -u gitlab-runner tart run macos-ios-build
sudo -u gitlab-runner tart stop macos-ios-build
sudo -u gitlab-runner tart delete macos-ios-build
sudo -u gitlab-runner tart pull ghcr.io/cirruslabs/macos-sequoia-xcode:latest

# Fastlane (im Projektverzeichnis)
bundle exec fastlane build_ios
bundle exec fastlane deploy_testflight
bundle exec fastlane match --readonly
bundle exec fastlane match --verbose   # Debugging

# Homebrew (als Admin)
brew update && brew upgrade
brew upgrade gitlab-runner

Ressourcen

Kommentar verfassen

Diese Website verwendet Akismet, um Spam zu reduzieren. Erfahre, wie deine Kommentardaten verarbeitet werden.