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
- Dedizierter Service-User einrichten
- GitLab Runner installieren
- Ruby-Umgebung konfigurieren
- Build-Bot absichern
- Tart + gitlab-tart-executor einrichten
- Runner registrieren & konfigurieren
- Tart VM-Image mit Xcode & Ruby vorbereiten
- Signierte KMP iOS Apps bauen
- Fastlane einrichten
- GitLab CI/CD Variables setzen
- Vollständige
.gitlab-ci.yml - 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 installoderbrew services), nicht beide gleichzeitig. Da wir den Service unter einem bestimmten User starten wollen, nutzen wir hiergitlab-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ät | Maßnahme | Status nach Abschnitt 1 |
|---|---|---|
| 🔴 Kritisch | Secrets nur als GitLab CI Variables | manuell |
| 🔴 Kritisch | Runner auf Protected + Tagged setzen | manuell |
| 🔴 Kritisch | Dedizierter Non-Admin Service-User | ✅ erledigt |
| 🟡 Wichtig | macOS Firewall aktivieren | manuell |
| 🟡 Wichtig | Tart für Job-Isolation | → Abschnitt 5 |
| 🟢 Langfristig | System regelmäßig aktualisieren | manuell |
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 Runner | Tart Runner | |
|---|---|---|
| Isolation | ❌ keine | ✅ eigene VM pro Job |
| Dateisystem-Zugriff | Voller User-Zugriff | Nur innerhalb der VM |
| Sicherheit bei Kompromittierung | Gesamter User betroffen | Nur die VM |
| Geschwindigkeit | Sehr 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
| Feld | Wert |
|---|---|
| GitLab URL | https://dein-gitlab.de/ |
| Registration Token | GitLab → Settings → CI/CD → Runners |
| Beschreibung | Mac Mini M4 Tart Runner |
| Tags | tart,macos,ios |
| Executor | custom |
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 mitgitlab-runnerumgeht 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:
| Variable | Inhalt | Masked | Protected |
|---|---|---|---|
ASC_KEY_ID | Key ID aus App Store Connect | ✅ | ✅ |
ASC_ISSUER_ID | Issuer ID aus App Store Connect | ✅ | ✅ |
ASC_KEY_CONTENT | .p8 API Key (base64-kodiert) | ✅ | ✅ |
MATCH_PASSWORD | Verschlü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_cisteht am Anfang der Fastlane-Lane?readonly: is_cibeimatchgesetzt?MATCH_PASSWORDCI-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