Инженерная статья

Как устранить дрейф окружения Shell в CI на Cloud Mac

Как устранить дрейф окружения Shell в CI на Cloud Mac

В удалённом терминале сборка проходит без ошибок, но в задаче 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 или сообщения об ошибках. После диагностики также проверьте журналы: в них не должны случайно сохраниться заголовки запросов, аргументы команд или пути к временным файлам.

Закройте проблему с помощью матрицы приёмки

После исправления один и тот же скрипт необходимо запустить всеми тремя способами. Проверки только в текущем терминале недостаточно.

  1. Запустите ./scripts/run-ci.sh напрямую в интерактивном терминале.
  2. Запустите его через env -i, передав только HOME, USER и базовый PATH.
  3. Выполните запуск через launchctl kickstart, затем проверьте статус завершения и оба файла журналов.
  4. Сравните корневой каталог репозитория, абсолютные пути к инструментам, версии и коды завершения для всех трёх запусков.
  5. Перезапустите пользовательский сеанс и повторите проверку, чтобы убедиться, что задача не зависит от временно экспортированных переменных.

Распространённая ошибка — добавить исправление в .zshrc: ручной запуск после этого работает, а CI по-прежнему завершается с ошибкой. Другой типичный случай — поддерживать отдельный PATH в plist, который через несколько недель снова расходится со скриптом из репозитория. Надёжнее разделить зоны ответственности: планировщик только запускает задачу, скрипт входа определяет окружение, а рабочий скрипт выполняет сборку. При таком разделении различия окружения можно фиксировать, воспроизводить и проверять.

При внедрении этого подхода на удалённом Mac от OwnAMac сначала уточните, от имени какого пользователя действительно выполняется задача и где расположен репозиторий, и только затем создавайте plist. Не копируйте имя пользователя из примера без изменений. Итоговый критерий приёмки — не просто «работает в терминале», а одинаковое определение набора инструментов и одинаковый результат как в минимальном окружении, так и при запуске через планировщик.

Часто задаваемые вопросы

Почему CI не находит команду, которая доступна в терминале?

Интерактивный Shell может загружать пользовательские профили и расширять PATH, тогда как launchd и неинтерактивные задачи этого не делают. PATH следует задавать явно в стартовом сценарии.

Можно ли хранить токен доступа в plist для launchd?

Не следует. Такой файл может попасть в резервную копию или диагностический архив. Секреты нужно передавать во время запуска через контролируемый механизм и не выводить их значения.

Как доказать, что исправление не зависит от текущего терминала?

Запустите один проверочный сценарий в терминале, в минимальном окружении env -i и через launchd. Сравните PATH, рабочий каталог, абсолютные пути инструментов и коды завершения.

OwnAMac Облачный Mac

Перенесите следующую сборку на выделенный физический узел Apple Silicon

Выберите одну из трёх конфигураций и пяти узлов с учётом масштаба задачи. Вычислительные ресурсы и хранилище не делятся с другими клиентами. Все узлы стабильно работают 365 дней в году; фактический статус доступности возвращается в реальном времени через консоль.

Выбрать конфигурацию и заказать