Skip to main content
mobile1 марта 2026 г.11 мин чтения

Автоматизация развертывания iOS: App Store Connect и TestFlight

Полное руководство по автоматизации развертывания iOS-приложений — от настройки Fastlane до распространения через TestFlight и публикации в App Store.

iosfastlanecicd
Автоматизация развертывания iOS: App Store Connect и TestFlight

Каждый iOS-разработчик проходил через этот ритуал: открыть Xcode, увеличить номер сборки, выбрать схему архивации, подождать пятнадцать минут, пройтись по Organizer, загрузить в App Store Connect, подождать еще немного, а затем вручную добавить тестировщиков в TestFlight. Делая это дважды в неделю для нескольких приложений, вы начинаете терять часы, которые следовало бы потратить на написание кода. Хуже всего не время, а непоследовательность. Один забытый шаг, один неверный профиль подготовки, и сборка либо тихо завершается с ошибкой, либо отклоняется автоматическими проверками Apple через несколько часов.

Я автоматизировал весь этот конвейер для iOS-приложения на Flutter и с тех пор применяю те же шаблоны к другим проектам. Инвестиции окупаются после третьего или четвертого цикла выпуска.

Почему ручное развертывание дает сбой

Ручной процесс имеет три фундаментальные проблемы, помимо очевидной траты времени.

Во-первых, концентрация знаний. Когда только один человек в команде знает, как развертывать приложение, он становится узким местом. Отпуска, больничные или просто нахождение в другом часовом поясе означают задержку релизов. Документация помогает, но 15-шаговое руководство по развертыванию само по себе является проблемой — оно устаревает в тот момент, когда кто-то меняет настройки сборки.

Во-вторых, человеческая ошибка в самый неподходящий момент. Релизы, как правило, происходят под давлением. Критическое исправление ошибки, дедлайн от клиента, функция, которую нужно выпустить раньше конкурента. Это именно те условия, при которых кто-то забывает увеличить номер сборки, выбирает не ту схему или загружает отладочную сборку вместо релизной.

В-третьих, отсутствие возможности аудита. Когда что-то идет не так с релизом, вы хотите точно знать, что было собрано, из какого коммита, с какой конфигурацией. Ручные процессы не оставляют надежного следа.

Fastlane: Основа

Fastlane — это де-факто стандарт для автоматизации развертывания iOS, и не без оснований. Он обрабатывает подписание кода, сборку, загрузку и управление метаданными через DSL на основе Ruby, который читается как контрольный список развертывания.

Начальная настройка

Настройка Fastlane в существующем проекте проста.

# Install Fastlane
brew install fastlane

# Initialize in your project directory (for Flutter, run in ios/)
cd ios
fastlane init

Во время инициализации Fastlane спрашивает, что вы хотите автоматизировать. Я рекомендую выбрать ручную настройку и настроить все явно — автоматическая настройка делает предположения, которые не всегда соответствуют структуре вашего проекта, особенно для приложений Flutter.

В результате создается каталог fastlane/, содержащий Appfile и Fastfile.

# fastlane/Appfile
app_identifier("com.yourcompany.yourapp")
apple_id("your@email.com")
itc_team_id("123456789")
team_id("ABCD1234EF")

Appfile хранит данные вашей учетной записи Apple Developer. Идентификаторы команд важны, если ваш Apple ID принадлежит нескольким командам — без них Fastlane будет запрашивать вас в интерактивном режиме, что нарушает работу CI.

Подписание кода с помощью Match

Подписание кода — это то, где большинство усилий по автоматизации iOS терпят крах. Профили подготовки, сертификаты, права — вся система кажется разработанной для того, чтобы наказывать автоматизацию. Инструмент match от Fastlane решает эту проблему, сохраняя ваши сертификаты и профили в частном репозитории Git или облачном хранилище, делая их доступными для любой машины, которой требуется сборка.

Настройка Match

fastlane match init

Это создает Matchfile, в котором вы настраиваете свое хранилище.

