Une compilation peut parfaitement fonctionner dans un terminal distant, puis échouer dans une tâche CI avec une erreur command not found, un mauvais répertoire de travail, voire un script qui réussit lorsqu’il est lancé manuellement mais échoue via launchd. Dans la plupart des cas, l’outil n’est pas défectueux : les trois points d’entrée ne reçoivent simplement pas le même environnement. Le shell interactif charge la configuration de l’utilisateur, le shell non interactif n’en lit qu’une partie, tandis que launchd démarre avec un environnement encore plus minimal. La solution ne consiste donc pas à ajouter indéfiniment des chemins au PATH, mais à faire passer toutes les tâches automatisées par un point d’entrée unique et explicite.
Conserver les données d’environnement des trois points d’entrée
Ne modifiez pas immédiatement la configuration. Collectez d’abord les mêmes informations depuis le terminal distant, la tâche CI et launchd, afin de déterminer si l’écart concerne le PATH, le répertoire courant ou la sélection des outils.
#!/bin/zsh
set -eu
printf 'user=%s
' "$(id -un)"
printf 'uid=%s
' "$(id -u)"
printf 'shell=%s
' "${SHELL:-unset}"
printf 'home=%s
' "${HOME:-unset}"
printf 'pwd=%s
' "$PWD"
printf 'path=%s
' "${PATH:-unset}"
for tool in zsh git xcodebuild ruby python3; do
printf '%s=' "$tool"
command -v "$tool" || printf 'missing
'
done
/usr/bin/xcode-select -p 2>/dev/null || true
/usr/bin/sw_vers
Enregistrez ce script sous scripts/inspect-env.sh, exécutez-le depuis chacun des trois points d’entrée et conservez séparément les sorties. Évitez de capturer directement l’intégralité de env dans une tâche de production : elle peut contenir des jetons ou des identifiants temporaires.
Si deux exécutions résolvent un même outil vers des chemins absolus différents, considérez qu’il existe une dérive d’environnement, même si les numéros de version sont encore identiques. Une mise à niveau ultérieure pourrait faire diverger les résultats obtenus par ces deux chemins.
Reproduire le problème avec un environnement minimal
La commande env -i permet de simuler une tâche qui ne charge pas la configuration habituelle de l’utilisateur :
/usr/bin/env -i \
HOME="$HOME" \
USER="$USER" \
PATH="/usr/bin:/bin:/usr/sbin:/sbin" \
/bin/zsh ./scripts/inspect-env.sh
Si le problème se reproduit de manière fiable dans ces conditions, il est inutile de réinstaller les outils à plusieurs reprises. Vérifiez plutôt le script d’entrée et la définition du PATH.
Créer un script d’entrée unique pour les tâches
Une tâche automatisée ne doit pas dépendre de .zshrc. Ce fichier est destiné aux sessions interactives et contient souvent la configuration de l’invite, des alias, des détections de terminal ou des instructions valables uniquement dans une session de connexion. Créez un point d’entrée propre au dépôt, par exemple scripts/run-ci.sh :
#!/bin/zsh
set -euo pipefail
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export LANG="en_US.UTF-8"
export LC_ALL="en_US.UTF-8"
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$repo_root"
required_tools=(git xcodebuild)
for tool in "${required_tools[@]}"; do
if ! command -v "$tool" >/dev/null 2>&1; then
printf 'required tool missing: %s
' "$tool" >&2
exit 127
fi
done
exec ./scripts/build.sh
L’ordre des entrées du PATH doit rester fixe et les répertoires système doivent être conservés. Si la tâche dépend d’outils supplémentaires, utilisez d’abord command -v sur la machine pour vérifier leur emplacement réel, puis ajoutez celui-ci au script d’entrée. Ne recopiez pas l’ensemble de la configuration utilisateur.
Le script d’entrée doit également se placer à la racine du dépôt. Ainsi, les chemins relatifs du script de compilation ne dépendront ni du répertoire temporaire choisi par l’exécuteur CI ni du répertoire par défaut de launchd.
Limiter launchd au démarrage de la tâche
La configuration de launchd doit rester simple : elle indique l’interpréteur, le script d’entrée, le répertoire de travail et les fichiers journaux. N’y assemblez pas de commandes complexes et n’y stockez aucune valeur sensible.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "file://localhost/System/Library/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.ci-runner</string>
<key>ProgramArguments</key>
<array>
<string>/bin/zsh</string>
<string>/Users/runner/project/scripts/run-ci.sh</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/runner/project</string>
<key>StandardOutPath</key>
<string>/Users/runner/Library/Logs/ci-runner.out.log</string>
<key>StandardErrorPath</key>
<string>/Users/runner/Library/Logs/ci-runner.err.log</string>
</dict>
</plist>
Validez la syntaxe avant le chargement :
plutil -lint ~/Library/LaunchAgents/com.example.ci-runner.plist
launchctl bootstrap "gui/$(id -u)" \
~/Library/LaunchAgents/com.example.ci-runner.plist
launchctl kickstart -k \
"gui/$(id -u)/com.example.ci-runner"
Après avoir modifié le plist, déchargez d’abord la tâche existante avec bootout, puis exécutez de nouveau bootstrap. Un simple kickstart ne recharge pas automatiquement une configuration déjà chargée.
Gérer les versions des outils et les variables sensibles
Figer les chemins ne suffit pas à figer les versions. Au démarrage de chaque tâche, le script d’entrée doit afficher les versions non sensibles des outils, les chemins résolus et le répertoire de travail afin de faciliter la comparaison des exécutions en échec. Il ne doit toutefois pas afficher l’ensemble des variables d’environnement.
| Élément contrôlé | Méthode recommandée | À conserver en cas d’échec |
|---|---|---|
| Résolution de l’outil | command -v |
Chemin absolu de l’outil |
| Version de l’outil | Utiliser l’option de version officielle | Texte de la version |
| Répertoire de travail | Exécuter explicitement cd vers la racine du dépôt |
Sortie de pwd |
| Chaîne d’outils système | Vérifier le répertoire de développement actif | Chemin et code de sortie |
| Variables sensibles | Vérifier uniquement leur présence | Nom de la variable, jamais sa valeur |
Les valeurs sensibles doivent être injectées au moment de l’exécution par un mécanisme d’identifiants contrôlé. Le script peut utiliser ${TOKEN:?TOKEN is required} pour échouer immédiatement si une valeur manque, mais il ne doit jamais en exposer le contenu via set -x, env ou un message d’erreur. Une fois le diagnostic terminé, vérifiez également que les journaux n’ont pas enregistré par inadvertance des en-têtes de requête, des arguments de commande ou des chemins de fichiers temporaires.
Valider la correction avec une matrice de tests
Après la correction, exécutez le même script depuis les trois points d’entrée. Une validation limitée au terminal courant ne suffit pas.
- Exécutez directement
./scripts/run-ci.shdans un terminal interactif. - Exécutez-le avec
env -ien ne transmettant que HOME, USER et un PATH de base. - Démarrez-le avec
launchctl kickstart, puis vérifiez l’état de sortie et les deux journaux. - Comparez, pour les trois exécutions, la racine du dépôt, les chemins absolus des outils, leurs versions et les codes de sortie.
- Redémarrez une fois la session utilisateur, puis relancez la tâche afin de confirmer qu’elle ne dépend d’aucune variable exportée temporairement.
Une erreur fréquente consiste à placer la correction dans .zshrc : le test manuel fonctionne alors de nouveau, tandis que la CI continue d’échouer. Une autre consiste à maintenir un second PATH dans le plist, qui finit quelques semaines plus tard par diverger du script du dépôt. La séparation la plus fiable est la suivante : le planificateur se contente de démarrer la tâche, le script d’entrée définit l’environnement et le script métier exécute la compilation. Une fois ces trois responsabilités isolées, les différences d’environnement peuvent être consignées, reproduites et examinées.
Pour appliquer cette méthode sur un Mac distant OwnAMac, commencez également par identifier l’utilisateur qui exécute réellement la tâche et le chemin du dépôt avant de générer le plist. Ne recopiez pas directement le nom d’utilisateur de l’exemple. Le critère d’acceptation final n’est pas que « cela fonctionne dans le terminal », mais que l’environnement minimal et l’environnement du planificateur résolvent le même ensemble d’outils et produisent des résultats cohérents.
Questions fréquentes
Pourquoi une commande fonctionne-t-elle dans le terminal mais pas dans la CI ?
Le Shell interactif charge souvent des profils utilisateur qui complètent PATH. Une tâche non interactive ou launchd ne les charge pas de la même façon. Le script d’entrée doit donc définir PATH explicitement.
Peut-on enregistrer un jeton d’accès dans un fichier plist launchd ?
Ce n’est pas recommandé. Le fichier peut se retrouver dans une sauvegarde ou un paquet de diagnostic. Les secrets doivent être injectés à l’exécution sans être affichés dans les journaux.
Comment vérifier que la correction est réellement reproductible ?
Exécutez le même script de contrôle dans le terminal, dans un environnement minimal créé avec env -i, puis via launchd. Comparez PATH, dossier courant, chemins des outils et codes de sortie.
Lancez votre prochaine compilation sur un nœud physique Apple Silicon dédié
Choisissez parmi trois niveaux de configuration et cinq nœuds selon la taille de votre tâche. Le calcul et le stockage ne sont pas partagés avec d’autres clients. Tous les nœuds fonctionnent normalement 365 jours par an ; leur disponibilité réelle est indiquée en temps réel par la console.