Стабильный Xcode Destination в Cloud Mac CI

Стабильный Xcode Destination в Cloud Mac CI

После нескольких недель непрерывного выполнения одной и той же команды xcodebuild test на облачном Mac может внезапно появиться ошибка о недоступном destination или быть выбран одноимённый симулятор из недавно установленного runtime. Сам проект при этом не меняется — меняется результат, к которому приходит Xcode при разрешении целевого устройства. Для стабильной работы не следует навсегда закреплять конкретный UDID в CI. Вместо этого нужно чётко разделить архивирование, проверку компиляции и тестирование в симуляторе, а в начале каждой задачи заново определять и записывать выбранную цель.

Разделите три типа целей сборки

Destination в Xcode отвечает на вопрос, для какой платформы и какого устройства выполняется текущее действие, и не является эквивалентом Scheme. Одну общую Scheme можно использовать для универсального архива iOS, тестирования на определённом симуляторе и проверки компиляции, однако этим трём сценариям не следует назначать одну неоднозначную команду.

ЗадачаРекомендуемая цельНужен ли конкретный UDID
Создание Archivegeneric/platform=iOSНет
Тестирование в симулятореКонкретный iOS SimulatorДа
Проверка компиляцииУниверсальная или конкретная цель в зависимости от платформы артефактаЗависит от содержания тестов

Если для задачи архивирования указать name=iPhone 16, появится ненужная зависимость от runtime симулятора. И наоборот, если для тестов указать только platform=iOS Simulator, Xcode может найти несколько кандидатов. Итоговый выбор будет зависеть от установленных runtime и порядка создания устройств.

Универсальная цель подтверждает, что проект способен создать артефакт для нужной платформы. Конкретная цель подтверждает, что тесты могут выполняться в строго определённой среде устройства. Не возлагайте оба значения на один Destination.

Проверяйте доступные цели до запуска задачи

Сначала зафиксируйте путь к Xcode, а затем запросите список доступных целей у самого проекта. Это позволяет отличить ситуацию, когда Scheme не поддерживает платформу, от отсутствия подходящего устройства на хосте и не начинать диагностику с удаления кешей.

set -euo pipefail

export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
WORKSPACE="App.xcworkspace"
SCHEME="App"

xcodebuild \
  -workspace "$WORKSPACE" \
  -scheme "$SCHEME" \
  -showdestinations

Сохраните вывод как вложение к задаче. Если в списке полностью отсутствует iOS Simulator, сначала проверьте Supported Destinations в Scheme, целевую платформу и тестовый Target, а не запускайте simctl erase all снова и снова. Если устройство присутствует, но помечено как unavailable, убедитесь, что соответствующий runtime по-прежнему поддерживается текущей версией Xcode.

Одновременно записывайте идентификационные данные toolchain

Проблемы с Destination часто возникают после смены Xcode по умолчанию. При каждом запуске записывайте как минимум следующие сведения:

xcodebuild -version
xcrun simctl list runtimes
xcrun simctl list devices available

Этот вывод не содержит секретов проекта, поэтому его можно архивировать как диагностическую информацию о сборке. При параллельном выполнении на нескольких облачных Mac он также помогает установить, возникает ли сбой только с определённым toolchain.

Разрешайте симулятор динамически вместо закрепления UDID

UDID принадлежит экземпляру устройства на конкретном хосте. Он может измениться после удаления и повторного создания симулятора, замены runtime или сброса рабочего узла. CI должен искать устройство по имени и состоянию доступности, требуя при этом единственного результата.

Приведённый ниже скрипт выбирает один доступный iPhone 16 из JSON, полученного от simctl. В реальном конвейере идентификатор нужного runtime также следует передавать как параметр, чтобы исключить неоднозначность при наличии устройств с одинаковым именем в двух версиях системы.

SIM_NAME="${SIM_NAME:-iPhone 16}"

SIM_UDID="$(
  xcrun simctl list devices available -j |
  python3 -c '
import json, sys
name = sys.argv[1]
data = json.load(sys.stdin)
matches = []
for runtime, devices in data["devices"].items():
    for device in devices:
        if device.get("isAvailable") and device.get("name") == name:
            matches.append((runtime, device["udid"]))
