Есть один факт про 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 есть три свойства, ради которых стоило переезжать:

  1. 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.

  2. TestFlight-доставка — это не шаг, а настройка. «Distribution Preparation: TestFlight (Internal Testing Only)» — и архив улетает в TestFlight без API-ключей.

  3. Кастомизация через три 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 pub get падает

Flutter в CI был запинен на версии годичной давности, чьи flutter_localizations тянут старый intl, а проект два дня назад обновил intl. Android-релиз упал бы так же — просто его ещё не тегировали.

3

SPM не резолвится

Свежий Flutter гоняет плагины через Swift Package Manager; Xcode Cloud отключает автоматический резолв и требует закоммиченный Package.resolved, которого не было. Откатился на CocoaPods.

4

«specs repository is too out-of-date»

Мой неправильный диагноз. Это generic-фолбэк CocoaPods, который печатается всегда, когда podspec не удаётся вычислить. pod repo update не помог.

5

Настоящая причина #4

Podspec одного плагина запускает Ruby-хелпер с require 'xcodeproj' под системным Ruby 2.6, а CocoaPods на образе Apple стоит из Homebrew с гемами Homebrew-Ruby. Лечится gem install --user-install xcodeproj.

6

Двойной pod install

flutter build --config-only уже делает pod install; мой скрипт запускал его второй раз. Три падения из семи были про CocoaPods — уполовинить эту поверхность было полезно само по себе.

7

Конфликт версий одной рекламной SDK

Podfile.lock не перегенерировался четыре месяца. Обновление Flutter изменило pubspec.lock, и плагин запросил другую версию нативной SDK, чем записано в локе. Локальный flutter build ipa на Mac тоже был сломан — просто в Actions это маскировалось.

Мораль из #2 и #7: CI, который вы не запускаете, не проверяет ничего. Стоило перенести iOS на новую платформу — и вылезли два репо-wide дефекта, которые «зелёный» Android-пайплайн просто ещё не успел показать.

Мораль из #4: «too out-of-date» в CocoaPods почти никогда не значит то, что написано. Читайте лог выше этой строки.

Цифры

Всё, что было в спеке до живого прогона, было оценкой. Вот измерения с первого зелёного билда:

Оплачиваемые минуты на один iOS-релиз: GitHub Actions против Xcode Cloud
Оплачиваемые минуты на один iOS-релиз: GitHub Actions против Xcode Cloud

Метрика

Оценка «до»

Измерено

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.

Чек-лист, если вы собираетесь повторить

  1. Убедитесь, что Runner.xcscheme (или ваша схема) — shared, с ArchiveAction на Release.

  2. В Xcode создайте воркфлоу (первый раз — только из Xcode, дальше можно из веба). Сразу: Clean — off, Start Condition — теги по префиксу, Distribution Preparation — TestFlight Internal.

  3. Занесите секреты в Environment Variables с «Keep value redacted». Ничего не коммитьте.

  4. Напишите ci_scripts/ с fail-fast на отсутствующих ключах и проверкой версии тега.

  5. Проверьте 100755 и LF в индексе git, а не на диске.

  6. Разведите платформы по тегам ios/v* и android/v*, голый v* — no-op.

  7. Не удаляйте старые секреты до первого зелёного живого билда.

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