После нескольких недель непрерывного выполнения одной и той же команды xcodebuild test на облачном Mac может внезапно появиться ошибка о недоступном destination или быть выбран одноимённый симулятор из недавно установленного runtime. Сам проект при этом не меняется — меняется результат, к которому приходит Xcode при разрешении целевого устройства. Для стабильной работы не следует навсегда закреплять конкретный UDID в CI. Вместо этого нужно чётко разделить архивирование, проверку компиляции и тестирование в симуляторе, а в начале каждой задачи заново определять и записывать выбранную цель.
Разделите три типа целей сборки
Destination в Xcode отвечает на вопрос, для какой платформы и какого устройства выполняется текущее действие, и не является эквивалентом Scheme. Одну общую Scheme можно использовать для универсального архива iOS, тестирования на определённом симуляторе и проверки компиляции, однако этим трём сценариям не следует назначать одну неоднозначную команду.
| Задача | Рекомендуемая цель | Нужен ли конкретный UDID |
|---|---|---|
| Создание Archive | generic/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 и четырёх дата-центров. Ресурсы не распределяются между арендаторами, а фактическая доступность отображается в консоли в реальном времени.