Automatizando Deploys iOS: App Store Connect e TestFlight
Um guia completo para automatizar deploys de apps iOS — desde a configuração do Fastlane até a distribuição pelo TestFlight e submissão na App Store.

Todo desenvolvedor iOS já viveu esse ritual: abrir o Xcode, incrementar o build number, selecionar o scheme de archive, esperar quinze minutos, clicar pelo Organizer, fazer upload para o App Store Connect, esperar mais um pouco, e então adicionar testadores manualmente no TestFlight. Faça isso duas vezes por semana em múltiplos apps e você começa a perder horas que deveriam ser gastas escrevendo código. A pior parte não é o tempo — é a inconsistência. Um passo esquecido, um provisioning profile errado, e o build falha silenciosamente ou é rejeitado pelos checks automatizados da Apple horas depois.
Eu automatizei todo esse pipeline para um app iOS baseado em Flutter e desde então apliquei os mesmos padrões a outros projetos. O investimento se paga após o terceiro ou quarto ciclo de release.
Por Que Deploys Manuais Falham
O processo manual tem três problemas fundamentais além da óbvia perda de tempo.
Primeiro, concentração de conhecimento. Quando apenas uma pessoa no time sabe como fazer o deploy, ela se torna um gargalo. Férias, dias de doença, ou simplesmente estar em um fuso horário diferente significa que releases ficam parados. Documentação ajuda, mas um guia de deploy de 15 passos é em si uma responsabilidade — ele fica desatualizado no momento em que alguém muda uma configuração de build.
Segundo, erro humano no pior momento. Releases tendem a acontecer sob pressão. Uma correção crítica de bug, um prazo de um cliente, uma feature que precisa ser lançada antes de um concorrente. Essas são exatamente as condições em que alguém esquece de incrementar o build number, seleciona o scheme errado, ou faz upload de um build de debug em vez de release.
Terceiro, falta de auditabilidade. Quando algo dá errado com um release, você quer saber exatamente o que foi compilado, de qual commit, com qual configuração. Processos manuais não deixam um rastro confiável.
Fastlane: A Base
Fastlane é o padrão de facto para automação de deploy iOS, e com razão. Ele lida com code signing, build, upload e gerenciamento de metadados através de uma DSL baseada em Ruby que se lê como um checklist de deploy.
Configuração Inicial
Configurar o Fastlane em um projeto existente é simples.
# Install Fastlane
brew install fastlane
# Initialize in your project directory (for Flutter, run in ios/)
cd ios
fastlane init
Durante a inicialização, o Fastlane pergunta o que você quer automatizar. Eu recomendo escolher a configuração manual e configurar tudo explicitamente — a configuração automática faz suposições que nem sempre correspondem à estrutura do seu projeto, especialmente para apps Flutter.
O resultado é um diretório fastlane/ contendo um Appfile e um Fastfile.
# fastlane/Appfile
app_identifier("com.yourcompany.yourapp")
apple_id("your@email.com")
itc_team_id("123456789")
team_id("ABCD1234EF")
O Appfile armazena os detalhes da sua conta Apple Developer. Os team IDs são importantes se seu Apple ID pertence a múltiplos times — sem eles, o Fastlane vai solicitar interativamente, o que quebra o CI.
Code Signing com Match
Code signing é onde a maioria dos esforços de automação iOS morrem. Provisioning profiles, certificados, entitlements — todo o sistema parece projetado para punir a automação. A ferramenta match do Fastlane resolve isso armazenando seus certificados e profiles em um repositório Git privado ou armazenamento na nuvem, tornando-os acessíveis a qualquer máquina que precise compilar.
Configurando o Match
fastlane match init
Isso cria um Matchfile onde você configura seu backend de armazenamento.
# fastlane/Matchfile
git_url("https://github.com/your-org/ios-certificates.git")
storage_mode("git")
type("appstore")
app_identifier(["com.yourcompany.yourapp"])
Depois gere e armazene seus certificados:
# Generate development certificates and profiles
fastlane match development
# Generate App Store distribution certificates and profiles
fastlane match appstore
O Match criptografa tudo antes de enviar para o repositório. Você define uma senha durante a configuração que precisará em cada máquina — incluindo o CI.
Por Que Match ao Invés de Assinatura Manual
A alternativa — gerenciar certificados manualmente pelo portal Apple Developer e distribuir arquivos .p12 — funciona até não funcionar mais. Modos comuns de falha incluem:
- O certificado de um desenvolvedor expira e ninguém percebe até que um build de release falhe.
- Alguém revoga um certificado enquanto debugava um problema de assinatura, invalidando todos os profiles que dependem dele.
- Uma nova máquina de CI precisa de provisionamento e a pessoa que configurou a máquina original saiu do time.
O Match elimina tudo isso sendo a única fonte de verdade. Se um certificado expira, você executa fastlane match nuke e regenera. Toda máquina puxa do mesmo repositório.
Code Signing no CI
No CI, você precisa lidar com o macOS Keychain já que não há GUI para desbloqueá-lo. O Fastlane fornece helpers para isso:
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
A flag readonly: true é crítica para o CI. Sem ela, o match pode tentar criar novos certificados se não encontrar os existentes, o que vai falhar sem autorização interativa e pode revogar certificados existentes.
Compilando com Gym
Gym é a ferramenta de build do Fastlane. Ele encapsula o xcodebuild com padrões sensatos e melhor saída de erros.
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
Para projetos Flutter, você tem duas opções: executar flutter build ipa e lidar com a saída, ou usar o gym diretamente no workspace Xcode que o Flutter gera. Eu prefiro o gym porque dá mais controle sobre assinatura e opções de exportação, mas ambos funcionam.
# 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
A flag --no-codesign no passo de build do Flutter é intencional — deixamos o gym lidar com a assinatura usando os profiles gerenciados pelo match em vez de depender da assinatura automática do Xcode.
Upload para o TestFlight com Pilot
Pilot lida com uploads para o TestFlight e gerenciamento de beta testers.
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
A flag skip_waiting_for_build_processing importa muito para o CI. Sem ela, o Fastlane vai fazer polling no App Store Connect até que a Apple termine de processar seu build — o que pode levar de 15 a 45 minutos. Seu runner de CI ficaria parado esse tempo todo, queimando créditos de computação. É melhor fazer o upload e deixar o processamento acontecer de forma assíncrona.
Gerenciando Testadores do TestFlight
Você também pode automatizar o gerenciamento de grupos de testadores:
lane :distribute_to_testers do
pilot(
distribute_external: true,
groups: ["External Beta Testers"],
changelog: "Bug fixes and performance improvements",
notify_external_testers: true
)
end
Para testadores externos, lembre-se de que a Apple exige Beta App Review para o primeiro build enviado a grupos externos e sempre que você adicionar novos grupos de testes externos. Esta é uma porta manual que você não pode automatizar — planeje um atraso de 24-48 horas nas primeiras distribuições externas.
Submissão para a App Store com Deliver
Quando seu app está pronto para produção, o deliver lida com a submissão para a 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
O Deliver também pode gerenciar os metadados da sua App Store — screenshots, descrições, palavras-chave, notas de release — tudo armazenado como arquivos no seu repositório.
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
Este é um dos recursos mais subestimados do Fastlane. Ter a listagem da sua App Store em controle de versão significa que você pode revisar mudanças de metadados em pull requests, rastrear quando descrições foram atualizadas e reverter se um redator cometer um erro.
Estratégias de Versionamento
O gerenciamento de versão é surpreendentemente controverso. A abordagem que adotei usa uma combinação de versionamento semântico para a versão de marketing e um build number auto-incrementado.
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
Para o build number especificamente, eu recomendo usar o número de build do CI ou um timestamp em vez de um simples inteiro incrementado. Isso evita conflitos quando múltiplos branches estão sendo compilados simultaneamente.
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
Para projetos Flutter, você também precisa manter o pubspec.yaml sincronizado com o projeto 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
O Fastfile Completo
Aqui está um Fastfile de produção que combina tudo:
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
Integração com GitHub Actions
O workflow de CI une tudo. Aqui está um workflow completo do GitHub Actions para deploy no 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
Chaves da API do App Store Connect
Perceba que estou usando chaves de API em vez de credenciais do Apple ID. Isso é importante para o CI — a autenticação por Apple ID requer autenticação de dois fatores, que não funciona em ambientes não interativos. As chaves de API são criadas no App Store Connect em Usuários e Acesso, e elas nunca expiram a menos que você as revogue.
# 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
)
Armazene o conteúdo do arquivo de chave .p8 como um secret codificado em base64 no GitHub Actions. Isso evita problemas de caminho de arquivo e mantém a chave fora do seu repositório.
Problemas Comuns e Como Resolvê-los
Incompatibilidade de Provisioning Profile
A falha de build mais comum é uma incompatibilidade entre o provisioning profile e os entitlements no seu app. Os sintomas incluem erros como "no provisioning profile matching" ou "code signing entitlements not valid."
A correção é quase sempre regenerar seus profiles do match:
fastlane match nuke appstore
fastlane match appstore
Depois verifique que o bundle identifier do seu projeto Xcode corresponde exatamente ao que está no seu Matchfile e Appfile.
Problemas com Entitlements
Se seu app usa push notifications, associated domains, Sign in with Apple, ou outras capacidades, estas devem ser configuradas tanto no portal Apple Developer quanto no arquivo Runner.entitlements. Uma incompatibilidade entre os dois causa falhas de assinatura que produzem mensagens de erro enganosas.
<!-- 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>
Rejeição de Build Number
O App Store Connect rejeita uploads onde o build number não é estritamente maior que o upload anterior para a mesma versão. Se você usa build numbers do CI, isso geralmente não é um problema. Mas se você resetar seu CI ou trocar de provedor de CI, pode enfrentar isso. A correção é usar app_store_build_number para buscar o último build number e incrementar a partir dele:
lane :smart_build_number do
current = app_store_build_number(live: false)
increment_build_number(build_number: current + 1)
end
Incompatibilidade de Versão do Xcode
A Apple regularmente descontinua suporte para builds feitos com versões mais antigas do Xcode. Seu runner de CI precisa corresponder à versão do Xcode que você usa para desenvolver. No GitHub Actions, você pode especificar isso:
- name: Select Xcode version
run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer
Consulte a documentação da Apple para a versão mínima do Xcode necessária para submissões na App Store — isso muda com cada release major do iOS.
Configurações Específicas por Ambiente
A maioria dos apps precisa de configurações diferentes para diferentes ambientes — endpoints de API diferentes, projetos Firebase diferentes, feature flags diferentes. Lide com isso no seu 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
Para Firebase, use arquivos GoogleService-Info.plist diferentes por ambiente e copie o correto durante o build:
lane :configure_firebase do |options|
env = options[:env] || "production"
sh("cp ../firebase/#{env}/GoogleService-Info.plist ../Runner/GoogleService-Info.plist")
end
Monitorando a Saúde do Build
Uma vez que seu pipeline está rodando, você quer visibilidade sobre sua saúde. Eu adiciono algumas coisas a cada pipeline de deploy:
Rastreamento de tempo de build — se seus builds começarem a levar mais tempo, você quer saber antes de atingirem o limite de timeout.
Notificações no Slack — notificações de sucesso e falha mantêm o time informado sem que ninguém precise ficar assistindo o dashboard do CI.
error do |lane, exception|
slack(
message: "Build failed: #{exception.message}",
success: false,
slack_url: ENV["SLACK_WEBHOOK"]
)
end
Retenção de artefatos — mantenha arquivos dSYM para simbolização de crashes. Faça upload deles para seu serviço de relatório de crashes automaticamente:
lane :upload_symbols do
upload_symbols_to_crashlytics(
dsym_path: "./build/YourApp.app.dSYM.zip",
gsp_path: "./Runner/GoogleService-Info.plist"
)
end
O Retorno sobre o Investimento
Configurar todo esse pipeline leva cerca de um dia para alguém que já fez isso antes, e dois a três dias para quem está fazendo pela primeira vez — a maior parte do tempo é gasta lutando com problemas de code signing, não escrevendo configuração do Fastlane. Após esse investimento inicial, cada release subsequente vai de um processo manual de 30-60 minutos para um git push.
O valor real nem é a economia de tempo. É a confiança. Quando fazer deploy é um único comando ou um trigger automático, você faz deploy com mais frequência. Deploys menores significam menos risco por release, feedback mais rápido dos testadores e iteração mais ágil nas features. O pipeline se paga não em horas economizadas, mas em um fluxo de trabalho de desenvolvimento fundamentalmente melhor.