Gerar build de produção na mão tem um charme: você roda um flutter build, espera, abre o Transporter, arrasta o IPA, sobe o AAB no Play Console. Funciona. Até o dia em que você precisa lembrar qual keystore usou, em qual máquina estava o certificado e quem tem a senha do P12.
A ideia deste artigo é tirar tudo isso da sua máquina e colocar num pipeline do Azure DevOps que faz o seguinte:
- Você cria uma tag de release no Git.
- O pipeline compila Android (APK e AAB) num agente Linux e iOS (IPA) num agente macOS.
- Tudo sai assinado, verificado e publicado como artifact.
- O IPA é enviado direto para o App Store Connect, pronto para o TestFlight.
E o mais importante: sem nenhum segredo no repositório e sem nenhum segredo no log.
Vou passar pelo que você precisa preparar no Azure e na Apple, e depois pelo azure-pipelines.yml bloco a bloco, explicando o porquê de cada decisão. Os nomes de arquivo e de app são genéricos (meuapp), troque pelos seus.
A visão geral do fluxo
Antes do YAML, vale ver o desenho completo:
git tag app-production-v1.0.68
↓
Azure Pipelines (trigger por tag)
├── Job Android (ubuntu-24.04)
│ valida tag → instala Flutter → assina → APK + AAB → artifact
│
└── Job iOS (macOS-26)
valida tag → instala certificado e perfil → assinatura manual
→ IPA verificado → artifact → Transporter → App Store ConnectOs dois jobs são independentes. Um build Android não espera o iOS e vice-versa.
O que você precisa preparar antes
No Azure DevOps
Secure Files (Pipelines → Library → Secure files) são arquivos binários criptografados que só o pipeline autorizado consegue baixar. É ali que mora tudo que é arquivo sensível:
meuapp-production-keystore.jks: o keystore de assinatura Android.meuapp-distribution.p12: certificado de distribuição Apple com a chave privada.meuapp-appstore.mobileprovision: o provisioning profile de distribuição App Store.appstoreconnect-api-key.p8: a chave privada da API do App Store Connect.meta-strings.xml: configuração privada de SDK (no meu caso, os IDs do SDK da Meta), que não deve ficar no repositório.
Variable group (Pipelines → Library → Variable groups) com as senhas e identificadores, marcando como secretas as que são segredo:
ANDROID_KEYSTORE_PASSWORD,ANDROID_KEY_ALIAS,ANDROID_KEY_PASSWORDIOS_P12_PASSWORDAPP_STORE_CONNECT_ISSUER_IDASC_KEY_ID
Na Apple
Para o IPA subir direto, você precisa de quatro peças que combinem entre si:
- Certificado Apple Distribution (P12): exporte do Keychain o certificado junto com a chave privada. Sem a chave privada, o certificado não assina nada.
- Provisioning profile do tipo App Store Connect: crie no portal Apple Developer um perfil de distribuição para o App ID do app, vinculado a esse certificado. Perfil de desenvolvimento, Ad Hoc ou Enterprise não serve.
- Team API Key do App Store Connect: em Users and Access → Integrations, gere uma chave de equipe. Você recebe o arquivo
.p8(que a Apple deixa baixar uma vez só), o Key ID e o Issuer ID. Para enviar build, a função Developer é suficiente. Não use Admin para isso. - O app já criado no App Store Connect com o mesmo Bundle ID, para o upload ter onde cair.
Certificado (P12 + chave privada)
↓ precisa estar dentro do
Provisioning profile (App Store Connect)
↓ que aponta para o
App ID / Bundle ID
↓ que precisa casar com a
Assinatura manual do Xcode no buildNo repositório
Dois arquivos pequenos que deixam o build reprodutível:
pubspec.yamlcom a versão no formatoX.Y.Z+BUILD, por exemplo1.0.68+68..tool-versionsfixando a versão do Flutter, por exemploflutter 3.47.5-stable.
E uma pasta scripts/ci/ com os scripts de apoio (Python e Ruby, só biblioteca padrão e uma gem). Eles aparecem ao longo do YAML.
O YAML, bloco a bloco
Trigger, parâmetros e pool
trigger:
tags:
include:
- android-production-v*
- ios-production-v*
- app-production-v*
parameters:
- name: buildAndroid
displayName: Build Android APK and AAB
type: boolean
default: true
- name: buildIOS
displayName: Build iOS IPA
type: boolean
default: true
pr: none
pool:
vmImage: ubuntu-24.04
variables:
- group: meuapp-release
- name: flutterSdkPath
value: $(Agent.TempDirectory)/flutter-sdkO que está acontecendo aqui:
trigger.tags: o pipeline só dispara com tag. Três prefixos: só Android, só iOS ou os dois (app-production-v*). Push em branch não gera release.parameters: quando você roda manualmente pela interface, aparecem dois checkboxes para escolher a plataforma.pr: none: pull request não roda este pipeline. Isso importa para segurança: o pipeline que tem acesso a keystore e certificado não deve executar código de uma branch que ainda não foi revisada. Validação de PR fica num pipeline separado, sem segredos.pool: o padrão é Linux. O job iOS sobrescreve com macOS.variables: carrega o variable group com as senhas e define onde o Flutter vai ser instalado.
A condição que protege a produção
Cada job tem uma condição assim:
condition: and(
succeeded(),
eq(${{ parameters.buildAndroid }}, true),
or(
startsWith(variables['Build.SourceBranch'], 'refs/tags/android-production-v'),
startsWith(variables['Build.SourceBranch'], 'refs/tags/app-production-v')
)
)Em português: o job Android roda se o parâmetro estiver marcado e se a execução veio de uma tag Android ou de uma tag geral. Mesmo que alguém rode o pipeline manualmente apontando para main, o Build.SourceBranch vai ser refs/heads/main e o job de produção simplesmente não executa. Build de produção só nasce de tag.
O job Android
Checkout limpo
- checkout: self
clean: true
fetchDepth: 1
persistCredentials: falseclean: true garante workspace zerado. fetchDepth: 1 baixa só o commit da tag, sem histórico. E persistCredentials: false deixa explícito que o token de acesso ao repositório não fica gravado no .git/config depois do checkout, então nenhum script do build consegue reutilizá-lo.
Validar tag e versão
- bash: python3 scripts/ci/android_production.py validate
displayName: Validate release tag and pinned Flutter version
Esse script faz três coisas antes de gastar um minuto de build:
- lê a versão do
pubspec.yamle exige que a tag bata com ela (app-production-v1.0.68só passa se o pubspec disser1.0.68); - lê o
.tool-versionse descobre a versão do Flutter; - publica essa versão como variável do pipeline com um logging command do Azure:
print(f"##vso[task.setvariable variable=FlutterVersion]{info['flutter_version']}")A partir daí, qualquer step pode usar $(FlutterVersion). Os testes desses scripts também rodam no pipeline (python3 -m unittest), porque script de release sem teste é só esperança.
Liberar disco e fixar Java
Agente hospedado vem cheio de ferramenta que o seu build não usa. Um step remove dotnet, swift, imagens de emulador e afins, preservando Android SDK, NDK, Java e Flutter. Em seguida, JavaToolInstaller@0 fixa o Java 17.
Baixar os Secure Files
- task: DownloadSecureFile@1
name: releaseKeystore
inputs:
secureFile: meuapp-production-keystore.jksA task baixa o arquivo para uma pasta temporária do agente e expõe o caminho em $(releaseKeystore.secureFilePath). O arquivo é apagado automaticamente no fim do job.
Preparar a assinatura sem vazar senha
- bash: python3 scripts/ci/android_production.py prepare
displayName: Prepare signing and private configuration
env:
RELEASE_KEYSTORE_PATH: $(releaseKeystore.secureFilePath)
RELEASE_META_XML_PATH: $(metaResources.secureFilePath)
ANDROID_KEYSTORE_PASSWORD: $(ANDROID_KEYSTORE_PASSWORD)
ANDROID_KEY_ALIAS: $(ANDROID_KEY_ALIAS)
ANDROID_KEY_PASSWORD: $(ANDROID_KEY_PASSWORD)Aqui tem um detalhe do Azure que pega muita gente: variáveis secretas não viram variável de ambiente sozinhas. Você precisa mapear explicitamente no bloco env de cada step que vai usar. Isso é bom: só o step que precisa da senha recebe a senha.
O script então:
- confere se cada variável existe e foi expandida. Se o Azure não encontra uma variável, ele deixa o texto literal
$(NOME), e o script recusa esse valor em vez de seguir com uma senha que na verdade é a string$(ANDROID_KEY_PASSWORD); - gera o
android/key.properties(que o Gradle lê para assinar) e ostrings.xmlcom a configuração privada; - grava esses arquivos com permissão
0600e se recusa a sobrescrever arquivo existente. Se eles já existirem, o checkout não está limpo, e o build para.
Instalar Flutter na versão fixada
- bash: |
set -euo pipefail
git clone --depth 1 --branch "$FLUTTER_VERSION" \
https://github.com/flutter/flutter.git "$FLUTTER_SDK_PATH"
export PATH="$FLUTTER_SDK_PATH/bin:$PATH"
echo "##vso[task.prependpath]$FLUTTER_SDK_PATH/bin"
flutter config --no-analytics --jdk-dir "$JAVA_HOME"
flutter precache --android
env:
FLUTTER_VERSION: $(FlutterVersion)
FLUTTER_SDK_PATH: $(flutterSdkPath)
CI: 'true'O Flutter é clonado exatamente na versão do .tool-versions. O task.prependpath coloca o flutter no PATH dos próximos steps. Resultado: o build do CI usa o mesmo Flutter da sua máquina, e atualizar o SDK vira um commit revisável.
SDK Android e memória do Gradle
O step seguinte aceita as licenças e instala com o sdkmanager exatamente os componentes que o projeto usa (platform, build-tools, NDK, CMake), em vez de confiar no que veio pré-instalado no agente. Também escreve um ~/.gradle/gradle.properties com heap menor: o projeto pode pedir 8 GB pensando na máquina do desenvolvedor, mas o agente hospedado tem menos memória. Propriedades de usuário têm prioridade sobre as do projeto, então isso vale só no CI.
Dependências travadas
- bash: |
set -euo pipefail
test -s lib/src/core/persistence/env.g.dart
flutter pub get --enforce-lockfile--enforce-lockfile faz o pub get falhar se o pubspec.lock não bater com o pubspec.yaml. Nada de dependência resolvendo para uma versão diferente da que você testou. O test -s confere que o código gerado pelo Envied (que já está versionado) existe, então o CI não precisa criar .env.
Build e verificação
- bash: |
set -euo pipefail
flutter build apk --flavor=production --dart-define=LOCALE=en_US --release --no-tree-shake-icons
"$ANDROID_HOME/build-tools/36.0.0/apksigner" verify \
build/app/outputs/flutter-apk/app-production-release.apk
- bash: |
set -euo pipefail
flutter build appbundle --flavor=production --dart-define=LOCALE=en_US --release --no-tree-shake-icons
jarsigner -verify build/app/outputs/bundle/productionRelease/app-production-release.aabCompilar não basta: cada saída é verificada. O apksigner confere a assinatura do APK. Para o AAB, o jarsigner roda e, no step seguinte, o script também abre o arquivo e exige um bloco de assinatura em META-INF/, porque o jarsigner pode retornar sucesso num arquivo sem assinatura.
Coletar, publicar e limpar
O comando collect copia APK, AAB e mapping.txt para o staging, gera um release.json (versão, build number, commit, ID da execução) e um SHA256SUMS. Depois:
- task: PublishPipelineArtifact@1
inputs:
targetPath: $(Build.ArtifactStagingDirectory)/android-production
artifact: android-production-$(Build.BuildId)
- bash: rm -f android/key.properties android/app/src/main/res/values/strings.xml
condition: always()O condition: always() faz a limpeza rodar mesmo se o build falhar. Arquivo com senha não fica para trás em nenhum cenário.
O job iOS
O começo é igual: checkout limpo e validação da tag. A diferença está na assinatura.
- job: IOSProduction
pool:
vmImage: macOS-26
timeoutInMinutes: 60Certificado e perfil
- task: InstallAppleCertificate@2
name: iosCertificate
inputs:
certSecureFile: meuapp-distribution.p12
certPwd: $(IOS_P12_PASSWORD)
keychain: temp
setUpPartitionIdACLForPrivateKey: true
- task: InstallAppleProvisioningProfile@1
name: iosProfile
inputs:
provisioningProfileLocation: secureFiles
provProfileSecureFile: meuapp-appstore.mobileprovision
removeProfile: truekeychain: temp: o certificado vai para um keychain temporário criado só para este job e removido no fim. Nada fica no keychain do sistema.setUpPartitionIdACLForPrivateKey: libera ocodesignpara usar a chave privada sem pedir permissão interativa, coisa que num agente sem tela travaria o build.removeProfile: true: o perfil é removido do agente no fim.
As duas tasks têm name porque expõem variáveis de saída que vamos usar: $(iosCertificate.signingIdentity) e $(iosProfile.provisioningProfileUuid).
O perfil também é baixado de novo com DownloadSecureFile@1. Parece redundante, mas não é: a task de instalação coloca o perfil no lugar que o Xcode procura, e o download cru serve para o script inspecionar o conteúdo do perfil.
Validar o perfil antes de compilar
O ios_production.py decodifica o perfil com security cms -D e confere tudo que pode dar errado:
if profile.get('TeamIdentifier') != [TEAM_ID]:
raise ValueError('Provisioning profile must belong to the production Apple team')
# ...
if profile.get('ProvisionedDevices') or profile.get('ProvisionsAllDevices') \
or entitlements.get('get-task-allow'):
raise ValueError('Use an App Store distribution profile, not development, Ad Hoc or enterprise')Ele checa se o perfil é do time certo, do Bundle ID certo, se não expirou, se é de distribuição App Store (sem lista de devices e sem get-task-allow) e se tem os entitlements que o app usa (push em produção, Sign in with Apple, Associated Domains). Também confere se o UUID instalado é o mesmo do arquivo e se a identidade de assinatura é Apple Distribution.
Se alguma coisa não bater, o build para em segundos, com uma mensagem clara, em vez de falhar lá no archive.
Assinatura manual só no CI
No dia a dia, o Xcode da sua máquina usa assinatura automática, logado na sua conta. O agente não está logado em conta nenhuma. Por isso, um script Ruby com a gem xcodeproj muda a configuração Release-production apenas no checkout descartável do CI:
config.build_settings['CODE_SIGN_STYLE'] = 'Manual'
config.build_settings['DEVELOPMENT_TEAM'] = ENV.fetch('APPLE_TEAM_ID')
config.build_settings['CODE_SIGN_IDENTITY'] = 'Apple Distribution'
config.build_settings['PROVISIONING_PROFILE_SPECIFIER'] = ENV.fetch('IOS_PROFILE_UUID')O projeto no repositório continua com a assinatura automática que você usa localmente. O CI ajusta o que precisa e joga fora no fim.
O mesmo script Python gera o ExportOptions.plist, que diz ao Xcode como exportar o IPA:
export = dict(method='app-store-connect', destination='export',
teamID=TEAM_ID, signingStyle='manual',
provisioningProfiles={BUNDLE_ID: uuid},
signingCertificate='Apple Distribution',
manageAppVersionAndBuildNumber=False,
stripSwiftSymbols=True, uploadSymbols=True)Os pontos importantes: method='app-store-connect' (IPA para a loja), destination='export' (gera o arquivo; o envio fica para outro step) e manageAppVersionAndBuildNumber=False, porque quem manda na versão é o pubspec.yaml, não o Xcode.
CocoaPods sincronizado
cd ios
rm -rf Podfile.lock
pod install --verboseO Flutter gera o Podfile a partir dos plugins do projeto. Recriar o lockfile no checkout do CI garante que os Pods instalados correspondem exatamente aos plugins daquela versão do pubspec.lock, que é quem de fato trava as versões.
Build, verificação e artifact
flutter build ipa --flavor=production --dart-define=LOCALE=en_US --release \
--no-tree-shake-icons \
--export-options-plist="$AGENT_TEMPDIRECTORY/export-options.plist"Depois do build, o step:
- exige que exista exatamente um IPA não vazio;
- descompacta o IPA e roda
codesign --verify --deep --strictno.app; - confere que existe o
embedded.mobileprovisiondentro do app; - copia o IPA e os
dSYMs(para símbolos de crash) para o staging e gera oSHA256SUMS.
E publica tudo como artifact antes do upload para a Apple. Assim, o IPA fica disponível no Azure independentemente do que acontecer no envio.
O envio para o App Store Connect
Por que instalar o Transporter
O Transporter é o uploader oficial da Apple. O problema é que ele não está mais embutido no Xcode: chamar xcrun iTMSTransporter num Mac recente responde que o Transporter agora faz parte do aplicativo Transporter, distribuído pela Mac App Store.
Num agente hospedado isso não funciona. Ele nasce limpo a cada execução, não tem Apple Account logada e não tem ninguém para clicar em “Obter” na App Store.
A saída é instalar a versão de linha de comando do Transporter, que a Apple distribui como pacote .pkg num endpoint público (o mesmo usado pelo projeto Apple-Actions). Ela é instalada com o installer do macOS e fica em /usr/local/itms/bin/iTMSTransporter. Existem outras ferramentas de upload, como o fastlane, mas aqui a escolha foi ficar só com o que a Apple fornece, sem dependência extra.
O step de upload
- bash: |
set -euo pipefail
: "${ASC_ISSUER_ID:?Set the APP_STORE_CONNECT_ISSUER_ID pipeline variable}"
: "${ASC_KEY_ID:?Set the ASC_KEY_ID pipeline variable}"
ipas=("$BUILD_ARTIFACTSTAGINGDIRECTORY"/ios-production/*.ipa)
key_dir="$HOME/.appstoreconnect/private_keys"
mkdir -p "$key_dir"
cp "$ASC_API_KEY_PATH" "$key_dir/AuthKey_${ASC_KEY_ID}.p8"
chmod 600 "$key_dir/AuthKey_${ASC_KEY_ID}.p8"
trap 'rm -f "$key_dir/AuthKey_${ASC_KEY_ID}.p8"' EXIT
transporter_pkg="$AGENT_TEMPDIRECTORY/itmstransporter.pkg"
curl -fsSL \
'https://itunesconnect.apple.com/WebObjects/iTunesConnect.woa/ra/resources/download/public/Transporter__OSX/bin/' \
-o "$transporter_pkg"
sudo installer -pkg "$transporter_pkg" -target /
rm -f "$transporter_pkg"
/usr/local/itms/bin/iTMSTransporter -m upload \
-assetFile "${ipas[0]}" \
-apiKey "$ASC_KEY_ID" \
-apiIssuer "$ASC_ISSUER_ID" \
-v informational
displayName: Upload IPA to App Store Connect for TestFlight
env:
ASC_API_KEY_PATH: $(appStoreConnectApiKey.secureFilePath)
ASC_ISSUER_ID: $(APP_STORE_CONNECT_ISSUER_ID)
ASC_KEY_ID: $(ASC_KEY_ID)Pedaço por pedaço:
: "${VAR:?mensagem}": se a variável estiver vazia, o step falha na hora com uma mensagem que diz exatamente o que configurar.- A chave
.p8precisa de nome e lugar certos. Quando você passa-apiKey <KEY_ID>, o Transporter procura um arquivo chamadoAuthKey_<KEY_ID>.p8em pastas conhecidas, como~/.appstoreconnect/private_keys. Por isso o step copia o Secure File para lá, com esse nome e permissão600. trap ... EXIT: a chave é apagada quando o script termina, com sucesso ou com erro.- Instalação do Transporter: baixa o
.pkgoficial, instala, apaga o instalador. - Upload: autenticado pela API Key, sem usuário, senha ou 2FA.
Quando o comando termina sem erro, o build aparece no App Store Connect depois que a Apple processa o arquivo. A partir daí você segue no TestFlight ou na submissão para revisão. O pipeline não envia nada para revisão e não libera build para testadores sozinho: ele entrega o IPA, e o resto continua sendo decisão sua.
Limpeza final
- bash: |
rm -f ios/Flutter/Meta.xcconfig "$AGENT_TEMPDIRECTORY/export-options.plist"
rm -f "$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8"
condition: always()
env:
ASC_KEY_ID: $(ASC_KEY_ID)Mesma lógica do Android: always(), para não sobrar arquivo sensível em nenhum cenário.
Como disparar um release
Com tudo configurado, publicar vira isto:
# pubspec.yaml: version: 1.0.68+68
git tag app-production-v1.0.68
git push origin app-production-v1.0.68Tag app-production-v* gera os dois. android-production-v* ou ios-production-v* gera só uma plataforma. Os artifacts ficam na execução do pipeline, e o IPA vai para o App Store Connect.
Checklist de segurança
Resumindo tudo que torna esse pipeline seguro:
- Nenhum segredo no Git. Keystore, P12, perfil,
.p8e configs privadas no Secure Files. Senhas em variáveis secretas. - Segredo só onde precisa. Variáveis secretas mapeadas no
envde cada step, e não globalmente. - Nenhum segredo no log. Nada de
echode variável sensível. O Azure mascara os secrets, mas é melhor não depender só disso. - Produção só por tag, e PR não roda o pipeline de release.
- Checkout limpo, sem credencial persistida.
- Versões travadas: Flutter pelo
.tool-versions, pacotes com--enforce-lockfile, tag conferida contra opubspec.yaml. - Keychain temporário e perfil removido no fim do job iOS.
- Validação antes do build longo: perfil, identidade de assinatura e variáveis conferidos em segundos.
- Saídas verificadas:
apksigner, assinatura do AAB,codesign --verifye checksums. - API Key com o mínimo de permissão (Developer basta para upload).
- Limpeza com
condition: always()etrappara a chave da API. - Artifact antes do upload, para o IPA nunca se perder.
Uma dica extra que vale adicionar: o Issuer ID tem formato de UUID. Uma linha no começo do job valida o formato e evita descobrir um erro de digitação só no último step:
[[ "$ASC_ISSUER_ID" =~ ^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$ ]] \
|| { echo "Issuer ID com formato inválido"; exit 1; }O comando de build continua sendo uma linha. O que o pipeline acrescenta é tudo em volta dela: de onde vêm as credenciais, quem pode usá-las, como provar que a saída está assinada e como garantir que nada sensível sobra no agente.
Montado assim, um release deixa de depender de uma máquina específica ou de alguém que lembra onde estava o P12. Você cria uma tag, e o Azure entrega APK, AAB e IPA verificados, com o iOS já a caminho do TestFlight.
Em CI, credencial é código operacional. Trate com o mesmo cuidado que você trata o resto do app.