# fastlane/Matchfile
git_url("https://github.com/your-org/ios-certificates.git")
storage_mode("git")
type("appstore")
app_identifier(["com.yourcompany.yourapp"])

Затем сгенерируйте и сохраните свои сертификаты:

# Generate development certificates and profiles
fastlane match development

# Generate App Store distribution certificates and profiles
fastlane match appstore

Match шифрует все перед отправкой в репозиторий. Вы устанавливаете парольную фразу во время настройки, которая понадобится на каждой машине — включая CI.

Почему Match лучше ручного подписания

Альтернатива — ручное управление сертификатами через портал Apple Developer и распространение файлов .p12 — работает до тех пор, пока не перестает. Распространенные сбои включают:

  • Сертификат разработчика истекает, и никто не замечает этого, пока не завершится сбой сборки релиза.
  • Кто-то отзывает сертификат во время отладки проблемы с подписанием, делая недействительными все профили, которые от него зависят.
  • Новой машине CI требуется подготовка, а человек, который настраивал исходную машину, покинул команду.

Match устраняет все эти проблемы, являясь единым источником истины. Если срок действия сертификата истекает, вы запускаете fastlane match nuke и генерируете его заново. Каждая машина получает данные из одного и того же репозитория.

Подписание кода в CI

В CI вам нужно работать с macOS Keychain, так как нет графического интерфейса для его разблокировки. Fastlane предоставляет для этого вспомогательные средства:

lane :ci_setup do
  create_keychain(
    name: "ci_keychain",
    password: ENV["KEYCHAIN_PASSWORD"],
    default_keychain: true,
    unlock: true,
    timeout: 3600,
    lock_when_sleeps: false
  )

  match(
    type: "appstore",
    keychain_name: "ci_keychain",
    keychain_password: ENV["KEYCHAIN_PASSWORD"],
    readonly: true
  )
end

Флаг readonly: true критически важен для CI. Без него match может попытаться создать новые сертификаты, если не найдет существующих, что приведет к сбою без интерактивной авторизации и может отозвать существующие сертификаты.

Сборка с помощью Gym

Gym — это инструмент сборки Fastlane. Он оборачивает xcodebuild с разумными значениями по умолчанию и улучшенным выводом ошибок.

lane :build do
  gym(
    scheme: "Runner",
    workspace: "Runner.xcworkspace",
    export_method: "app-store",
    output_directory: "./build",
    output_name: "YourApp.ipa",
    clean: true,
    include_bitcode: false,
    export_options: {
      provisioningProfiles: {
        "com.yourcompany.yourapp" => "match AppStore com.yourcompany.yourapp"
      }
    }
  )
end

Для проектов Flutter у вас есть два варианта: запустить flutter build ipa и обработать вывод, или использовать gym непосредственно в рабочей области Xcode, которую генерирует Flutter. Я предпочитаю gym, потому что он дает больше контроля над параметрами подписания и экспорта, но оба варианта работают.

# Flutter-specific build lane
lane :build_flutter do
  Dir.chdir("..") do
    sh("flutter build ios --release --no-codesign")
  end

  gym(
    scheme: "Runner",
    workspace: "Runner.xcworkspace",
    export_method: "app-store",
    clean: false,  # Flutter already built, no need to clean
    include_bitcode: false
  )
end

Флаг --no-codesign в шаге сборки Flutter является преднамеренным — мы позволяем gym обрабатывать подписание с помощью профилей, управляемых match, вместо того чтобы полагаться на автоматическое подписание Xcode.

Загрузка в TestFlight с помощью Pilot

Pilot обрабатывает загрузки в TestFlight и управление бета-тестировщиками.

lane :beta do
  ci_setup
  build_flutter

  pilot(
    skip_waiting_for_build_processing: true,
    apple_id: "1234567890",
    distribute_external: false,
    notify_external_testers: false
  )
end

