Разработчик публикует приложение в App Store через Mac и TestFlight

Публикация в App Store во многих командах до сих пор выглядит так: PM давит, iOS-лид вручную делает Archive, ночью заливает TestFlight, утром снова заполняет метаданные. Функционал давно в main, а тормозит не код — нет инженерного release-контура. В этой статье с точки зрения CI/CD разбираем путь от Xcode до прохождения модерации на шесть наблюдаемых этапов: границы подписи, сборки, загрузки, отправки на проверку и публикации, плюс готовый Runbook. Если выбираете, куда поставить узел сборки, начните с новых подходов к iOS-пайплайнам — там же слоистая схема Mac-острова.

После прочтения у вас будет: схема из шести этапов, таблица ручной работы vs автоматизации, минимальный YAML fastlane + GitHub Actions и чеклист повторной сборки TestFlight за 30 минут после отказа модерации.

Ключевая мысль: публикация — это не один клик Product → Archive в Xcode, а release-инженерия под четырьмя ограничениями: доверенная подпись + прослеживаемые артефакты + согласованные метаданные Connect + опрашиваемый статус модерации.

Зачем считать публикацию инженерным контуром, а не чеклистом

Новички часто воспринимают публикацию как чеклист: скриншоты, политика конфиденциальности, кнопка 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 manifest5–15 минВысокая (Linux/Mac)
② Подготовка подписиСертификаты, provisioning profile, разблокировка keychain, ASC API KeyРазово + ротацияСредняя (инъекция Secret)
③ Archive & Exportxcodebuild archive, экспорт IPA10–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"
Важно: при первом импорте сертификата на удалённом Mac часто нужна графическая сессия VNC для доверия keychain и TCC; после этого job идут по SSH без GUI. Разделение SSH и VNC — в корпоративный CI: SSH vs VNC.

Артефакты сборки: 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 (processingvalid)
  • Проверка, что сохранённые ответы по 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. Тарифы — на странице планов, старт заказа — в конфигураторе.

Что читать дальше