Objectif : un pipeline de build iOS bout en bout vérifiable
Avant d'installer le Runner, nous définissons le critère d'« installation terminée » — pour éviter de finir l'install et de bloquer à la signature. La ligne d'arrivée de ce guide : push vers une branche cible → GitHub Actions se déclenche sur le nœud M4 dédié → checkout du code → exécution de xcodebuild archive et production réussie d'un .xcarchive. L'upload TestFlight est hors périmètre ici, mais une fois Archive stable, fastlane ou altool n'est qu'une étape supplémentaire.
Le dépôt de test est une app SwiftUI de taille moyenne : ~60 fichiers source Swift, CocoaPods pour les dépendances tierces, config Release en signature Manual. Environnement de référence : Mac mini M4 JexMac à Singapour (16 Go de mémoire unifiée, 256 Go NVMe), Xcode 16.2 installé et verrouillé via xcodes.
Groupe témoin : même dépôt sur Runner hébergé runs-on: macos-14. UTC 13:00–18:00 (après-midi Asie-Pacifique) : file d'attente médiane 11 minutes, attente max 19 minutes ; la version Xcode préinstallée sur macos-14 ne correspondait pas aux machines locales, provoquant des problèmes de compatibilité Swift 6. L'auto-hébergement paie clairement ici — le temps d'attente passe des minutes aux secondes, et la version Xcode est figée sur votre nœud.
Trois coûts cachés des Runners macOS hébergés
Beaucoup d'équipes choisissent les Runners hébergés GitHub pour le « zéro ops », mais en iOS, les coûts cachés font souvent plus mal que la facture.
Premier : le coût en temps. Le pool macOS de GitHub est limité. Les comptes gratuits ont 2 000 minutes/mois ; macOS compte ×10, soit ~200 minutes effectives. Un projet avec 8 builds/jour de 6 minutes consomme ~1 440 minutes pondérées/mois — proche du plafond gratuit ; ajoutez un build nightly et vous dépassez.
Deuxième : la dérive d'environnement. Le label macos-latest change le Xcode sous-jacent quand GitHub met à jour l'infra — « vert hier, rouge aujourd'hui » arrive souvent. Un contournement est sudo xcode-select dans le workflow, mais chaque bascule de version ajoute 1–2 minutes et ne garantit pas l'alignement exact avec la machine locale.
Troisième : le coût de débogage. Les Runners hébergés sont détruits après chaque job — impossible de SSH pour reproduire. Pop-ups Keychain, profils de provisionnement expirés — les problèmes qui demandent « se connecter et regarder » forcent des push répétés. Nous avons poussé 7 fois pour une erreur User interaction is not allowed avant de trouver une partition list manquante.
Toutes les commandes et durées de cet article ont été exécutées sur un nœud physique dédié JexMac Mac mini M4 à Singapour, fenêtre de test fin juillet 2026. Matériel : Apple M4 · CPU 10 cœurs · 16 Go de mémoire unifiée · bande passante dédiée 1 Gbit/s.
Après livraison : première connexion SSH et baseline d'environnement
La console JexMac livre généralement les identifiants SSH sous 1–5 minutes après paiement. Une fois l'IP publique obtenue, configurez l'accès par clé Ed25519 et désactivez la connexion par mot de passe avant d'installer le Runner — le processus Runner tourne sous l'utilisateur macOS courant, la baseline SSH sécurisée vient donc en premier.
-
01
Ajouter la clé publique SSH
ssh-copy-id -i ~/.ssh/id_ed25519.pub jexmac@<IP-du-nœud>Une fois la connexion sans mot de passe validée, éditez
/etc/ssh/sshd_configpour définirPasswordAuthentication no, puis redémarrez sshd. -
02
Installer Homebrew et 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-passinstalle et verrouille Xcode 16.2 (ajustez la version selon le projet). -
03
Vérifier la chaîne de build
xcodebuild -versiondoit afficherXcode 16.2et le numéro de build.xcodebuild -showsdks | grep iphoneosconfirme que le SDK iOS est disponible. -
04
Installer CocoaPods (si utilisé)
sudo gem install cocoapods -n /usr/local/binExécutez tôt
pod installsur le nœud pour préparer le cache du dépôt Specs — évite que le premier build CI ne bloque sur le repo update.
Après la configuration de base, le nœud doit disposer d'un répertoire dédié (nous utilisons ~/ci-runner) pour le Runner et les artefacts de build, isolé des fichiers de développement quotidiens. Nous réservons un sous-répertoire DerivedData et définissons explicitement -derivedDataPath dans les workflows pour éviter les conflits de chemins lors d'exécutions concurrentes multi-projets.
Côté GitHub : token Runner et conception du routage par labels
Allez dans le dépôt cible → Settings → Actions → Runners → New self-hosted runner, plateforme macOS ARM64. La page génère un token d'enregistrement à usage unique (validité 1 heure) et un lien de téléchargement.
La conception des labels détermine directement si les workflows routent vers la bonne machine. Notre convention de nommage :
mac: label générique pour tous les nœuds macOS auto-hébergésm4: puce Apple Silicon M4, pour distinguer des anciens nœuds Intelxcode-16-2: verrouille la version mineure Xcode ; changez le label à la mise à niveau, pas la logique du workflowsg: région du datacenter (Singapour) ; utile pour le routage de proximité en multi-région
Le paramètre --labels à l'enregistrement écrit tous les labels d'un coup. Dans le workflow, faites correspondre sous forme de tableau :
jobs:
ios-archive:
runs-on: [self-hosted, mac, m4, xcode-16-2]
concurrency:
group: ios-build-${{ github.ref }}
cancel-in-progress: true
Un groupe concurrency garantit qu'une même branche ne lance pas deux Archives en parallèle — sur un nœud M4 16 Go, des Clean Builds multi-projets concurrent peuvent tourner, mais la contention DerivedData fait varier la durée d'un run de plus de 30 %. Pour plusieurs lignes produits, séparez les labels (ex. product-a, product-b) et utilisez des nœuds distincts plutôt que de forcer la concurrence sur une seule machine.
Les Runners auto-hébergés sur des dépôts publics peuvent être déclenchés par des PR de forks — un workflow malveillant exécute du code arbitraire sur votre Mac. Activez-les uniquement sur des dépôts privés ou au niveau Organisation, et faites tourner le processus Runner sous un compte non administrateur. En production, utilisez les règles de protection GitHub Environment pour limiter les secrets aux branches spécifiées.
Installer le Runner sur le nœud M4 et configurer la persistance launchd
Exécutez les étapes suivantes en session SSH. La version du Runner suit la page d'enregistrement GitHub (exemple ici : v2.321.0).
-
01
Télécharger et extraire
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
Enregistrement interactif
./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--unattendedignore la confirmation interactive, adapté au déploiement scripté. Le dossier de travail par défaut_workconvient. -
03
Installer le service launchd
./svc.sh install./svc.sh startVérification :
./svc.sh statusdoit afficheractive (running). Un statut vert Online sur la page Runners du dépôt confirme l'enregistrement.
launchd redémarre automatiquement le Runner après un reboot, mais un détail compte : le Runner tourne sous l'utilisateur macOS qui l'a installé — cet utilisateur doit s'être connecté au moins une fois (ou activer la connexion automatique), sinon launchd peut ne pas accéder au Keychain. Nous avons créé le compte dédié ci-bot, initialisé le Keychain à la première connexion SSH, puis installé svc.sh — aucune intervention manuelle après reboot.
Les logs Runner se trouvent dans ~/ci-runner/actions-runner/_diag/ ; chaque job produit un Worker_*.log. Pour les problèmes du type « job assigné au nœud mais aucune sortie d'étape », consultez d'abord les logs RunnerListener ici — plus complets que les Annotations de l'UI GitHub.
Workflow minimal : du checkout à Archive
Après l'enregistrement, créez .github/workflows/ios-archive.yml dans le dépôt. Voici notre version minimale validée — sans upload TestFlight, uniquement la production Archive :
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
Quelques choix de conception délibérés :
pod install --deployment verrouille les versions via Podfile.lock, pour aligner CI et dépendances locales. -derivedDataPath pointe vers un répertoire fixe sur le nœud — réutilisation du cache de compilation ; les builds incrémentaux passent de 4 min à ~1 min 40 s. upload-artifact envoie le xcarchive sur GitHub pour que les collègues sans SSH puissent télécharger et vérifier — conservation 7 jours, suffisant pour un contrôle QA.
Environnement sans interface : Keychain et import du certificat Distribution
Le succès d'Archive dépend de l'accès CI à la clé privée Distribution sans GUI. Les Runners hébergés préconfigurent le Keychain système ; les nœuds auto-hébergés exigent votre propre gestion — raison fréquente du blocage « Runner installé mais Archive échoue à la signature ».
Approche recommandée : créer un Keychain temporaire par job, importer le p12, signer immédiatement, détruire à la fin du job — évite le stockage durable de la clé privée sur disque.
# Ajouter avant l'étape « Build archive »
- 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 }}
Configurez au préalable trois variables dans GitHub Repository Secrets : KEYCHAIN_PASSWORD (mot de passe du Keychain temporaire — une chaîne aléatoire suffit), CERTIFICATE_P12_BASE64 (certificat Distribution encodé en Base64), P12_PASSWORD (mot de passe défini à l'export du p12).
| Mot-clé d'erreur | Cause racine fréquente | Solution |
|---|---|---|
User interaction is not allowed |
Partition list de la clé privée non définie ; codesign tente d'afficher une UI | Relancer la commande set-key-partition-list (voir le script ci-dessus) |
errSecItemNotFound |
Certificat importé dans le mauvais Keychain, ou default-keychain non basculé | Confirmer que security default-keychain -s build.keychain s'exécute avant l'import |
| could not find signing certificate | La chaîne CODE_SIGN_IDENTITY ne correspond pas exactement à l'Identity dans le Keychain |
Exécuter security find-identity -v -p codesigning build.keychain et copier le nom complet |
| Provisioning profile doesn't match | Profil de provisionnement expiré ou Bundle ID / Capability non alignés | Régénérer dans le portail Apple Developer, télécharger, synchroniser via secrets ou match |
Après l'import, vérifiez rapidement dans les logs du job avec une commande :
security find-identity -v -p codesigning build.keychain | grep Distribution
Voir 1 valid identities found avant l'étape Archive — économise des heures de débogage « signature échouée sans savoir où ».
Journal de dépannage : trois problèmes qui nous ont coûté un push de plus
Même en suivant les étapes ci-dessus, la première mise en place peut buter sur ces cas — on y est passé. Signatures de logs et correctifs inclus.
Label incorrect : job bloqué en file d'attente indéfiniment
Symptôme : l'UI GitHub Actions affiche le job Queued, la page Runners montre le nœud Online. Cause : les labels runs-on du workflow ne correspondent pas exactement à l'enregistrement — ex. le workflow a xcode-16.2 (point), l'enregistrement a utilisé xcode-16-2 (trait d'union). La correspondance des labels GitHub est une comparaison exacte de chaînes ; un caractère de décalage et aucun routage.
Correctif : sur la page Runners du dépôt, cliquez le nom du nœud, consultez la liste réelle des labels, copiez-collez dans le YAML — ne tapez pas à la main.
Runner Offline après redémarrage launchd
Symptôme : après reboot du nœud ou redémarrage de maintenance JexMac, le Runner affiche Offline ; il faut SSH et lancer ./svc.sh start. Cause : le UserName du plist launchd ne correspond pas à l'utilisateur SSH, ou cet utilisateur n'a jamais terminé une première connexion graphique/session.
Correctif : confirmez que ./svc.sh install et ./svc.sh start s'exécutent sous le même utilisateur ; après reboot, vérifiez ./svc.sh status. Si ça échoue encore, consultez les logs stderr sous /Library/Logs/GitHubActionsRunner/.
Conflit de permissions DerivedData
Symptôme : le second build échoue avec Unable to write to DerivedData ou permission refusée sur un fichier .o. Cause : le premier job a créé DerivedData en root ou sous un autre utilisateur ; le job suivant n'a pas l'accès en écriture.
Correctif : unifier le chemin DerivedData et ajouter un nettoyage au début du workflow : rm -rf ~/ci-runner/DerivedData && mkdir -p ~/ci-runner/DerivedData. Ou n'effacer que le sous-répertoire hash du projet avant chaque Archive pour conserver le cache de compilation réutilisable.
Pas de Mac permanent ? Louez un nœud dédié selon le rythme des builds
À ce stade vous le voyez : installer un Runner GitHub Actions auto-hébergé n'est pas le plus dur — le vrai goulot, c'est disposer d'une machine physique macOS en ligne 24/7 avec une version Xcode contrôlable. Les MacBook locaux sont de mauvais hôtes CI — les modèles 8 Go saturent ventilateurs et swap pendant Archive ; acheter un Mac mini d'entreprise implique validation d'actifs, colocation et rotation des certificats sur site.
Notre approche : louer un nœud dédié JexMac Mac mini M4 comme hôte Runner pendant les cycles de sprint (ex. deux semaines avant la release), renouveler à la semaine ou libérer une fois le lancement stabilisé. Config standard : 16 Go de mémoire unifiée, 256 Go NVMe, bande passante dédiée 1 Gbit/s, à partir de 21,5 $/jour, accès SSH/VNC sous 1–5 minutes après paiement, sans engagement. Cinq nœuds — Singapour, Japon (Tokyo), Corée (Séoul), Hong Kong, Est des États-Unis — choisissez selon la localisation de l'équipe pour réduire la latence de git fetch et de sync CocoaPods Specs.
Vs Runner macOS hébergé GitHub : file de pointe 10–20 min vs démarrage auto-hébergé en ~25 s ; minutes macOS facturées ×10 vs loyer journalier fixe prévisible. Vs achat matériel : zéro investissement initial ; mise à niveau en changeant les labels ou en réinstallant Xcode, sans cycle d'achat.
Le même nœud peut aussi servir de bureau de développement distant — VNC navigateur pour le débogage UI, Instruments pour le profiling ; le temps d'inactivité CI n'est pas perdu. Pour une petite équipe de 2–3 personnes, « une machine physique M4 = Runner + environnement Mac distant » bat souvent les minutes de Runner hébergé et les upgrades RAM locales.
Connectez votre Runner à un nœud M4
Toutes les commandes de cet article ont été vérifiées sur des nœuds physiques dédiés JexMac Mac mini M4. Provisionnez → accès SSH → enregistrez le Runner selon les étapes — Archive opérationnel en moins d'une heure. Location journalière à partir de 21,5 $, libération après le sprint, sans engagement annuel.