Флаг skip_waiting_for_build_processing очень важен для CI. Без него Fastlane будет опрашивать App Store Connect до тех пор, пока Apple не завершит обработку вашей сборки — что может занять от 15 до 45 минут. Ваш CI-раннер будет простаивать все это время, сжигая вычислительные кредиты. Лучше загрузить и позволить обработке происходить асинхронно.

Управление тестировщиками TestFlight

Вы также можете автоматизировать управление группами тестировщиков:

lane :distribute_to_testers do
  pilot(
    distribute_external: true,
    groups: ["External Beta Testers"],
    changelog: "Bug fixes and performance improvements",
    notify_external_testers: true
  )
end

Для внешних тестировщиков помните, что Apple требует Beta App Review для первой сборки, отправленной внешним группам, и всякий раз, когда вы добавляете новые внешние группы тестирования. Это ручной этап, который вы не можете автоматизировать — планируйте задержку в 24-48 часов при первых внешних распространениях.

Публикация в App Store с помощью Deliver

Как только ваше приложение готово к производству, deliver обрабатывает публикацию в App Store.

lane :release do
  ci_setup
  build_flutter

  deliver(
    submit_for_review: true,
    automatic_release: false,
    force: true,  # Skip HTML preview verification
    submission_information: {
      add_id_info_uses_idfa: false
    },
    precheck_include_in_app_purchases: false
  )
end

Deliver также может управлять метаданными вашего App Store — скриншотами, описаниями, ключевыми словами, примечаниями к выпуску — все это хранится в виде файлов в вашем репозитории.

fastlane/metadata/en-US/
├── description.txt
├── keywords.txt
├── name.txt
├── release_notes.txt
├── subtitle.txt
└── privacy_url.txt

fastlane/screenshots/en-US/
├── iPhone 6.5" Display/
│   ├── 01_home.png
│   ├── 02_detail.png
│   └── 03_settings.png
└── iPad Pro 12.9" Display/
    ├── 01_home.png
    └── 02_detail.png

Это одна из самых недооцененных функций Fastlane. Хранение информации о вашем приложении в App Store в системе контроля версий означает, что вы можете просматривать изменения метаданных в запросах на слияние, отслеживать, когда были обновлены описания, и откатывать изменения, если копирайтер допустил ошибку.

Стратегии увеличения версии

Управление версиями на удивление спорно. Подход, на котором я остановился, использует комбинацию семантического версионирования для маркетинговой версии и автоматически увеличивающегося номера сборки.

lane :bump_patch do
  increment_version_number(bump_type: "patch")
  increment_build_number
  commit_version_bump(message: "Bump version to #{get_version_number} (#{get_build_number})")
end

lane :bump_minor do
  increment_version_number(bump_type: "minor")
  increment_build_number(build_number: 1)
  commit_version_bump(message: "Bump version to #{get_version_number} (#{get_build_number})")
end

Для номера сборки я рекомендую использовать номер сборки CI или временную метку, а не простое увеличивающееся целое число. Это позволяет избежать конфликтов при одновременной сборке нескольких ветвей.

lane :set_ci_build_number do
  build_number = ENV["GITHUB_RUN_NUMBER"] || Time.now.strftime("%Y%m%d%H%M")
  increment_build_number(build_number: build_number)
end

Для проектов Flutter вам также необходимо синхронизировать pubspec.yaml с проектом Xcode:

lane :sync_flutter_version do
  version = get_version_number
  build = get_build_number
  Dir.chdir("..") do
    sh("sed -i '' 's/^version: .*/version: #{version}+#{build}/' pubspec.yaml")
  end
end

Полный Fastfile

Вот производственный Fastfile, который объединяет все:

default_platform(:ios)

