Dá pra automatizar 100% de um release de iOS com Expo EAS (build, testes, upload pro TestFlight) e ainda deixar uma porta aberta pra versão nunca chegar a ninguém. Não por bug. Por um clique manual que basta esquecer uma vez.
Era exatamente isso que rolava no pipeline do Dojicheck, nosso app de diagnóstico mobile que guia o usuário por uma sequência de testes funcionais do dispositivo: biometria, sensor de proximidade, conector de carga, tela touch, saúde da bateria, peças não originais, Wi-Fi, Bluetooth, vibração. Um app que muda rápido, cada modelo novo de device é motivo pra release. Foi por isso que esse gap incomodou rápido.
Pra resolver, perguntamos até pro time que constrói o EAS se dava pra automatizar esse último passo. A resposta deles confirmou exatamente o que a gente temia, e é o fio que conecta o resto desse artigo.
A dor
Todo merge em main já disparava um build de produção pro iOS. O EAS compilava o IPA, subia pro App Store Connect, o build aparecia processado no TestFlight, e parava aí. O último passo, o que de fato coloca a versão na fila de revisão da Apple, continuava vivendo só na cabeça de alguém: entrar no App Store Connect e clicar em submit for review, à mão, release após release.
Não é um passo difícil. É um passo esquecível, e num pipeline que já automatizou tudo antes dele, é o tipo de esquecimento que não dá erro nenhum. A versão simplesmente fica parada, pronta, processada, sem nunca chegar em ninguém, até que alguém note por acaso que "faz tempo que não sai release".
A investigação
Antes de escrever qualquer automação, checamos se o problema já tinha solução oficial. Lemos com calma:
- Submit to the Apple App Store with EAS Submit: confirma que o build fica disponível no TestFlight após o processamento, mas que liberar em produção exige logar no App Store Connect e submeter manualmente pra App Review.
- Automate submissions (doc do
--auto-submit): confirma que ele só encadeiaeas build→eas submit, upload automático pro TestFlight, sem acionar a revisão da App Store. - Pre-packaged jobs do EAS Workflows: o job
testflighttem um parâmetrosubmit_beta_review, mas é a Beta App Review da Apple, a aprovação leve só pra liberar grupos de testadores externos no TestFlight. Diferente da revisão completa de publicação na App Store. Nenhum job da lista cobre isso. - Um artigo da comunidade discutindo exatamente o mesmo gap, sinal de que não era só a nossa leitura da documentação.
Documentação pode ficar desatualizada, e uma plataforma pode mudar de plano sem aviso. Então, em vez de construir uma automação inteira em cima da nossa própria leitura, fomos confirmar direto com quem constrói a ferramenta: perguntamos no canal da comunidade do Expo se tínhamos entendido certo, e se havia algum caminho recomendado pra automatizar a submissão pra revisão. A resposta veio do próprio time:
kadi, Expo team: "You are correct, EAS uploads to TestFlight, but you'll need to open ASC and submit for review yourself. For the first time submission especially, it's not just a case of 'Submit this build to App Store', there's a lot of information you need to fill in: app store screenshots, tags, descriptions, app name, a bunch of legal declarations […]. This is all to say: that part is very tricky to automate, especially as ASC often change requirements and APIs. Saying that — yes you can use Fastlane to build manual flows for this."
Respondemos confirmando exatamente onde pretendíamos traçar a linha:
"We're keeping EAS responsible for the build and upload to TestFlight, since it works very well. Fastlane would only be used to submit the existing build to App Store Review."
Isso é a diferença entre construir em cima de uma suposição bem lida e construir em cima de uma certeza confirmada por quem faz a ferramenta. E foi o que nos deu confiança pra usar o EAS onde ele já é bom, em vez de reinventar algo só porque estávamos resolvendo outra coisa por perto.
O pipeline
Com o gap confirmado, o EAS continuou dono de tudo o que ele já faz bem: compilar e subir o binário. A peça que faltava veio de fora: uma lane do fastlane que promove um build que já está no ar pra revisão, sem tocar em nada que já foi enviado antes.
# fastlane/Fastfile
lane :submit_for_review do |options|
version = options[:version] || ENV["VERSION_STRING"]
build_number = options[:build_number]
app_store_connect_api_key(
key_id: ENV.fetch("ASC_KEY_ID"),
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
key_filepath: ENV.fetch("ASC_KEY_FILEPATH"),
)
wait_for_build_to_finish_processing(version: version, build_number: build_number) if build_number
upload_to_app_store(
app_version: version,
build_number: build_number,
skip_binary_upload: true,
skip_screenshots: true,
skip_metadata: true,
submit_for_review: true,
automatic_release: true,
reject_if_possible: true,
)
end
private_lane :wait_for_build_to_finish_processing do |options|
app_id = Spaceship::ConnectAPI::App.find(
CredentialsManager::AppfileConfig.try_fetch_value(:app_identifier)
).id
FastlaneCore::BuildWatcher.wait_for_build_processing_to_be_complete(
app_id: app_id,
platform: "ios",
app_version: options[:version],
build_version: options[:build_number],
poll_interval: 30,
timeout_duration: 45 * 60,
)
end
Três decisões nesse código pedem contexto pra fazer sentido. A primeira é o build_number fixo: doji-stg e doji-prd compartilham o mesmo ascAppId na App Store Connect, não são apps diferentes pra Apple, só ambientes diferentes do mesmo app, e sem fixar esse número o upload_to_app_store pegaria "o build mais recente pra essa versão", que podia muito bem ser um binário de staging.
Essa fixação, porém, tem um efeito colateral que só descobrimos lendo o próprio source do fastlane (Deliver::SubmitForReview#select_build): com build_number fixo, a consulta que localiza o build na App Store Connect é única e imediata, sem polling, e falha na hora com Build number: X does not exist se a Apple ainda estiver processando o binário — processamento assíncrono, de 5 a 30 minutos segundo a documentação do Expo.
O polling de verdade só existe no caminho sem build_number fixo, que a gente não pode usar. É pra isso que serve a lane wait_for_build_to_finish_processing: ela chama FastlaneCore::BuildWatcher diretamente, a mesma engine que pilot e deliver usam por baixo dos panos, escopada pro build exato que subimos, e só libera o fluxo quando a Apple realmente terminou.
A terceira decisão é mais simples: reject_if_possible: true existe porque a Apple só permite uma versão "Waiting for Review" por vez, por plataforma, e sem essa flag submeter uma versão nova enquanto a anterior ainda está na fila simplesmente falha, em vez de substituir a antiga pela nova.
Em volta disso, um workflow novo, o auto-version-bump.yml, fechou o ciclo por completo: a cada merge em main, ele bumpa a versão em app.json, comita de volta e dispara a release de produção sozinho. Ninguém mais precisa lembrar de nada, do commit até a fila de revisão da Apple.
Cinco jobs, cada um com uma única responsabilidade
O pipeline de produção do iOS ficou assim:
resolve-version → quality-gate → build → submit → submit-for-review
│ │
eas submit --wait fastlane
(sobe pro ASC, submit_for_review
cai no TestFlight) (pede a revisão)
resolve-version: só lê a versão doapp.json, fonte única de verdade tanto pro build quanto pro disparo manual viaworkflow_dispatch.quality-gate: lint, typecheck e testes correm antes de qualquer minuto de EAS ser consumido.build: só compila o IPA (eas build --wait, sem auto-submit). Termina e libera o runner assim que o binário está pronto — não fala com a Apple em nenhum momento.submit: recebe obuild_iddo job anterior e sobe o binário pro App Store Connect (eas submit --wait) — é aqui que ele aparece processado no TestFlight.submit-for-review: só depois dosubmitterminar, chama a lane do fastlane pra pedir a revisão de verdade.
Isso não era o desenho original, e não chegamos aqui de uma vez. No começo, era tudo um job só: compilar, subir e submeter pra revisão em sequência, no mesmo runner. Separamos compilar do resto primeiro — motivo óbvio, recompilar do zero (~15–20min) só pra tentar de novo uma submissão que falhou é desperdício puro.
Só que essa primeira separação não foi suficiente, e a gente só descobriu isso rodando em produção de verdade, não por suposição. Uma release passou pelo submit com sucesso — o build apareceu processado no TestFlight — e falhou logo depois, na etapa do fastlane, com Build number: X does not exist. É exatamente o efeito colateral do build_number fixo que descrevemos acima: a Apple ainda estava processando o binário, e a consulta do fastlane não esperou.
Alguém então re-rodou o job que tinha falhado — nesse ponto ainda um job único fazendo eas submit e a lane do fastlane juntos. Isso re-executou o eas submit também, subindo o mesmo binário de novo.
A Apple respondeu com um erro diferente e pior: Build number X ... has already been used — porque da primeira vez ele já tinha subido com sucesso. Foi esse incidente, não uma hipótese, que empurrou a separação até o fim: submit e submit-for-review viraram dois jobs, e a lane wait_for_build_to_finish_processing entrou pra esperar o processamento terminar antes de tentar submeter pra revisão.
Separar em jobs resolve o resto porque cada job do GitHub Actions roda num runner isolado, sem sistema de arquivos compartilhado entre eles. A única ponte é o que um job explicitamente expõe como output, e o próximo lê via needs.<job>.outputs.*. No nosso caso, é literalmente um build_id passando de build pra submit, e um submit_status de submit pra submit-for-review. Com essa fronteira, "Re-run failed jobs" no GitHub Actions passa a rerodar só o que falhou e o que depende dele — sem repetir uma etapa que já tinha dado certo.
A separação também protegeu o que já funcionava: em vez de reescrever o script existente (eas-build.sh, compartilhado pelos outros três workflows de dev/staging/produção), criamos um script fino novo, eas-submit.sh, só pra esse passo isolado, no mesmo estilo do original, mas sem risco de regressão nos outros pipelines que já dependiam dele.
Antes de fechar. Chegamos a considerar ir além: trocar essa espera por um workflow agendado (polling em intervalos curtos) ou por um webhook, pra reduzir minutos de CI ociosos. Pesquisamos como projetos maduros resolvem isso na prática antes de decidir, e o próprio fastlane espera de forma síncrona por padrão. Guias reais de EAS + GitHub Actions tratam esse tempo como custo aceito, não como algo a otimizar. Bloquear um job foi a escolha certa, não a incompleta.
Isso não é peculiaridade da Doji
Vale ser bem explícito aqui: esse buraco não é algo que só o Dojicheck tinha. É o que todo time que builda em produção com EAS vai encontrar. A gente só confirmou isso na prática, perguntando pra quem constrói a ferramenta. "EAS uploads to TestFlight, but you'll need to open ASC and submit for review yourself" não é uma limitação do nosso setup. É uma limitação do produto, reconhecida pelo próprio time do Expo.
O que engana é justamente o --auto-submit do eas build. O nome sugere que o release termina sozinho, e ele de fato sobe o binário sozinho. Só que "subir pro TestFlight" e "submeter pra revisão da App Store" são coisas diferentes, e só a primeira está automatizada. Todo time que confia nesse nome sem checar acaba, mais cedo ou mais tarde, olhando pro App Store Connect e vendo uma versão pronta, processada, e paralisada, exatamente como a gente descreveu na abertura desse artigo. A diferença entre times não é se vão encontrar esse buraco, é quando.
Por que isso importa em React Native
Web deploya em segundos. Mobile não, e não tem como forçar. Entre um merge e o app chegando no usuário existem etapas que nenhuma automação sua controla: o processamento do binário na Apple, a fila de revisão, a distribuição pra loja. Esse trecho final é lento por definição, e é exatamente por isso que ele não é onde vale investir esforço de automação. É em tudo o que vem antes dele.
Cada passo manual que sobrevive num pipeline mobile custa desproporcionalmente mais do que o equivalente num pipeline web, porque tentar de novo significa passar pela mesma espera de revisão outra vez. Bump de versão e build number esquecido, por exemplo, é motivo clássico de rejeição ("the bundle version must be higher") e é totalmente evitável automatizando o bump, como o auto-version-bump.yml faz aqui.
E um pipeline desenhado em etapas isoladas, como as cinco que descrevemos acima, escala com o time: mais gente consegue lançar release sem que ninguém precise carregar na cabeça "lembra de clicar em tal botão depois que o build terminar". Foi exatamente isso que resolvemos aqui: não um problema de EAS, nem de fastlane, mas o único passo que ainda dependia de alguém lembrar. E nada do que descrevemos (a lane do fastlane, a separação de jobs, o build_id passando entre eles) depende de nada específico do Dojicheck. É copiável pro eas.json de qualquer projeto Expo que já tenha chegado em produção.
O que ainda não é automático
A próprio Kadi apontou o limite real disso: a primeira submissão de um app carrega uma pilha de informação (screenshots, descrição, categoria, declarações legais, credenciais de demonstração pro revisor) e uma rejeição exige alguém entrando no App Store Connect pra responder ou corrigir. Nada disso é seguro de automatizar de olhos fechados.
No nosso caso, o app já passou pela primeira aprovação, então isso só importa daqui pra frente se uma versão for rejeitada. Em vez de tentar adivinhar o estado da revisão via API, a Apple já manda um e-mail quando o status muda. O plano é simplesmente encaminhar esse e-mail pro Slack do time. Alguém do nosso time é avisado rápido, sem depender de alguém lembrar de checar o App Store Connect por conta própria.
O que fica disso
A parte fácil de contar dessa história é a automação em si: o fastlane, os jobs separados, o workflow de versão. A parte que vale mais é o processo que levou até ela. Verificar antes de automatizar. Perguntar pra quem constrói a ferramenta em vez de confiar só na própria leitura da documentação. E decidir onde parar: qual pedaço automatizar até o fim, qual pedaço deixar bloqueando um job de propósito, e qual pedaço (primeira submissão, rejeição) segue exigindo para o time ficar olhando.
Se você está montando algo parecido pro seu app React Native/Expo, o que vale levar daqui não é o YAML exato. É essa forma de decidir. E se seu time já builda em produção com EAS, vale checar uma coisa agora, antes de precisar descobrir do jeito difícil: alguém sabe, hoje, quem é a pessoa que clica em "submit for review" a cada release? Se a resposta não for automática, o buraco já está aí. Só ainda não apareceu.
Participe da conversa
Assine a newsletter para comentar neste artigo.