Deploy seguro de Flutter no Azure DevOps: Android e iOS saindo de uma tag direto para as lojas

Como montar um pipeline no Azure DevOps que, a partir de uma tag, compila Android e iOS de um app Flutter, assina tudo sem expor segredo e envia o IPA direto para o App Store Connect. O YAML explicado bloco a bloco.

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:

  1. Você cria uma tag de release no Git.
  2. O pipeline compila Android (APK e AAB) num agente Linux e iOS (IPA) num agente macOS.
  3. Tudo sai assinado, verificado e publicado como artifact.
  4. 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 Connect

Os 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_PASSWORD
  • IOS_P12_PASSWORD
  • APP_STORE_CONNECT_ISSUER_ID
  • ASC_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 build

No repositório

Dois arquivos pequenos que deixam o build reprodutível:

  • pubspec.yaml com a versão no formato X.Y.Z+BUILD, por exemplo 1.0.68+68.
  • .tool-versions fixando a versão do Flutter, por exemplo flutter 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-sdk

O 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: false

clean: 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.yaml e exige que a tag bata com ela (app-production-v1.0.68 só passa se o pubspec disser 1.0.68);
  • lê o .tool-versions e 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.jks

A 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 o strings.xml com a configuração privada;
  • grava esses arquivos com permissão 0600 e 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.aab

Compilar 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: 60

Certificado 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: true
  • keychain: 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 o codesign para 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 --verbose

O 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 --strict no .app;
  • confere que existe o embedded.mobileprovision dentro do app;
  • copia o IPA e os dSYMs (para símbolos de crash) para o staging e gera o SHA256SUMS.

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 .p8 precisa de nome e lugar certos. Quando você passa -apiKey <KEY_ID>, o Transporter procura um arquivo chamado AuthKey_<KEY_ID>.p8 em pastas conhecidas, como ~/.appstoreconnect/private_keys. Por isso o step copia o Secure File para lá, com esse nome e permissão 600.
  • trap ... EXIT: a chave é apagada quando o script termina, com sucesso ou com erro.
  • Instalação do Transporter: baixa o .pkg oficial, 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.68

Tag 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, .p8 e configs privadas no Secure Files. Senhas em variáveis secretas.
  • Segredo só onde precisa. Variáveis secretas mapeadas no env de cada step, e não globalmente.
  • Nenhum segredo no log. Nada de echo de 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 o pubspec.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 --verify e checksums.
  • API Key com o mínimo de permissão (Developer basta para upload).
  • Limpeza com condition: always() e trap para 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.