В удалённом терминале сборка проходит без ошибок, но в задаче CI появляются сообщения command not found, используется неверный рабочий каталог, а один и тот же скрипт успешно запускается вручную и завершается с ошибкой через launchd. Обычно проблема не в самих инструментах, а в том, что три способа запуска получают разные окружения: интерактивная Shell загружает пользовательские настройки, неинтерактивная Shell читает лишь часть конфигурации, а launchd запускает процесс в ещё более минимальном окружении. Поэтому исправлять нужно не очередным добавлением PATH в конфигурационные файлы, а созданием единой и явно определённой точки входа для всех автоматизированных задач.
Сначала сохраните данные окружения для трёх способов запуска
Не меняйте конфигурацию сразу. Соберите одинаковые сведения в удалённом терминале, задаче CI и launchd, чтобы определить, где именно возникает расхождение: в PATH, текущем каталоге или выборе инструментов.
#!/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
Сохраните скрипт как scripts/inspect-env.sh, запустите его всеми тремя способами и сохраните каждый результат отдельно. Не собирайте полный вывод env в производственных задачах: он может содержать токены или временные учётные данные.
Если при двух запусках определяются разные абсолютные пути к инструменту, это следует считать дрейфом окружения, даже если текущие версии совпадают. После обновления инструменты по этим путям могут начать давать разные результаты.
Воспроизведение в минимальном окружении
С помощью env -i можно имитировать задачу, которая не загружает пользовательские настройки:
/usr/bin/env -i \
HOME="$HOME" \
USER="$USER" \
PATH="/usr/bin:/bin:/usr/sbin:/sbin" \
/bin/zsh ./scripts/inspect-env.sh
Если проблема стабильно воспроизводится в таком режиме, переустанавливать инструменты снова и снова не нужно. Проверьте точку входа и определение PATH.
Создайте единый скрипт запуска задач
Автоматизированные задачи не должны зависеть от .zshrc. Этот файл предназначен для интерактивной работы и часто содержит настройку приглашения, псевдонимы, проверку терминала и логику, действующую только в сеансе входа. Создайте для репозитория отдельную точку входа, например 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
Порядок каталогов в PATH должен быть фиксированным, а системные каталоги необходимо сохранить. Если задаче требуются дополнительные инструменты, сначала определите их фактическое расположение на машине с помощью command -v, а затем добавьте путь в скрипт запуска. Не копируйте туда целиком пользовательскую конфигурацию.
Скрипт запуска также должен переходить в корневой каталог репозитория. Тогда относительные пути в скриптах сборки не будут зависеть от временного каталога исполнителя CI или каталога по умолчанию в launchd.
Оставьте launchd только функцию запуска
Конфигурация launchd должна оставаться простой: укажите интерпретатор, скрипт запуска, рабочий каталог и файлы журналов. Не собирайте сложные команды внутри plist и не храните в нём конфиденциальные значения.
<?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>
Перед загрузкой проверьте синтаксис:
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"
После изменения plist сначала выгрузите прежнюю задачу с помощью bootout, а затем снова выполните bootstrap. Один только kickstart не обновляет уже загруженную старую конфигурацию.
Управляйте версиями инструментов и секретными переменными
Фиксированный путь ещё не означает фиксированную версию. В начале задачи скрипт запуска должен выводить неконфиденциальные сведения: версии инструментов, определённые пути и рабочий каталог. Это упрощает сравнение неудачных запусков, но полный набор переменных окружения выводить нельзя.
| Что проверять | Рекомендуемый способ | Что сохранять при ошибке |
|---|---|---|
| Определение инструмента | command -v |
Абсолютный путь к инструменту |
| Версия инструмента | Вызвать официальный параметр версии | Текст версии |
| Рабочий каталог | Явно выполнить cd в корень репозитория |
Вывод pwd |
| Системный набор инструментов | Проверить текущий каталог разработчика | Путь и код завершения |
| Секретные переменные | Проверять только наличие | Имя переменной без значения |
Секретные значения должны передаваться во время выполнения задачи через контролируемый механизм управления учётными данными. Скрипт может использовать ${TOKEN:?TOKEN is required}, чтобы немедленно завершиться при отсутствии переменной, но не должен раскрывать её содержимое через set -x, env или сообщения об ошибках. После диагностики также проверьте журналы: в них не должны случайно сохраниться заголовки запросов, аргументы команд или пути к временным файлам.
Закройте проблему с помощью матрицы приёмки
После исправления один и тот же скрипт необходимо запустить всеми тремя способами. Проверки только в текущем терминале недостаточно.
- Запустите
./scripts/run-ci.shнапрямую в интерактивном терминале. - Запустите его через
env -i, передав только HOME, USER и базовый PATH. - Выполните запуск через
launchctl kickstart, затем проверьте статус завершения и оба файла журналов. - Сравните корневой каталог репозитория, абсолютные пути к инструментам, версии и коды завершения для всех трёх запусков.
- Перезапустите пользовательский сеанс и повторите проверку, чтобы убедиться, что задача не зависит от временно экспортированных переменных.
Распространённая ошибка — добавить исправление в .zshrc: ручной запуск после этого работает, а CI по-прежнему завершается с ошибкой. Другой типичный случай — поддерживать отдельный PATH в plist, который через несколько недель снова расходится со скриптом из репозитория. Надёжнее разделить зоны ответственности: планировщик только запускает задачу, скрипт входа определяет окружение, а рабочий скрипт выполняет сборку. При таком разделении различия окружения можно фиксировать, воспроизводить и проверять.
При внедрении этого подхода на удалённом Mac от OwnAMac сначала уточните, от имени какого пользователя действительно выполняется задача и где расположен репозиторий, и только затем создавайте plist. Не копируйте имя пользователя из примера без изменений. Итоговый критерий приёмки — не просто «работает в терминале», а одинаковое определение набора инструментов и одинаковый результат как в минимальном окружении, так и при запуске через планировщик.
Часто задаваемые вопросы
Почему CI не находит команду, которая доступна в терминале?
Интерактивный Shell может загружать пользовательские профили и расширять PATH, тогда как launchd и неинтерактивные задачи этого не делают. PATH следует задавать явно в стартовом сценарии.
Можно ли хранить токен доступа в plist для launchd?
Не следует. Такой файл может попасть в резервную копию или диагностический архив. Секреты нужно передавать во время запуска через контролируемый механизм и не выводить их значения.
Как доказать, что исправление не зависит от текущего терминала?
Запустите один проверочный сценарий в терминале, в минимальном окружении env -i и через launchd. Сравните PATH, рабочий каталог, абсолютные пути инструментов и коды завершения.
Перенесите следующую сборку на выделенный физический узел Apple Silicon
Выберите одну из трёх конфигураций и пяти узлов с учётом масштаба задачи. Вычислительные ресурсы и хранилище не делятся с другими клиентами. Все узлы стабильно работают 365 дней в году; фактический статус доступности возвращается в реальном времени через консоль.