platform :ios do
  before_all do
    setup_ci if ENV["CI"]
  end

  desc "Setup CI environment"
  lane :ci_setup do
    create_keychain(
      name: "ci_keychain",
      password: ENV["KEYCHAIN_PASSWORD"],
      default_keychain: true,
      unlock: true,
      timeout: 3600,
      lock_when_sleeps: false
    )

    match(
      type: "appstore",
      keychain_name: "ci_keychain",
      keychain_password: ENV["KEYCHAIN_PASSWORD"],
      readonly: true
    )
  end

  desc "Build the Flutter iOS app"
  lane :build_flutter do
    Dir.chdir("..") do
      sh("flutter build ios --release --no-codesign")
    end

    gym(
      scheme: "Runner",
      workspace: "Runner.xcworkspace",
      export_method: "app-store",
      output_directory: "./build",
      clean: false,
      include_bitcode: false
    )
  end

  desc "Deploy to TestFlight"
  lane :beta do
    ci_setup if ENV["CI"]
    set_ci_build_number
    build_flutter

    pilot(
      skip_waiting_for_build_processing: true,
      distribute_external: false
    )

    slack(
      message: "New TestFlight build uploaded!",
      slack_url: ENV["SLACK_WEBHOOK"]
    ) if ENV["SLACK_WEBHOOK"]
  end

  desc "Deploy to App Store"
  lane :release do
    ci_setup if ENV["CI"]
    set_ci_build_number
    build_flutter

    deliver(
      submit_for_review: true,
      automatic_release: false,
      force: true,
      precheck_include_in_app_purchases: false
    )
  end

  desc "Set build number from CI"
  private_lane :set_ci_build_number do
    build_number = ENV["GITHUB_RUN_NUMBER"] || Time.now.strftime("%Y%m%d%H%M")
    increment_build_number(build_number: build_number)
  end
end

Интеграция с GitHub Actions

Рабочий процесс CI связывает все воедино. Вот полный рабочий процесс GitHub Actions для развертывания TestFlight:

name: Deploy to TestFlight

on:
  push:
    branches: [main]
  workflow_dispatch:

jobs:
  deploy:
    runs-on: macos-14
    timeout-minutes: 45

    steps:
      - uses: actions/checkout@v4

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.27.x'
          channel: stable
          cache: true

      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2'
          bundler-cache: true
          working-directory: ios

      - name: Flutter dependencies
        run: flutter pub get

      - name: Deploy to TestFlight
        working-directory: ios
        env:
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.ASC_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY: ${{ secrets.ASC_API_KEY }}
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_AUTH }}
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
        run: bundle exec fastlane beta

Ключи API App Store Connect

Обратите внимание, что я использую ключи API, а не учетные данные Apple ID. Это важно для CI — аутентификация Apple ID требует двухфакторной аутентификации, которая не работает в неинтерактивных средах. Ключи API создаются в App Store Connect в разделе «Пользователи и доступ» и никогда не истекают, если вы их не отзовете.

# In your Appfile or Fastfile
app_store_connect_api_key(
  key_id: ENV["APP_STORE_CONNECT_API_KEY_ID"],
  issuer_id: ENV["APP_STORE_CONNECT_API_ISSUER_ID"],
  key_content: ENV["APP_STORE_CONNECT_API_KEY"],
  is_key_content_base64: true,
  in_house: false
)

Сохраните содержимое файла ключа .p8 как секрет, закодированный в base64, в GitHub Actions. Это позволяет избежать проблем с путями к файлам и сохраняет ключ вне вашего репозитория.

Распространенные ошибки и способы их исправления

Несоответствия профилей подготовки

Наиболее распространенная ошибка сборки — это несоответствие между профилем подготовки и правами в вашем приложении. Симптомы включают ошибки типа "no provisioning profile matching" или "code signing entitlements not valid".

Решение почти всегда заключается в повторной генерации ваших match-профилей:

fastlane match nuke appstore
fastlane match appstore

Затем убедитесь, что идентификатор пакета вашего проекта Xcode точно соответствует тому, что указано в ваших Matchfile и Appfile.

Проблемы с правами

Если ваше приложение использует push-уведомления, связанные домены, вход с Apple или другие возможности, они должны быть настроены как на портале Apple Developer, так и в вашем файле Runner.entitlements. Несоответствие между ними приводит к сбоям подписания, которые выдают вводящие в заблуждение сообщения об ошибках.

