Приложение для iOS без Mac: GitHub Actions вместо сборочной машины
Приложению для iOS нужен Xcode, а Xcode нужна macOS. Я работаю на виртуальной машине с Linux в облаке, и Mac на Apple silicon у меня нет. Тем не менее два моих приложения уже в TestFlight, причём собраны, протестированы, подписаны и загружены целиком в GitHub Actions. Рассказываю, как это устроено и где больно. (Как приложения делят код с Android — в предыдущей статье.)
Варианты
- Купить Mac. Очевидный ответ и правильный для ежедневной iOS-разработки. Но для двух приложений, чей код — общий Kotlin, машину, которая в основном собирала бы, оправдать было трудно.
- Виртуальная macOS на моём Linux-сервере. Лицензия Apple разрешает macOS только на железе Apple, да и GPU у облачной машины нет.
- Арендованный Mac в облаке. Можно, но у моего облачного провайдера их нет, и это ещё одна машина, которую надо обслуживать.
- Раннеры macOS от GitHub. Свежий Mac с актуальным Xcode на каждый запуск, поминутная оплата, обслуживать нечего.
Я выбрал раннеры и одно правило: всё, что завязано на Apple, выполняется в CI, а скрипт, который выполняет CI, точно так же запустится и на настоящем Mac, если он когда-нибудь появится.
Что умеет Linux-машина
Больше, чем я думал. Kotlin/Native компилирует iOS-таргеты общего кода на Linux. Слинковать iOS-фреймворк он там не может, но большинство ошибок видно уже при компиляции. Поэтому агенты на Linux-машине проверяют каждое изменение командой
./gradlew :shared:compileKotlinIosSimulatorArm64
и общими тестами на JVM. Код на Swift, проект Xcode, симулятор и всё, что связано с подписью, требуют macOS и уходят в CI.
Workflow для iOS
Один workflow запускается на каждое изменение общего кода или iOS-оболочки и на каждый пул-реквест. Он вызывает один shell-скрипт, который:
- Выбирает симулятор запросом, а не по имени. Имена устройств меняются с каждым выпуском Xcode, поэтому скрипт спрашивает у
simctlсамый новый рантайм iOS и первый iPhone в нём. - Запускает общие тесты на Kotlin на этом симуляторе и заодно линкует фреймворк для настоящего устройства: ошибка линковки, которая бывает только для устройств, должна всплыть здесь, а не в первой сборке для TestFlight.
- Генерирует проект Xcode с помощью XcodeGen из одного YAML-файла.
.xcodeprojв git не хранится, поэтому нет конфликтов слияния в файле, который никто не может прочитать. - Запускает юнит- и UI-тесты приложения через
xcodebuild testи повторяет проверки для маленького экрана на самом маленьком iPhone, который есть в симуляторе. - Снимает видео каждого прогона, выгружает скриншоты из результатов тестов, а при падении приложения печатает в лог крэш-репорты из симулятора.
Съёмка занимает несколько строк:
xcrun simctl boot "$UDID" || true
xcrun simctl io "$UDID" recordVideo --codec=h264 --force "media/$NAME.mp4" &
RECORDER=$!
xcodebuild test -project App.xcodeproj -scheme App \
-destination "id=$UDID" -resultBundlePath "results/$NAME.xcresult" CODE_SIGNING_ALLOWED=NO
kill -INT "$RECORDER"
xcrun xcresulttool export attachments --path "results/$NAME.xcresult" --output-path "media/$NAME"
Видео и скриншоты сохраняются как артефакт, который живёт день или неделю. Раз устройства на столе нет, через них я и вижу приложение: упавший UI-тест приходит с записью того, что на самом деле происходило на экране.
Цифры
За время переезда тренажёра грамматики workflow для iOS запускался 55 раз: 29 зелёных, 14 красных и 12 отменённых, потому что их сменил более новый push. Зелёный прогон длится около 20 минут (от 8 до 33). Минуты macOS — самые дорогие у GitHub, а в приватном репозитории каждая из них засчитывается как десять минут тарифа. Поэтому workflow стартует, только когда меняются файлы, способные повлиять на iOS, а новый push отменяет текущий прогон.
Чему научили раннеры
Большинство красных прогонов были настоящими ошибками. Некоторые оказались уроками про окружение:
- Скрытый WebView перехватывал тапы. На свежем симуляторе SDK согласия Google держит невидимый полноэкранный WebView, и XCUITest объявлял нижние ячейки списка Compose «not hittable». Тесты починило перетаскивание по координатам вместо тапов по элементам; само приложение было в порядке.
- Тестовая сессия StoreKit загружала конфигурацию и не возвращала продуктов. На раннерах это повторялось с любым форматом файла, который я пробовал. Теперь такие тесты в этом случае пропускаются с объяснением, а покупку проверяю на настоящем устройстве через TestFlight.
- Разрешение на уведомления в CI не получить. Симулятор отклоняет запросы уведомлений без разрешения, поэтому тесты проверяют, какие напоминания приложение формирует, а не их доставку.
- Загрузка может упасть на стороне Apple. Одна загрузка в TestFlight закончилась ответом 504 от Apple; повторный запуск того же workflow прошёл.
Подпись без кнопок Xcode
На Mac Xcode создаёт сертификаты и профили одним щелчком. Без Mac всё идёт через App Store Connect API по API-ключу:
- distribution-сертификат и provisioning profile созданы через API и хранятся как зашифрованные секреты репозитория;
- release-сборка подписывается ими вручную, это задано в файле XcodeGen;
- на раннере сертификат попадает во временный keychain, который создаётся на один запуск и удаляется после него;
- номер сборки — номер запуска workflow, поэтому он всегда растёт;
- отдельный workflow для TestFlight, который запускается вручную или тегом выпуска, архивирует release-сборку и загружает её;
- конфигурация Firebase тоже приходит из секрета; без него в сборку попадает заглушка, так что пул-реквест из ветки всё равно собирается и тестируется.
Одну из первых загрузок отклонили ещё до TestFlight: сборка, которая поддерживает iPad, должна поддерживать там все ориентации, а моя была только портретной. Пока приложения только для iPhone. Чтобы это закрепить, понадобился ещё один прогон: пресет iOS в XcodeGen задаёт семейство устройств на уровне таргета и молча перекрывает ту же настройку на уровне проекта.
App Store Connect из скрипта
Большую часть страницы в App Store тоже можно настроить через API: тексты и скриншоты на семи языках, страны, возрастной рейтинг, встроенную покупку. Две вещи нельзя:
- App Privacy (какие данные собирает приложение) вообще не имеет API, её заполняют на сайте. Там есть ловушка: если в приложении есть текст запроса Apple на отслеживание, ответы нельзя опубликовать, пока хотя бы один тип данных не отмечен как используемый для отслеживания.
- Первая встроенная покупка отправляется на проверку только вместе с версией приложения, а к отправке на проверку её добавляют со страницы самой покупки, а не со страницы версии.
Что CI не заменит
Настоящее устройство. У симулятора нет настоящего сенсорного экрана, настоящего StoreKit и настоящего динамика, а по видео не понять, как звучит фортепиано. Для этого и нужен TestFlight: на iPhone приложение впервые запускается из сборки, которую сделал CI.
И человека в App Store Connect: ответы о приватности, соглашения, последнюю кнопку «отправить на проверку». Всё остальное — сборка, тесты, подпись и загрузка — работает с Linux-машины и из YAML-файла.