Есть один факт про GitHub Actions, который многие мобильные разработчики узнают только из счёта: одна минута на macOS-раннере стоит десять минут на Linux. Не в деньгах — в «бесплатных» минутах, которые входят в тарифный план. Ваш пятиминутный iOS-архив съедает из квоты столько же, сколько пятьдесят минут Linux-тестов. И пока Android-джоба тратит 6 минут квоты, её соседка по воркфлоу тратит 45.
Ниже — история одного переезда: как я вынес iOS-деплой из GitHub Actions в Xcode Cloud, что при этом сломалось (семь раз), что измерил и почему теперь iOS-релиз стоит мне ноль минут GitHub Actions и 6 минут оплачиваемого compute-времени Apple при 25 бесплатных часах в месяц.
Проект — кроссплатформенная Flutter-игра, которая выкладывается в TestFlight и Google Play internal testing. Но большинство выводов одинаково применимы к нативному Swift-приложению.
С чего всё началось: 90 строк YAML, которые никто не хотел трогать
Старый пайплайн выглядел так, как выглядят почти все «самодельные» iOS-пайплайны в GitHub Actions:
дистрибутивный сертификат
.p12в base64 в секретах репозитория;provisioning profile — тоже в base64;
пароль к сертификату, три параметра App Store Connect API key (ID, issuer,
.p8в base64);временный keychain, который создаётся, разблокируется и удаляется в шагах воркфлоу;
sedпоproject.pbxproj, чтобы подменить стиль подписи на ручной;xcrun altool/xcrun notarytool/ загрузка в TestFlight.
Итого — шесть секретов только ради подписи и около 90 строк YAML, к которым все боялись подходить: любое изменение проверялось только пушем тега и ожиданием, пока macOS-раннер поднимется, поставит Flutter, скачает поды и упадёт на этапе xcodebuild archive.
Работало это, надо признать, стабильно: три релиза подряд, по 4,5–6,5 минут wall-clock каждый. Но экономика была так себе. GitHub умножает macOS-минуты на 10 при списании с включённой в тариф квоты, так что каждый релиз стоил ~45 «минут» из 2000–3000 в месяц. При pay-as-you-go это около $0.06 за минуту на стандартном macOS-раннере против $0.006 на Linux — не разорение, но десятикратная разница по сравнению с Android-частью того же воркфлоу заставляет задуматься.
И главное — сертификат истекает раз в год, профиль надо перевыпускать при каждом добавлении устройства, а .p12 в base64 — это ровно тот артефакт, который однажды окажется не там, где надо.
Почему Xcode Cloud
Apple с января 2024 года включает 25 compute-часов Xcode Cloud в месяц в стандартную подписку Apple Developer Program — то есть в те $99 в год, которые вы и так платите. Платные тарифы начинаются со 100 часов за $49.99/мес, но до них ещё надо дорасти.
Помимо цены, у Xcode Cloud есть три свойства, ради которых стоило переезжать:
Managed signing. Никаких
.p12, профилей, keychain’ов иsedпо pbxproj. В проекте остаётсяCODE_SIGN_STYLE = AutomaticиDEVELOPMENT_TEAM = <ваш Team ID>, всё остальное Apple делает сама. Одна строчка в логе —Automatically signing iOS for device deployment using specified development team— заменяет 90 строк YAML.TestFlight-доставка — это не шаг, а настройка. «Distribution Preparation: TestFlight (Internal Testing Only)» — и архив улетает в TestFlight без API-ключей.
Кастомизация через три shell-скрипта. Apple ищет в репозитории папку
ci_scripts/рядом с проектом (ios/ci_scripts/для Flutter) и запускаетci_post_clone.sh,ci_pre_xcodebuild.sh,ci_post_xcodebuild.sh. Этого достаточно, чтобы поставить Flutter, собрать.env, прогнать проверки и отправить уведомление.
Есть и цена: воркфлоу живёт в App Store Connect, а не в репозитории. Это главный архитектурный минус, и о нём — отдельно ниже.
Важная оговорка для разработчиков из РФ и РБ. Из-за политики Apple аккаунтам Apple Developer Program, у которых юридический адрес (business entity) указан в России или Беларуси, Xcode Cloud недоступен: при попытке включить его консоль отвечает «This Apple Developer Program account cannot utilize Xcode Cloud. Records indicate that your business entity is located in the Russian Federation or the Republic of Belarus, which are currently subject to restrictions by various jurisdictions». Единственный рабочий вариант — аккаунт с регионом/юрадресом вне РФ и РБ; проверьте это до того, как планировать миграцию, иначе всё описанное ниже просто не включится.
Схема тегов: ios/v* и android/v*
Первое решение, которое пришлось принять: как триггерить только нужную платформу. Раньше один тег v1.0.2 запускал одну джобу с двумя платформами. Теперь платформы разъехались по разным CI, и я развёл их по префиксам:
git tag ios/v1.0.3 && git push origin ios/v1.0.3 # → Xcode Cloud git tag android/v1.0.3 && git push origin android/v1.0.3 # → GitHub Actions
Голый тег v1.0.3 сознательно не запускает ничего. Это важный нюанс: старая привычка должна проваливаться тихо и бесплатно, а не дорого и с ошибкой. В README теперь есть строка: «тишина после v* — не сломанный пайплайн».
В Xcode Cloud это одна настройка: Start Condition → Tag Changes → Custom Tags → «Tags beginning with ios/v». В GitHub Actions — on.push.tags: ['android/v*'].

