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

