Публикация в App Store во многих командах до сих пор выглядит так: PM давит, iOS-лид вручную делает Archive, ночью заливает TestFlight, утром снова заполняет метаданные. Функционал давно в main, а тормозит не код — нет инженерного release-контура. В этой статье с точки зрения CI/CD разбираем путь от Xcode до прохождения модерации на шесть наблюдаемых этапов: границы подписи, сборки, загрузки, отправки на проверку и публикации, плюс готовый Runbook. Если выбираете, куда поставить узел сборки, начните с новых подходов к iOS-пайплайнам — там же слоистая схема Mac-острова.
После прочтения у вас будет: схема из шести этапов, таблица ручной работы vs автоматизации, минимальный YAML fastlane + GitHub Actions и чеклист повторной сборки TestFlight за 30 минут после отказа модерации.
Зачем считать публикацию инженерным контуром, а не чеклистом
Новички часто воспринимают публикацию как чеклист: скриншоты, политика конфиденциальности, кнопка Submit. Проблема в том, что чеклист не хранит состояние и не гарантирует, что N-й релиз пройдёт с теми же предпосылками, что и первый. С инженерной точки зрения публикация — направленный пайплайн: каждый этап выдаёт конкретный артефакт, следующий потребляет только выход предыдущего:
- Повторяемость: один и тот же git tag на Mac-острове должен давать тот же IPA, что и локально (при одинаковых Xcode и lock-файлах зависимостей)
- Наблюдаемость: у каждого этапа — лог, время и код ошибки; в Slack видно «upload успешен / обрабатывается / готово к отправке», а не «вроде залили»
- Откат: в магазине остаётся предыдущий build; в git — tag и dSYM, чтобы символикация крашей не обрывалась
- Разделение ролей: разработка отвечает за бинарник и номер версии; продукт и маркетинг — за текст и скриншоты в магазине; CI разводит это разными job или approval gate
Кросс-платформенным командам без Mac эта схема тоже подходит: код на Windows / Linux, release-этапы сходятся на macOS-узле. Модель разделения — в материале Windows/Linux + удалённый Mac-остров сборки.
Шесть этапов: от Xcode Scheme до продажи в App Store
Таблица ниже — каркас всей статьи. Runbook в конце выстроен в этом порядке, чтобы не загрузить билд и только потом обнаружить, что build number не увеличили.
| Этап | Основные действия | Типичное время | Степень автоматизации |
|---|---|---|---|
| ① Предпроверка | Номер версии, lock зависимостей, unit-тесты, черновик privacy manifest | 5–15 мин | Высокая (Linux/Mac) |
| ② Подготовка подписи | Сертификаты, provisioning profile, разблокировка keychain, ASC API Key | Разово + ротация | Средняя (инъекция Secret) |
| ③ Archive & Export | xcodebuild archive, экспорт IPA | 10–40 мин | Высокая (выделенный Mac) |
| ④ TestFlight | Загрузка, обработка, внутренние группы | 15–60 мин | Высокая |
| ⑤ Отправка на модерацию | Метаданные, скриншоты, export compliance, submit for review | ~30 мин вручную + ожидание | Средняя (нужен approval gate) |
| ⑥ Публикация | Одобрение, phased release / полный выкат | 24–48 ч модерации | Средняя (опрос статуса) |
Главный водораздел — между этапами ④ и ⑤: TestFlight стоит автоматизировать максимально (каждый merge в main или nightly); отправку на модерацию лучше пропускать через ручное или PM-одобрение, чтобы не уехать в очередь с неготовым текстом. Этап ③ обязан выполняться на Mac с зафиксированной версией Xcode — основной сценарий для Cloud Mac как iOS CI-узла, а не для повседневного написания Swift.
Подпись и сертификаты: главный источник недетерминизма в CI
Локально Xcode спокойно делает Archive, а в CI вылезает errSecInternalComponent или No profiles for ... — почти всегда виноваты не зафиксированные идентичность и окружение, а не сам проект.
Три типа учётных данных — не смешивайте в одном Secret
| Учётные данные | Назначение | Где хранить в CI |
|---|---|---|
| Distribution-сертификат (p12) | codesign устанавливаемого пакета | Зашифрованный Secret; keychain на Mac-острове или временный keychain |
| Provisioning Profile | Привязка Bundle ID, capabilities, устройств | Скачивать по UUID в приватную папку job, не перезаписывать глобально |
| ASC API Key (.p8) | Загрузка, статус build, deliver для модерации | GitHub Encrypted Secret; в keychain не нужен |
При параллельной подписи в нескольких job типичные «хвостовые» сбои — гонка за keychain и каталог профилей. Решение: отдельный пользователь на runner, временный keychain на job. Подробности — в FAQ по изоляции keychain и профилей при codesign.
Минимальный скрипт keychain для CI (Xcode 16 / macOS 14)
# 在 self-hosted Mac runner 上,每个 release job 开头
KEYCHAIN=$RUNNER_TEMP/build.keychain-db
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security list-keychains -s "$KEYCHAIN" login.keychain-db
security import dist.p12 -k "$KEYCHAIN" -P "$P12_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
Артефакты сборки: Archive → Export → загрузка в TestFlight
Release-сборка отличается от Debug не «ещё одним флагом оптимизации», а тем, что конфигурация подписи, параметры Export и метаданные версии должны совпадать с записью приложения в App Store Connect.
xcodebuild Archive (командная строка вместо Product → Archive)
xcodebuild -scheme MyApp -configuration Release \
-destination 'generic/platform=iOS' \
-archivePath "$RUNNER_TEMP/MyApp.xcarchive" \
archive \
DEVELOPMENT_TEAM=XXXXXXXXXX \
-allowProvisioningUpdates
Экспорт IPA и загрузка
xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/MyApp.xcarchive" \
-exportPath "$RUNNER_TEMP/export" \
-exportOptionsPlist ExportOptions.plist
# ExportOptions.plist 中 method 应为 app-store
# 上传可用 xcrun altool(旧)或 fastlane pilot / deliver
Инженерные правила:
- CFBundleVersion (build number) при каждой загрузке должен расти — привяжите к git tag или номеру CI run и зафиксируйте в Runbook
- Сохраняйте .xcarchive и dSYM — в GitHub Artifacts или S3; и отказы модерации, и продакшен-крэши зависят от символов
- Ключ кэша DerivedData с мажорной версией Xcode — после обновления Xcode грязный кэш может дать «собралось, подпись упала»
Стабильность времени этапа ③ сильно зависит от диска и политики кэша на Mac-острове — см. FAQ по кэшу и диску self-hosted runner.
Модерация: что автоматизировать помимо ручной проверки Apple
Модерация Apple по-прежнему в основном ручная, но массу повторяющейся работы до и после можно отдать пайплайну:
Что имеет смысл автоматизировать
- Опрос статуса обработки build (
processing→valid) - Проверка, что сохранённые ответы по export compliance, авторским правам и рекламному идентификатору всё ещё актуальны
- Отправка метаданных и скриншотов через fastlane
deliverили App Store Connect API (если исходники скриншотов лежат в git) - После одобрения — уведомление в Slack / запуск phased release с заданным процентом
Где лучше оставить ручной gate
- Первая публикация в регионе, чувствительные категории: платежи, здоровье, детский контент
- Финальные маркетинговые тексты, превью-видео и скриншоты магазина
- Сама кнопка «отправить на модерацию» — через GitHub Environment
required_reviewersили ручнойworkflow_dispatch
При отказе (Rejected) или отклонении метаданных (Metadata Rejected) Runbook должен требовать: сначала правка в git, затем bump build, снова ③→④ — не меняйте только веб-интерфейс Connect без нового бинарника (кроме чисто метаданных). Частые причины — отсутствие Privacy Manifest, нет демо-аккаунта для входа, неполные декларации SDK — их стоит ловить на этапе ① скриптами по PrivacyInfo.xcprivacy и ключам Info.plist.
CI/CD Runbook: минимальный контур GitHub Actions + fastlane
Ниже — минимальный workflow «merge в main → сборка видна в TestFlight». Job отправки на модерацию по умолчанию выключен: нужен tag release/* или ручной запуск.
Фрагмент Fastfile (загрузка в TestFlight)
# fastlane/Fastfile
default_platform(:ios)
platform :ios do
lane :beta do
setup_ci if ENV['CI']
match(type: "appstore", readonly: true)
build_app(
scheme: "MyApp",
export_method: "app-store",
output_directory: "./build"
)
upload_to_testflight(
skip_waiting_for_build_processing: false,
api_key_path: "asc_api_key.json"
)
end
lane :release do
upload_to_app_store(
submit_for_review: true,
automatic_release: false,
precheck_include_in_app_purchases: false
)
end
end
GitHub Actions: предпроверка + Release (self-hosted Mac)
name: App Store Release
on:
push:
branches: [main]
workflow_dispatch:
inputs:
submit_review:
description: 'Submit to App Review'
type: boolean
default: false
jobs:
preflight:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: swiftlint --strict
- run: swift test --parallel
- name: Check Privacy Manifest exists
run: test -f MyApp/PrivacyInfo.xcprivacy
beta:
needs: preflight
runs-on: [self-hosted, macos, ios-release]
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: ~/ci/DerivedData
key: dd-xcode16-${{ hashFiles('**/Package.resolved') }}
- name: TestFlight upload
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
run: bundle exec fastlane beta
submit-review:
if: github.event.inputs.submit_review == true
needs: beta
runs-on: ubuntu-latest
environment: app-store-review
steps:
- uses: actions/checkout@v4
- run: bundle exec fastlane release
Оркестрация остаётся в GitHub Actions, исполнение тяжёлых шагов — на self-hosted Mac-острове, в той же философии, что и в гибридном пайплайне: Linux для быстрых проверок, Mac только для Archive и загрузки. Временное расширение runner на релизной неделе — в разделе про burst в той же статье.
Типичные ошибки: когда не стоит полностью автоматизировать отправку на модерацию
Инженеризация ≠ «все кнопки роботу». В этих случаях полный автоматический submit_for_review чаще вредит:
- Тексты магазина ещё правят в Google Doc — расхождение с Connect ведёт к Metadata Rejected
- Backend API жёстко связан с версией приложения — сначала убедитесь, что сервер в серой зоне готов, потом отправляйте на модерацию
- Первое подключение ATT, платежей, HealthKit — ответы ревьюеру и демо-видео часто требуют участия продукта
- Подпись на shared hosted macOS runner — дрейф окружения хуже очереди; release-контур нужен на выделенном Mac
Разумная стратегия по умолчанию: полная автоматизация до TestFlight, полуавтомат до App Review. Внутренняя группа проверила сборку, PM одобрил в Environment — только тогда запускается job submit-review.
FAQ
Нужен ли локальный Mac для публикации в App Store?
Не обязательно. Повседневная разработка может идти на Windows или Linux, но Archive, подпись и загрузка в TestFlight требуют macOS. Команды без локального Mac проходят весь путь публикации по SSH или VNC на Cloud Mac или self-hosted Mac-острове сборки.
Можно ли объединить TestFlight и отправку на модерацию в один CI-пайплайн?
Да — этапы сборки и подписи общие, но лучше разделить на два job: сначала upload_to_testflight для внутреннего тестирования; job отправки на модерацию запускается вручную или через approval gate и отдельно проверяет метаданные, скриншоты и privacy manifest.
Как выбрать между fastlane и нативным xcodebuild?
xcodebuild отвечает за компиляцию и Archive; fastlane оборачивает подпись, загрузку, метаданные и deliver для модерации. Небольшие команды могут отдать всё fastlane; в крупных репозиториях в CI явно вызывают xcodebuild, а fastlane оставляют только на release-слое.
ASC API Key или p12-сертификат — что лучше для CI?
Для загрузки бинарника и опроса статуса модерации предпочтительнее ASC API Key (.p8) — импорт в keychain не нужен. Для codesign всё равно нужны Distribution-сертификат и provisioning profile. Типичное разделение: API Key для Connect, p12 для подписи, отдельные Secret.
Как быстро выпустить новую сборку после отказа модерации?
Сохраните git tag и dSYM отклонённой сборки; после исправления увеличьте build number и пройдите тот же release-пайплайн upload_to_testflight, проверьте на TestFlight и снова вызовите submit_for_review. Критично: build number монотонно растёт, версия метаданных согласована.
Export Compliance и privacy manifest — их обязательно заполнять вручную?
Первый раз — в веб-интерфейсе App Store Connect, затем зафиксируйте шаблон; CI может записывать уже согласованные ответы через fastlane deliver или App Store Connect API. Если приложение использует только HTTPS, большинство команд выбирают стандартное исключение и фиксируют его в Info.plist.
Сколько времени обычно занимает путь от Archive до одобрения?
Сборка: 10–40 минут в зависимости от размера проекта; загрузка и обработка: 15–60 минут; модерация: обычно 24–48 часов, в праздники дольше. Инженерная цель — сжать и предсказать всё, кроме ожидания Apple.
Подходит ли удалённый Cloud Mac как узел публикации?
Да. Выделенный физический Mac с зафиксированной версией Xcode, постоянным keychain и DerivedData стабильнее для подписи и релизных дедлайнов, чем shared hosted runner; первичную настройку TCC — через VNC, далее пайплайн по SSH без GUI.
Итоги
Публикация в App Store с точки зрения CI/CD — это шестиэтапный release-контур, а не разовый Archive в Xcode. Разделив подпись, сборку, TestFlight, отправку на модерацию и публикацию, вы локализуете сбой на конкретном этапе, а не получаете абстрактное «опять не вышло в магазин».
- Предпроверку и TestFlight автоматизируйте максимально; отправку на модерацию — через approval gate
- Три типа учётных данных храните раздельно; при параллельной сборке изолируйте keychain и профили
- build number привязывайте к git tag; archive и dSYM обязательно сохраняйте
- Этапы ③④ — на выделенном Mac-острове, этап ① — на Linux
- После отказа модерации — тот же пайплайн с новым build number, не только правки в веб-интерфейсе
Следующий шаг: добавьте в репозиторий workflow только с загрузкой в TestFlight, прогоните его, затем подключите job с environment: app-store-review. Узел сборки можно начать с посуточного Cloud Mac на неделю, прежде чем закреплять постоянный Mac mini. Тарифы — на странице планов, старт заказа — в конфигураторе.
Что читать дальше
- GitHub Actions не справляется? Новые подходы к iOS-пайплайнам
- Корпоративный Mac CI: изоляция keychain и профилей при codesign
- iOS CI тормозит: очередь в GitHub Actions и Cloud Mac
- Self-hosted runner: кэш, диск и параллельные job
- Windows/Linux + удалённый Mac-остров сборки
- Корпоративный CI: SSH vs VNC и несколько репозиториев