if len(matches) != 1:
    for runtime, udid in matches:
        print(f"{runtime} {udid}", file=sys.stderr)
    raise SystemExit(f"expected one simulator named {name}, found {len(matches)}")
print(matches[0][1])
' "$SIM_NAME"
)"

printf 'Resolved simulator: %s (%s)\n' "$SIM_NAME" "$SIM_UDID"

Ключевой принцип — немедленно завершать задачу с ошибкой, если результат не уникален. Неявный выбор первого элемента массива позволяет продолжить выполнение, но превращает неоднозначность среды в трудновоспроизводимые результаты тестов.

Разделяйте команды тестирования и архивирования

После получения UDID сначала запустите устройство и дождитесь завершения его инициализации, а затем выполняйте тесты. Команда bootstatus -b предотвращает преждевременный запуск тестов, когда системные службы ещё не готовы.

xcrun simctl boot "$SIM_UDID" 2>/dev/null || true
xcrun simctl bootstatus "$SIM_UDID" -b

xcodebuild test \
  -workspace "$WORKSPACE" \
  -scheme "$SCHEME" \
  -destination "platform=iOS Simulator,id=$SIM_UDID" \
  -resultBundlePath "Artifacts/TestResults.xcresult"

Команда архивирования, напротив, вообще не обращается к симулятору:

xcodebuild archive \
  -workspace "$WORKSPACE" \
  -scheme "$SCHEME" \
  -configuration Release \
  -destination "generic/platform=iOS" \
  -archivePath "Artifacts/App.xcarchive"

Такое разделение также делает границы полномочий более понятными: этап тестирования управляет состоянием симулятора, а этап архивирования работает только с проектом, настройками подписи и путём вывода. Даже если оба этапа выполняются на одном облачном Mac SetMini, для них следует сохранять два независимых раздела журнала.

Превратите результат разрешения в проверяемую запись

Одного успешного завершения команды недостаточно. Конвейер должен записывать Scheme, DEVELOPER_DIR, имя устройства, UDID, runtime, тип действия и путь к пакету результатов в обычный текстовый файл или JSON. При диагностике сначала сравнивайте эти факты и только затем рассматривайте изменения в коде.

На этапе завершения рекомендуется выключать только устройство, запущенное текущей задачей:

cleanup() {
  if [[ -n "${SIM_UDID:-}" ]]; then
    xcrun simctl shutdown "$SIM_UDID" 2>/dev/null || true
  fi
}
trap cleanup EXIT

Не выключайте безусловно все симуляторы на общем узле и не используйте erase all как стандартную очистку. В первом случае будут прерваны параллельные задачи, а во втором — скрыт источник загрязнения состояния и увеличено время последующей инициализации.

Итоговую проверку можно свести к пяти пунктам: для архивирования используется универсальная цель; для тестов используется UDID, динамически разрешённый в текущем запуске; кандидат должен быть единственным; перед запуском записываются версии Xcode и runtime; при завершении обрабатывается только устройство, принадлежащее текущей задаче. При соблюдении этих правил Destination превращается из неявного состояния среды в проверяемый и воспроизводимый вход сборки.

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

Нужен ли конкретный симулятор для создания архива iOS?

Нет. Для архива используйте generic/platform=iOS, чтобы задача не зависела от симулятора, который может измениться после обновления runtime.

Можно ли постоянно хранить UDID симулятора в конфигурации CI?

Не рекомендуется. Определяйте UDID в начале каждой задачи по модели и runtime, а затем записывайте выбранное значение в журнал.

Что делать с несколькими симуляторами с одинаковым именем?

Отфильтруйте только доступные устройства и нужный runtime. Если кандидатов осталось несколько, завершите задачу с ошибкой и выведите их список.

Выделенный физический узел

Перенесите этапы разработки на облачный Mac с доступом в любое время

Выберите одну из двух конфигураций Apple Silicon и четырёх дата-центров. Ресурсы не распределяются между арендаторами, а фактическая доступность отображается в консоли в реальном времени.

Выбрать тариф