Automatizando Despliegues de Android a Google Play Store
Configurando builds y despliegues automatizados de Android — desde la configuración de Gradle hasta la publicación en Play Store vía CI/CD.

Publicar una app Android en Play Store suena como que debería ser simple — construir un APK, subirlo, listo. En la práctica, el proceso involucra configuraciones de firma, variantes de build, formatos de bundle, tracks de Play Store, rollouts graduales y suficiente configuración de Gradle como para hacer que cualquiera cuestione sus decisiones profesionales. Después de automatizar despliegues de Android en varios proyectos, incluyendo apps Flutter que necesitan pipelines tanto para iOS como Android, tengo una configuración que maneja todo desde el commit hasta el rollout en producción sin intervención manual.
El Sistema de Build de Android
Antes de automatizar cualquier cosa, necesitas un entendimiento sólido de cómo funcionan los builds de Android. A diferencia de iOS, donde Xcode maneja la mayor parte de la complejidad detrás de una GUI, el sistema de build de Android está completamente basado en Gradle y se configura mediante código. Esto es en realidad una ventaja para la automatización — todo es explícito y está versionado.
Configuración de Gradle para Builds de Release
Tu android/app/build.gradle (o build.gradle.kts si has migrado a Kotlin DSL) es el archivo central de configuración. Para builds de release, necesitas configurar la firma, minificación y optimización.
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'
}
}
}
La configuración de firma lee primero de variables de entorno, luego recurre a un archivo de propiedades local. Este patrón permite a los desarrolladores firmar builds localmente usando un archivo key.properties mientras que CI usa variables de entorno — el mismo archivo Gradle funciona en ambos contextos sin modificación.
El Archivo de Propiedades Local
Para desarrollo local, crea un archivo key.properties en tu directorio android/ (y agrégalo a .gitignore inmediatamente):
storePassword=your_store_password
keyPassword=your_key_password
keyAlias=your_key_alias
keystorePath=../keys/release-keystore.jks
Luego referéncialo en tu build.gradle:
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
Configuración de Firma
La firma de apps Android es más simple que la firma de código de iOS, pero conlleva un riesgo permanente: si pierdes tu clave de subida, pierdes la capacidad de actualizar tu app. Google introdujo Play App Signing para mitigar esto, y lo recomiendo fuertemente.
Play App Signing
Con Play App Signing habilitado, Google mantiene la clave de distribución real. Tú firmas tus subidas con una clave de subida, y Google re-firma con la clave de distribución antes de entregar a los usuarios. Los beneficios:
- Recuperación de pérdida de clave: Si pierdes tu clave de subida, Google puede resetearla. Sin Play App Signing, perder tu clave significa crear un nuevo listado de app.
- APKs más pequeños: Google puede optimizar la app para cada configuración de dispositivo usando la clave de distribución.
- Rotación de clave: Puedes rotar tu clave de subida sin afectar a los usuarios instalados.
Habilítalo en Play Console bajo Configuración > Firma de la app. Para nuevas apps, está habilitado por defecto.
Generando un Keystore
Si necesitas crear un nuevo keystore:
keytool -genkey -v \
-keystore release-keystore.jks \
-keyalg RSA \
-keysize 2048 \
-validity 10000 \
-alias your_key_alias
Almacena este archivo keystore de forma segura. Para CI, lo codifico en base64 y lo almaceno como un secret de GitHub Actions:
base64 -i release-keystore.jks | pbcopy
Luego lo decodifico en el workflow de CI antes de construir:
- name: Decode keystore
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/keystore.jks
AAB vs APK
Google ahora requiere Android App Bundles (AAB) para todas las nuevas apps en Play Store. La diferencia importa para tu pipeline de build.
APK (Android Package) es un archivo único que contiene todo para todas las configuraciones de dispositivo. Es más grande, pero universal — cualquier dispositivo Android puede instalar cualquier APK. Los APKs siguen siendo útiles para distribución interna, pruebas en dispositivos físicos y distribución fuera de Play Store.
AAB (Android App Bundle) contiene todo el código y recursos pero permite a Google Play generar APKs optimizados para cada dispositivo. Un usuario con un teléfono Pixel solo descarga los recursos que necesita, no los assets para cada densidad de pantalla y arquitectura de CPU. Los bundles son típicamente 15-30% más pequeños que los APKs universales.
Para tu pipeline de CI, construye ambos:
# For Play Store
flutter build appbundle --release
# For internal testing / direct installation
flutter build apk --release --split-per-abi
La flag --split-per-abi genera APKs separados para cada arquitectura de CPU (arm64-v8a, armeabi-v7a, x86_64), lo cual reduce el tamaño de archivo para distribución directa.
Fastlane Supply para Play Store
La acción supply de Fastlane maneja las subidas a Play Store y la gestión de metadatos, reflejando lo que deliver hace para iOS.
Configurando Supply
Primero, necesitas una cuenta de servicio de Google Play con los permisos correctos.
- Ve a Google Cloud Console y crea una cuenta de servicio.
- Descarga el archivo de clave JSON.
- En Play Console, ve a Configuración > Acceso a la API y vincula la cuenta de servicio.
- Otórgale permisos de "Release manager" para tu app.
# android/fastlane/Appfile
json_key_file(ENV["PLAY_STORE_JSON_KEY"] || "path/to/service-account.json")
package_name("com.yourcompany.yourapp")
Subida Básica
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 proyectos Flutter, prefiero ejecutar el comando de build de Flutter directamente en lugar de usar la tarea de Gradle a través de Fastlane, porque el proceso de build de Flutter incluye pasos de compilación de Dart que Gradle solo no maneja correctamente:
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 de Play Store
Play Store tiene cuatro tracks, y entender cuándo usar cada uno es importante para tu estrategia de lanzamiento.
Pruebas Internas
- Hasta 100 testers.
- No se requiere revisión — los builds están disponibles casi instantáneamente.
- Mejor para: pruebas del equipo de desarrollo, ciclos de QA, verificar builds antes de promover.
Pruebas Cerradas (Alpha)
- Testers ilimitados, pero tú gestionas la lista.
- Requiere una breve revisión de Google (usualmente unas pocas horas).
- Mejor para: programas beta con usuarios invitados, previews para clientes.
Pruebas Abiertas (Beta)
- Cualquiera puede unirse desde el listado de Play Store.
- Requiere revisión.
- Mejor para: programas beta públicos, recopilar feedback antes de un lanzamiento amplio.
Producción
- Disponible para todos los usuarios.
- Se requiere revisión completa.
- Soporta rollouts graduales.
Promoviendo Entre Tracks
Fastlane hace que la promoción entre tracks sea sencilla:
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 Graduales
Los rollouts graduales son una de las mayores ventajas de Android sobre iOS. Puedes lanzar a un porcentaje de usuarios e incrementar gradualmente, monitoreando tasas de crashes y feedback de usuarios en cada etapa.
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
Un cronograma típico de rollout:
- Día 1: 10% — observar tasas de crash en Firebase Crashlytics.
- Día 2-3: 25% — verificar feedback de usuarios y tickets de soporte.
- Día 4-5: 50% — verificar que no hay regresiones de rendimiento a escala.
- Día 6-7: 100% — lanzamiento completo.
Si algo sale mal en cualquier etapa, puedes detener el rollout y enviar un fix sin afectar a todos los usuarios.
Workflow de GitHub Actions
Aquí hay un 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
Algunas cosas a notar sobre este workflow:
Los runners de Ubuntu funcionan bien para Android. A diferencia de iOS, que requiere macOS, los builds de Android corren en Linux. Los runners de Ubuntu son más baratos y arrancan más rápido en GitHub Actions.
Se requiere Java 17 para las versiones actuales del Android Gradle Plugin. Si tu proyecto usa una versión más antigua del AGP, podrías necesitar Java 11, pero esto es cada vez más raro.
El paso de limpieza elimina los secrets decodificados incluso si el build falla. Esta es una medida de defensa en profundidad — los runners de GitHub Actions son efímeros, pero es buena práctica.
Gestión de Versiones
Android usa dos identificadores de versión: versionCode (un entero que debe incrementar con cada subida) y versionName (la cadena de versión legible para humanos).
Para proyectos Flutter, ambos vienen de pubspec.yaml:
version: 1.2.3+45
# 1.2.3 = versionName
# 45 = versionCode
Automatizo el incremento del código de versión usando el número de ejecución de 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
Esto asegura que el código de versión siempre incremente, incluso entre branches. Si necesitas más control, puedes obtener el último código de versión de 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
Gestionando Listados de Play Store
Supply puede gestionar todo tu listado de Play Store desde archivos 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 descargar tu listado actual como punto de partida:
fastlane supply init
Luego actualiza los archivos y sube los cambios:
lane :update_listing do
supply(
skip_upload_aab: true,
skip_upload_apk: true
)
end
Configuración de ProGuard y R8
Cuando minifyEnabled es true, R8 (el sucesor de ProGuard) reduce, ofusca y optimiza tu código. Esto es esencial para builds de producción — reduce el tamaño del APK y hace la ingeniería inversa más difícil — pero también puede romper cosas si no se configura correctamente.
Problemas comunes y sus reglas 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.**
Prueba tu build de release a fondo — los problemas de R8 a menudo se manifiestan como crashes en tiempo de ejecución que no aparecen en builds de debug. Las flags --obfuscate y --split-debug-info en Flutter habilitan optimizaciones adicionales:
flutter build appbundle --release --obfuscate --split-debug-info=build/symbols
Conserva el directorio build/symbols — lo necesitas para simbolizar stack traces de reportes de crash.
El 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
Diferencias con la Automatización de iOS
Habiendo automatizado ambas plataformas, hay varias diferencias clave que vale la pena mencionar.
La firma es más simple en Android. Un solo archivo keystore versus la danza de certificados y perfiles de aprovisionamiento en iOS. No se necesita match ni ninguna herramienta de gestión de certificados.
No hay requisito obligatorio de macOS. Los builds de Android corren en Linux, lo que significa runners de CI más baratos y rápidos.
Tiempos de revisión más rápidos. El proceso de revisión de Google es típicamente más rápido que el de Apple, y las subidas al track interno no requieren revisión en absoluto.
Los rollouts graduales son nativos. En iOS, puedes hacer lanzamientos por fases, pero no puedes controlar el porcentaje ni detener a mitad de rollout con la misma granularidad.
La API de Play Store es más permisiva. Puedes gestionar casi todo programáticamente, incluyendo experimentos de listado de tienda y precios. La API de App Store Connect de Apple tiene más restricciones.
La principal desventaja son los tiempos de build de Gradle. Un build limpio de Android con optimización R8 puede tomar significativamente más tiempo que un build equivalente de iOS. El caché ayuda — tanto el caché integrado de Gradle como el caché a nivel de CI del directorio .gradle.
Cómo Se Ve un Pipeline Maduro
Después de toda la configuración, la experiencia del día a día debería ser invisible. Haz push a main, un build se ejecuta, y unos minutos después los testers tienen una nueva versión en el track interno. Etiqueta un release, y un build de producción pasa con un rollout gradual.
El pipeline elimina toda una categoría de problemas "funciona en mi máquina", asegura que cada build sea reproducible, y da al equipo la confianza de que enviar es un no-evento. Esa última parte es el verdadero objetivo — hacer que el despliegue sea tan rutinario que nadie piense en ello.
Danil Ulmashev
Full Stack Developer
Interesado en trabajar juntos?