<!-- ios/Runner/Runner.entitlements -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>aps-environment</key>
    <string>production</string>
    <key>com.apple.developer.associated-domains</key>
    <array>
        <string>applinks:yourdomain.com</string>
    </array>
</dict>
</plist>

Отклонение из-за номера сборки

App Store Connect отклоняет загрузки, если номер сборки не строго больше предыдущей загрузки для той же версии. Если вы используете номера сборок CI, это обычно не проблема. Но если вы сбрасываете CI или меняете поставщика CI, вы можете столкнуться с этим. Решение состоит в использовании app_store_build_number для получения последнего номера сборки и его увеличения оттуда:

lane :smart_build_number do
  current = app_store_build_number(live: false)
  increment_build_number(build_number: current + 1)
end

Несоответствия версий Xcode

Apple регулярно прекращает поддержку сборок, созданных с использованием старых версий Xcode. Ваш CI-раннер должен соответствовать версии Xcode, с которой вы разрабатываете. В GitHub Actions вы можете указать это:

- name: Select Xcode version
  run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer

Проверьте документацию Apple на предмет минимальной версии Xcode, необходимой для публикации в App Store — это меняется с каждым крупным выпуском iOS.

Конфигурации для конкретных сред

Большинству приложений требуются разные конфигурации для разных сред — разные конечные точки API, разные проекты Firebase, разные флаги функций. Обработайте это в вашем Fastfile:

lane :beta_staging do
  Dir.chdir("..") do
    sh("flutter build ios --release --no-codesign --dart-define=ENV=staging")
  end
  # ... rest of build and upload
end

lane :beta_production do
  Dir.chdir("..") do
    sh("flutter build ios --release --no-codesign --dart-define=ENV=production")
  end
  # ... rest of build and upload
end

Для Firebase используйте разные файлы GoogleService-Info.plist для каждой среды и копируйте нужный во время сборки:

lane :configure_firebase do |options|
  env = options[:env] || "production"
  sh("cp ../firebase/#{env}/GoogleService-Info.plist ../Runner/GoogleService-Info.plist")
end

Мониторинг состояния сборки

Как только ваш конвейер запущен, вы хотите иметь представление о его состоянии. Я добавляю несколько вещей в каждый конвейер развертывания:

Отслеживание времени сборки — если ваши сборки начинают занимать больше времени, вы хотите знать об этом до того, как они достигнут лимита времени ожидания.

Уведомления Slack — уведомления об успехе и неудаче информируют команду, и никому не нужно следить за панелью управления CI.

error do |lane, exception|
  slack(
    message: "Build failed: #{exception.message}",
    success: false,
    slack_url: ENV["SLACK_WEBHOOK"]
  )
end

Сохранение артефактов — сохраняйте файлы dSYM для символизации сбоев. Загружайте их в свою службу отчетов о сбоях автоматически:

lane :upload_symbols do
  upload_symbols_to_crashlytics(
    dsym_path: "./build/YourApp.app.dSYM.zip",
    gsp_path: "./Runner/GoogleService-Info.plist"
  )
end

Возврат инвестиций

Настройка всего этого конвейера занимает около дня для того, кто делал это раньше, и два-три дня для новичка — большая часть времени тратится на борьбу с проблемами подписания кода, а не на написание конфигурации Fastlane. После этих первоначальных инвестиций каждый последующий выпуск превращается из 30-60-минутного ручного процесса в git push.

Настоящая ценность заключается даже не в экономии времени. Это уверенность. Когда развертывание — это одна команда или автоматический триггер, вы развертываете чаще. Меньшие развертывания означают меньший риск на каждый выпуск, более быструю обратную связь от тестировщиков и более быструю итерацию функций. Конвейер окупается не сэкономленными часами, а принципиально улучшенным рабочим процессом разработки.

DU

Danil Ulmashev

Full Stack Developer

Хотите работать вместе?