Automatizando Deploys Android para a Google Play Store
Configurando builds e deploys automatizados para Android — desde configuração do Gradle até publicação na Play Store via CI/CD.

Publicar um app Android na Play Store parece que deveria ser simples — compilar um APK, fazer upload, pronto. Na prática, o processo envolve configurações de assinatura, variantes de build, formatos de bundle, tracks da Play Store, rollouts escalonados e configuração Gradle suficiente para fazer qualquer um questionar suas escolhas de carreira. Depois de automatizar deploys Android em vários projetos, incluindo apps Flutter que precisam de pipelines tanto para iOS quanto para Android, eu tenho uma configuração que lida com tudo, do commit ao rollout em produção, sem intervenção manual.
O Sistema de Build Android
Antes de automatizar qualquer coisa, você precisa de um entendimento sólido de como os builds Android funcionam. Diferente do iOS, onde o Xcode lida com a maior parte da complexidade por trás de uma GUI, o sistema de build do Android é inteiramente baseado em Gradle e configurado através de código. Isso é na verdade uma vantagem para automação — tudo é explícito e versionado.
Configuração Gradle para Builds de Release
Seu android/app/build.gradle (ou build.gradle.kts se você migrou para Kotlin DSL) é o arquivo central de configuração. Para builds de release, você precisa configurar assinatura, minificação e otimização.
android {
compileSdkVersion 35
defaultConfig {
applicationId "com.yourcompany.yourapp"
minSdkVersion 24
targetSdkVersion 35
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
signingConfigs {
release {
keyAlias System.getenv("KEY_ALIAS") ?: properties["keyAlias"]
keyPassword System.getenv("KEY_PASSWORD") ?: properties["keyPassword"]
storeFile file(System.getenv("KEYSTORE_PATH") ?: properties["keystorePath"] ?: "keystore.jks")
storePassword System.getenv("STORE_PASSWORD") ?: properties["storePassword"]
}
}
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
A configuração de assinatura lê de variáveis de ambiente primeiro, depois faz fallback para um arquivo de propriedades local. Esse padrão permite que desenvolvedores assinem builds localmente usando um arquivo key.properties enquanto o CI usa variáveis de ambiente — o mesmo arquivo Gradle funciona em ambos os contextos sem modificação.
O Arquivo de Propriedades Local
Para desenvolvimento local, crie um arquivo key.properties no seu diretório android/ (e adicione-o ao .gitignore imediatamente):
storePassword=your_store_password
keyPassword=your_key_password
keyAlias=your_key_alias
keystorePath=../keys/release-keystore.jks
Depois referencie-o no seu build.gradle:
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
Configuração de Assinatura
A assinatura de apps Android é mais simples que o code signing do iOS, mas carrega um risco permanente: se você perder sua chave de upload, perde a capacidade de atualizar seu app. O Google introduziu o Play App Signing para mitigar isso, e eu recomendo fortemente usá-lo.
Play App Signing
Com o Play App Signing habilitado, o Google mantém a chave de distribuição real. Você assina seus uploads com uma chave de upload, e o Google reassina com a chave de distribuição antes de entregar aos usuários. Os benefícios:
- Recuperação de chave perdida: Se você perder sua chave de upload, o Google pode resetá-la. Sem Play App Signing, perder sua chave significa criar uma nova listagem de app.
- APKs menores: O Google pode otimizar o app para cada configuração de dispositivo usando a chave de distribuição.
- Rotação de chave: Você pode rotacionar sua chave de upload sem afetar usuários instalados.
Habilite no Play Console em Configurações > Assinatura de app. Para novos apps, é habilitado por padrão.
Gerando um Keystore
Se você precisa criar um novo keystore:
keytool -genkey -v \
-keystore release-keystore.jks \
-keyalg RSA \
-keysize 2048 \
-validity 10000 \
-alias your_key_alias
Armazene este arquivo keystore com segurança. Para CI, eu codifico em base64 e armazeno como um secret do GitHub Actions:
base64 -i release-keystore.jks | pbcopy
Depois decodifico no workflow do CI antes de compilar:
- name: Decode keystore
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/keystore.jks
AAB vs APK
O Google agora exige Android App Bundles (AAB) para todos os novos apps na Play Store. A diferença importa para seu pipeline de build.
APK (Android Package) é um arquivo único contendo tudo para todas as configurações de dispositivo. É maior, mas universal — qualquer dispositivo Android pode instalar qualquer APK. APKs ainda são úteis para distribuição interna, testes em dispositivos físicos e distribuição fora da Play Store.
AAB (Android App Bundle) contém todo o código e recursos mas permite que o Google Play gere APKs otimizados para cada dispositivo. Um usuário com um telefone Pixel baixa apenas os recursos que precisa, não os assets para cada densidade de tela e arquitetura de CPU. Bundles são tipicamente 15-30% menores que APKs universais.
Para seu pipeline de CI, compile ambos:
# For Play Store
flutter build appbundle --release
# For internal testing / direct installation
flutter build apk --release --split-per-abi
A flag --split-per-abi gera APKs separados para cada arquitetura de CPU (arm64-v8a, armeabi-v7a, x86_64), o que reduz o tamanho do arquivo para distribuição direta.
Fastlane Supply para Play Store
A ação supply do Fastlane lida com uploads e gerenciamento de metadados da Play Store, espelhando o que o deliver faz para iOS.
Configurando o Supply
Primeiro, você precisa de uma conta de serviço do Google Play com as permissões corretas.
- Vá ao Google Cloud Console e crie uma conta de serviço.
- Baixe o arquivo de chave JSON.
- No Play Console, vá em Configurações > Acesso à API e vincule a conta de serviço.
- Conceda permissões de "Release manager" para seu app.
# android/fastlane/Appfile
json_key_file(ENV["PLAY_STORE_JSON_KEY"] || "path/to/service-account.json")
package_name("com.yourcompany.yourapp")
Upload Básico
default_platform(:android)
platform :android do
desc "Deploy to Play Store Internal Testing"
lane :internal do
gradle(
task: "bundle",
build_type: "Release",
project_dir: "./"
)
supply(
track: "internal",
aab: "../build/app/outputs/bundle/release/app-release.aab",
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
end
Para projetos Flutter, eu prefiro executar o comando de build do Flutter diretamente em vez de usar a task Gradle pelo Fastlane, porque o processo de build do Flutter inclui passos de compilação Dart que o Gradle sozinho não lida corretamente:
lane :internal do
Dir.chdir("..") do
sh("flutter build appbundle --release")
end
supply(
track: "internal",
aab: "../build/app/outputs/bundle/release/app-release.aab",
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
Tracks da Play Store
A Play Store tem quatro tracks, e entender quando usar cada um é importante para sua estratégia de release.
Teste Interno
- Até 100 testadores.
- Sem revisão necessária — builds ficam disponíveis quase instantaneamente.
- Melhor para: testes do time de desenvolvimento, ciclos de QA, verificação de builds antes de promover.
Teste Fechado (Alpha)
- Testadores ilimitados, mas você gerencia a lista.
- Requer uma breve revisão do Google (geralmente algumas horas).
- Melhor para: programas beta com usuários convidados, previews para clientes.
Teste Aberto (Beta)
- Qualquer pessoa pode participar pela listagem da Play Store.
- Requer revisão.
- Melhor para: programas beta públicos, coleta de feedback antes de um lançamento amplo.
Produção
- Disponível para todos os usuários.
- Revisão completa necessária.
- Suporta rollouts escalonados.
Promovendo Entre Tracks
O Fastlane torna a promoção entre tracks direta:
lane :promote_to_beta do
supply(
track: "internal",
track_promote_to: "beta",
skip_upload_changelogs: false,
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
lane :promote_to_production do
supply(
track: "beta",
track_promote_to: "production",
rollout: "0.1", # 10% staged rollout
skip_upload_changelogs: false,
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
Rollouts Escalonados
Rollouts escalonados são uma das maiores vantagens do Android sobre o iOS. Você pode lançar para uma porcentagem de usuários e aumentar gradualmente, monitorando taxas de crash e feedback dos usuários em cada estágio.
lane :staged_rollout do |options|
percentage = options[:percentage] || "0.1"
supply(
track: "production",
rollout: percentage,
aab: "../build/app/outputs/bundle/release/app-release.aab"
)
end
lane :increase_rollout do |options|
percentage = options[:percentage] || "0.5"
supply(
track: "production",
rollout: percentage,
skip_upload_aab: true,
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
lane :complete_rollout do
supply(
track: "production",
rollout: "1.0",
skip_upload_aab: true,
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
Um cronograma típico de rollout:
- Dia 1: 10% — observe taxas de crash no Firebase Crashlytics.
- Dia 2-3: 25% — verifique feedback dos usuários e tickets de suporte.
- Dia 4-5: 50% — confirme que não há regressões de performance em escala.
- Dia 6-7: 100% — release completo.
Se algo der errado em qualquer estágio, você pode pausar o rollout e enviar uma correção sem afetar todos os usuários.
Workflow do GitHub Actions
Aqui está um workflow completo de CI/CD para Android:
name: Deploy to Play Store
on:
push:
branches: [main]
workflow_dispatch:
inputs:
track:
description: 'Play Store track'
required: true
default: 'internal'
type: choice
options:
- internal
- alpha
- beta
- production
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
cache: 'gradle'
- 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: android
- name: Flutter dependencies
run: flutter pub get
- name: Decode keystore
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/keystore.jks
- name: Create service account file
run: echo '${{ secrets.PLAY_STORE_SERVICE_ACCOUNT }}' > android/play-store-key.json
- name: Build and deploy
working-directory: android
env:
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
KEYSTORE_PATH: app/keystore.jks
PLAY_STORE_JSON_KEY: play-store-key.json
run: bundle exec fastlane ${{ github.event.inputs.track || 'internal' }}
- name: Cleanup secrets
if: always()
run: |
rm -f android/app/keystore.jks
rm -f android/play-store-key.json
Algumas coisas a notar sobre este workflow:
Runners Ubuntu funcionam bem para Android. Diferente do iOS, que requer macOS, builds Android rodam no Linux. Runners Ubuntu são mais baratos e iniciam mais rápido no GitHub Actions.
Java 17 é necessário para versões atuais do Android Gradle Plugin. Se seu projeto usa uma versão mais antiga do AGP, você pode precisar do Java 11, mas isso é cada vez mais raro.
O passo de cleanup remove secrets decodificados mesmo se o build falhar. Esta é uma medida de defesa em profundidade — runners do GitHub Actions são efêmeros, mas é boa prática.
Gerenciamento de Versão
O Android usa dois identificadores de versão: versionCode (um inteiro que deve aumentar a cada upload) e versionName (a string de versão legível por humanos).
Para projetos Flutter, ambos vêm do pubspec.yaml:
version: 1.2.3+45
# 1.2.3 = versionName
# 45 = versionCode
Eu automatizo o incremento do version code usando o número de execução do CI:
lane :set_version_code do
build_number = ENV["GITHUB_RUN_NUMBER"] || "1"
Dir.chdir("..") do
current_version = sh("grep '^version:' pubspec.yaml | sed 's/version: //' | sed 's/+.*//'").strip
sh("sed -i 's/^version: .*/version: #{current_version}+#{build_number}/' pubspec.yaml")
end
end
Isso garante que o version code sempre aumente, mesmo entre branches. Se você precisar de mais controle, pode buscar o último version code da Play Store:
lane :smart_version_code do
current = google_play_track_version_codes(track: "internal").max || 0
new_code = current + 1
Dir.chdir("..") do
current_version = sh("grep '^version:' pubspec.yaml | sed 's/version: //' | sed 's/+.*//'").strip
sh("sed -i 's/^version: .*/version: #{current_version}+#{new_code}/' pubspec.yaml")
end
end
Gerenciando Listagens da Play Store
O Supply pode gerenciar toda a listagem da Play Store a partir de arquivos versionados:
android/fastlane/metadata/android/
├── en-US/
│ ├── title.txt
│ ├── short_description.txt
│ ├── full_description.txt
│ ├── changelogs/
│ │ ├── default.txt
│ │ └── 45.txt # Changelog for versionCode 45
│ └── images/
│ ├── phoneScreenshots/
│ │ ├── 1_home.png
│ │ └── 2_detail.png
│ ├── featureGraphic.png
│ └── icon.png
└── ru-RU/
├── title.txt
├── short_description.txt
└── full_description.txt
Para baixar sua listagem atual como ponto de partida:
fastlane supply init
Depois atualize os arquivos e envie as mudanças:
lane :update_listing do
supply(
skip_upload_aab: true,
skip_upload_apk: true
)
end
Configuração do ProGuard e R8
Quando minifyEnabled é true, o R8 (o sucessor do ProGuard) encolhe, ofusca e otimiza seu código. Isso é essencial para builds de produção — reduz o tamanho do APK e dificulta a engenharia reversa — mas também pode quebrar coisas se não for configurado corretamente.
Problemas comuns e suas regras ProGuard:
# Keep Flutter's classes
-keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; }
# Keep Firebase classes
-keep class com.google.firebase.** { *; }
# Keep models used with JSON serialization
-keep class com.yourcompany.yourapp.models.** { *; }
# Keep classes referenced via reflection
-keepattributes *Annotation*
-keepattributes Signature
-keepattributes InnerClasses
# Common crash fix: OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
Teste seu build de release completamente — problemas do R8 frequentemente se manifestam como crashes em tempo de execução que não aparecem em builds de debug. As flags --obfuscate e --split-debug-info no Flutter habilitam otimizações adicionais:
flutter build appbundle --release --obfuscate --split-debug-info=build/symbols
Mantenha o diretório build/symbols — você precisa dele para simbolizar stack traces de relatórios de crash.
O Fastfile Completo
default_platform(:android)
platform :android do
desc "Deploy to internal testing"
lane :internal do
set_version_code
build_release
supply(
track: "internal",
aab: "../build/app/outputs/bundle/release/app-release.aab",
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
desc "Deploy to closed beta"
lane :beta do
set_version_code
build_release
supply(
track: "beta",
aab: "../build/app/outputs/bundle/release/app-release.aab",
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
desc "Deploy to production with staged rollout"
lane :production do
set_version_code
build_release
supply(
track: "production",
rollout: "0.1",
aab: "../build/app/outputs/bundle/release/app-release.aab"
)
end
desc "Promote internal to beta"
lane :promote_to_beta do
supply(
track: "internal",
track_promote_to: "beta"
)
end
desc "Increase production rollout"
lane :increase_rollout do |options|
supply(
track: "production",
rollout: options[:percentage] || "0.5",
skip_upload_aab: true
)
end
private_lane :build_release do
Dir.chdir("..") do
sh("flutter build appbundle --release --obfuscate --split-debug-info=build/symbols")
end
end
private_lane :set_version_code do
build_number = ENV["GITHUB_RUN_NUMBER"] || Time.now.strftime("%Y%m%d%H%M")
Dir.chdir("..") do
current_version = sh("grep '^version:' pubspec.yaml | sed 's/version: //' | sed 's/+.*//'").strip
sh("sed -i '' 's/^version: .*/version: #{current_version}+#{build_number}/' pubspec.yaml")
end
end
end
Diferenças da Automação iOS
Tendo automatizado ambas as plataformas, existem várias diferenças-chave que valem ser notadas.
A assinatura é mais simples no Android. Um único arquivo keystore versus a dança de certificados e provisioning profiles no iOS. Sem necessidade de match ou qualquer ferramenta de gerenciamento de certificados.
Sem requisito obrigatório de macOS. Builds Android rodam em Linux, o que significa runners de CI mais baratos e mais rápidos.
Tempos de revisão mais rápidos. O processo de revisão do Google é tipicamente mais rápido que o da Apple, e uploads para o track interno não requerem revisão alguma.
Rollouts escalonados são nativos. No iOS, você pode fazer releases faseados, mas não pode controlar a porcentagem ou pausar no meio do rollout com a mesma granularidade.
A API da Play Store é mais permissiva. Você pode gerenciar quase tudo programaticamente, incluindo experimentos de listagem na loja e preços. A API do App Store Connect da Apple tem mais restrições.
A principal desvantagem são os tempos de build do Gradle. Um build limpo do Android com otimização R8 pode levar significativamente mais tempo que um build iOS equivalente. Cache ajuda — tanto o cache nativo do Gradle quanto o cache no nível do CI do diretório .gradle.
Como um Pipeline Maduro Se Parece
Após toda a configuração, a experiência do dia a dia deve ser invisível. Push para main, um build roda, e alguns minutos depois os testadores têm uma nova versão no track interno. Marque um release, e um build de produção passa com rollout escalonado.
O pipeline remove uma categoria inteira de problemas de "funciona na minha máquina", garante que cada build é reproduzível e dá ao time confiança de que publicar é um não-evento. Essa última parte é o verdadeiro objetivo — tornar o deploy tão rotineiro que ninguém pensa sobre isso.