Три скрипта, которые делают всю работу
Для нативного Swift-проекта ci_scripts/ может вообще не понадобиться. Для Flutter — без них никак: на машине Apple нет Flutter SDK, а Runner.xcworkspace без Generated.xcconfig не соберётся.
ci_post_clone.sh — самый тяжёлый. Он:
клонирует Flutter нужной версии (
git clone --depth 1 -b $FLUTTER_VERSION), делаетflutter precache --iosиflutter pub get;проверяет, что версия в теге совпадает с
version:вpubspec.yaml— иначе билд падает сразу, пока не потрачено ни минуты компиляции;собирает
.envиз переменных окружения воркфлоу и падает, если обязательный ключ аналитики не задан (об этом ниже);делает
flutter build ios --config-only --release --dart-define-from-file=.env— это генерируетGenerated.xcconfigи запускаетpod install;отправляет в Telegram
STARTED.
ci_pre_xcodebuild.sh — «ворота»: декодирует DART_DEFINES из Generated.xcconfig и проверяет, что ключ аналитики реально попал внутрь и непустой. Стоит секунду, а превращает тихий отказ аналитики в громкий отказ сборки.
ci_post_xcodebuild.sh — отправляет в Telegram ARCHIVED (или FAILED), с номером сборки и версией.
Обратите внимание на слово ARCHIVED, а не DEPLOYED. Скрипт выполняется до того, как Xcode Cloud отдаёт архив в TestFlight, и не может знать, пройдёт ли экспорт-комплаенс и обработка. Честное уведомление лучше оптимистичного. Бэкап на случай пост-архивной ошибки — встроенное email-уведомление Xcode Cloud.
Где живут ключи (и где они не живут)
Ни один секрет не лежит в репозитории и не попадает в лог. Правило простое:
Xcode Cloud: App Store Connect → ваше приложение → Xcode Cloud → Workflows → воркфлоу → Environment → Environment Variables. Для каждого секрета ставьте галочку «Keep value redacted» — тогда даже случайный
echoвыведет звёздочки.GitHub Actions: репозиторий → Settings → Secrets and variables → Actions → New repository secret.
Если один и тот же ключ (например, аналитики) нужен обеим платформам, у вас теперь две копии — запишите это в документацию, иначе при ротации обновят одну.
Скрипты со своей стороны логируют только факт наличия: API_KEY present (32 characters) — value not logged, а в .env для лога пишется <redacted, 32 chars>. «Keep value redacted» — это защита в глубину, а не единственная линия обороны.
Настройки, которые реально экономят compute-часы
Вот что я нашёл в дефолтном воркфлоу, который Xcode Cloud создал при онбординге, и что сразу переключил:
Clean был включён. Apple прямо пишет, что Clean «значительно увеличивает время сборки» и нужен только при раздаче внешним тестировщикам TestFlight. Для internal testing он просто сжигает derived data и кеш при каждом билде. Выключил.
Start condition стоял на Branch Changes → main. Каждый пуш в main — включая правки README — запускал полноценный архив. Первый (и провальный) билд в истории проекта был именно таким. Заменил на теги.
Xcode Version — оставил «Latest Release», хотя в спеке была рекомендация пиннить версию. Аргумент за пин — «изменение тулчейна должно быть осознанным коммитом». Аргумент против оказался сильнее: Apple удаляет старые версии Xcode из Xcode Cloud (все 15.x и 16.x исчезли одним днём), так что пин однажды сломается сам, и диагностировать это сложнее, чем обычный апгрейд. Плюс с апреля 2026-го Apple требует iOS 26 SDK для загрузки в App Store Connect — тулчейн всё равно обязан двигаться.
Семь падений на первом живом прогоне
Правило «первое живое инфраструктурное упражнение даёт 3–8 хотфикс-коммитов» сработало точно: шесть коммитов, семь отдельных причин. Что интересно — ни одна не была ошибкой дизайна миграции. Все семь — либо старые дефекты проекта, либо особенности образа Apple. Две из них ломали Android и локальную сборку на Mac ничуть не меньше, просто никто не заметил.
# | Симптом | Реальная причина |
|---|---|---|
1 | Ни один тег не запускает билд | Привязка репозитория в Xcode Cloud была создана до того, как GitHub App получило доступ, и осталась протухшей. Дать приложению доступ — недостаточно. Помогло «New Primary Repository» → «Access Granted». |
2 |
| Flutter в CI был запинен на версии годичной давности, чьи |
3 | SPM не резолвится | Свежий Flutter гоняет плагины через Swift Package Manager; Xcode Cloud отключает автоматический резолв и требует закоммиченный |
4 | «specs repository is too out-of-date» | Мой неправильный диагноз. Это generic-фолбэк CocoaPods, который печатается всегда, когда podspec не удаётся вычислить. |
5 | Настоящая причина #4 | Podspec одного плагина запускает Ruby-хелпер с |
6 | Двойной |
|
7 | Конфликт версий одной рекламной SDK |
|
Мораль из #2 и #7: CI, который вы не запускаете, не проверяет ничего. Стоило перенести iOS на новую платформу — и вылезли два репо-wide дефекта, которые «зелёный» Android-пайплайн просто ещё не успел показать.
Мораль из #4: «too out-of-date» в CocoaPods почти никогда не значит то, что написано. Читайте лог выше этой строки.
Цифры
Всё, что было в спеке до живого прогона, было оценкой. Вот измерения с первого зелёного билда:

Метрика | Оценка «до» | Измерено |
|---|---|---|
Wall-clock | ~15 мин (пессимистично) | 10 мин (очередь 10 с) |
Оплачиваемое compute-время | ~15 мин | 6 мин |
Запас релизов/мес на 25-часовом тарифе | ~100 | ~250 |
macOS-минут GitHub Actions на iOS-релиз | ~45 | 0 |
Пессимистичный бюджет в 15 минут оказался в 2,5 раза выше реального. Главная причина: Xcode Cloud считает compute, а не wall-clock, и клонирование Flutter с precache — это в основном сеть, а не CPU.
Ещё три вещи, которые «бумага» не могла доказать, а живой прогон доказал:
Биты
100755и LF-окончания пережили путь Windows → git → GitHub → Xcode Cloud. Скрипты писались на Windows-машине, и риск «Apple не найдёт исполняемых скриптов» был в спеке самым высоким. Проверять надо не файлы на диске, а индекс git:git ls-files -s ios/ci_scripts/покажет100755, а.gitattributesс*.sh text eol=lfзакроет вопрос окончаний.Apple не удаляет
Generated.xcconfigиPods/между фазами. Открытый вопрос в спеке звучал: «а вдруг между post-clone и archive среда чистится?». Ответ — нет,ci_pre_xcodebuild.shувидел непустой ключ. Ворота я всё равно оставил.Секреты не утекли в лог даже в тех переменных, где галочка «redacted» ещё не была поставлена. Значит, дисциплина в скриптах работает.
Что осталось, и что бы я сделал иначе
Открытые хвосты (оба требуют Mac):
Перегенерировать
Podfile.lockи закоммитить — пока скрипт удаляет протухший лок, поды в CI не запинены, а для релизного пайплайна это потеря воспроизводимости.Закоммитить
Package.resolved, чтобы вернуть SPM до того, как Flutter сделает его обязательным.Установить билд на реальное устройство и убедиться, что событие аналитики доходит. Зелёный билд доказывает конфиг, но не бинарник.
И одно — про документацию. Воркфлоу Xcode Cloud живёт в консоли, вне git. Это значит, что единственная запись о том, как настроен iOS-пайплайн — это runbook в репозитории, который кто-то обязан обновлять в тот же день, когда меняет настройку в консоли. У меня это docs/release/xcode-cloud-runbook.md с секцией «AS-CONFIGURED STATE» и датой. Без этого пайплайн через полгода становится фольклором, которую помнит один человек.
Сохраняйте старые GitHub-секреты, пока первый живой релиз не станет зелёным. git revert коммита, удаляющего iOS-джобу — это ваш rollback ровно до тех пор, пока сертификат и профиль ещё лежат в секретах. Удалять их после — осознанно и с датой в runbook.
Чек-лист, если вы собираетесь повторить
Убедитесь, что
Runner.xcscheme(или ваша схема) — shared, сArchiveActionнаRelease.В Xcode создайте воркфлоу (первый раз — только из Xcode, дальше можно из веба). Сразу: Clean — off, Start Condition — теги по префиксу, Distribution Preparation — TestFlight Internal.
Занесите секреты в Environment Variables с «Keep value redacted». Ничего не коммитьте.
Напишите
ci_scripts/с fail-fast на отсутствующих ключах и проверкой версии тега.Проверьте
100755и LF в индексе git, а не на диске.Разведите платформы по тегам
ios/v*иandroid/v*, голыйv*— no-op.Не удаляйте старые секреты до первого зелёного живого билда.
После первого билда запишите измеренные минуты в runbook и пересчитайте запас.
Шесть минут за релиз против сорока пяти. Ноль строк с base64. И одна привычка, которую стоило приобрести давно: измерять, а не оценивать.
Послесловие: из семи падений родился скилл
Где-то между падением №4 (которое оказалось не падением) и №7 (которое оказалось четырёхмесячным Podfile.lock) я поймал себя на мысли, что весь этот путь — детект стека, опрос «куда деплоим и что триггерит», карта секретов по именам, три скрипта, консоль App Store Connect, первый тег, чтение логов, фикс, повтор — это не уникальная история одного проекта. Это одна и та же последовательность для любого мобильного репозитория, и я больше не хочу проходить её руками.
Поэтому вся возня превратилась в скилл mobile-cicd для агентных ассистентов: он сам определяет, нативный ли проект или Flutter/RN/KMP и какие модули в нём есть, спрашивает, что деплоить (Google Play, TestFlight, Firebase App Distribution), по какому правилу (тег, ветка, вручную) и куда слать уведомления (Telegram или Slack), генерирует ci_scripts и воркфлоу из проверенных шаблонов, помогает завести секреты, не запрашивая ни одного значения, при желании прокликивает консоль Xcode Cloud через расширение Chrome, пушит, смотрит первый билд и чинит по логам, пока он не станет зелёным. Внутри — тот самый ранбук и каталог всех падений из этой статьи.
Скилл открытый, работает с Claude Code, Codex CLI, Gemini CLI, Cursor и любой LLM, которой можно дать папку с markdown: https://github.com/goodiny777/mobile-cicd-skill. Если он сэкономит вам хотя бы одно из семи падений — напишите, какое.