Conceptos básicos de GitHub Actions
Antes de ver código, los términos que vas a ver en todos los archivos:
- Workflow: un archivo YAML en
.github/workflows/. Define cuándo corre y qué hace. - Trigger: el evento que dispara el workflow. Push a main, apertura de un PR, tag, o manual (
workflow_dispatch). - Job: un conjunto de pasos que corre en una máquina virtual. Los jobs pueden correr en paralelo o en secuencia.
- Step: un comando o una action dentro de un job.
- Action: un paso reutilizable publicado en el marketplace.
actions/checkout,actions/setup-java, etc. - Runner: la máquina virtual donde corre el job. Para Android usamos
ubuntu-latest. - Secret: variable cifrada almacenada en GitHub. Nunca aparece en los logs. Acá van el keystore, las contraseñas y las claves de API.
Estructura de archivos
tu-repo/
└── .github/
└── workflows/
├── ci.yml ← corre en cada PR: compila y testea
└── deploy.yml ← corre al mergear a main: firma y publica
Separar CI de deploy en dos archivos distintos tiene una razón concreta: el CI corre en cada PR de cualquier contribuidor, pero el deploy solo debería correr cuando el equipo decide publicar. Mezclarlos en uno solo complica los permisos y el control de cuándo se publica.
CI básico: compilar y correr tests
El primer workflow — corre en cada push a cualquier rama y en cada PR hacia main:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [ "**" ] # cualquier rama
pull_request:
branches: [ main, develop ] # PRs solo hacia estas ramas
jobs:
build-and-test:
name: Compilar y testear
runs-on: ubuntu-latest
steps:
# 1. Clonar el repositorio
- name: Checkout
uses: actions/checkout@v4
# 2. Configurar Java 17 (requerido por AGP moderno)
- name: Setup Java
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
# 3. Configurar Gradle (habilita caché automáticamente)
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
# 4. Dar permisos de ejecución al wrapper
- name: Grant Gradle wrapper permissions
run: chmod +x gradlew
# 5. Compilar el proyecto en modo debug
- name: Compilar debug
run: ./gradlew assembleDebug --no-daemon
# 6. Correr unit tests
- name: Unit tests
run: ./gradlew test --no-daemon
# 7. Publicar resultados de tests (aparecen en la UI de GitHub)
- name: Publicar resultados de tests
uses: actions/upload-artifact@v4
if: always() # publicar aunque el step anterior falle
with:
name: test-results
path: '**/build/reports/tests/'
--no-daemon en CIEn CI es preferible usar --no-daemon. El Gradle daemon está pensado para reutilizarse entre builds en la misma máquina — en CI cada run empieza en una VM limpia, así que el daemon no aporta nada y suma overhead de startup.
Caché de Gradle — la diferencia entre 8 minutos y 2
Sin caché, cada run descarga todas las dependencias de cero. Con caché, solo descarga lo que cambió. La action gradle/actions/setup-gradle ya maneja la caché automáticamente cuando la usás — no necesitás configuración extra.
Lo que cachea: el directorio ~/.gradle/caches (dependencias descargadas) y ~/.gradle/wrapper (el Gradle wrapper). La clave de caché se calcula a partir de los archivos de dependencias — si libs.versions.toml o los build.gradle cambian, la caché se invalida automáticamente.
# Sin caché: primer run ~8-12 min, runs siguientes ~8-12 min
# Con caché: primer run ~8-12 min, runs siguientes ~2-4 min
# El ahorro es real en el uso de minutos de GitHub Actions (free tier: 2000 min/mes)
Secrets — guardar el keystore y las credenciales
El keystore nunca va al repositorio. GitHub Secrets es donde se guardan: van cifrados, no aparecen en logs, y se inyectan como variables de entorno en los workflows.
Para el keystore, el problema es que es un archivo binario — no un string. La solución: convertirlo a Base64, guardarlo como secret de texto, y reconstruirlo en el workflow.
# En tu máquina local — convertir el keystore a Base64:
base64 -i mi-app-release.keystore | tr -d '\n'
# Copiá el output completo
# En GitHub → tu repo → Settings → Secrets and variables → Actions → New secret:
# KEYSTORE_BASE64 ← el output del base64
# KEYSTORE_PASSWORD ← contraseña del keystore
# KEY_ALIAS ← alias de la clave (ej: mi-app)
# KEY_PASSWORD ← contraseña de la clave
# PLAY_SERVICE_ACCOUNT ← JSON de la cuenta de servicio de Play (ver sección deploy)
Los secrets solo están disponibles en el repositorio donde los creasteSi tenés múltiples repos que necesitan el mismo keystore, tenés que agregar los secrets en cada uno, o usar Organization Secrets si tu cuenta es de organización. También podés usar GitHub Environments para tener secrets separados por ambiente (staging vs producción).
Firmar el AAB en el workflow de deploy
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [ main ] # al mergear a main
workflow_dispatch: # o manualmente desde la UI de GitHub
jobs:
deploy:
name: Build, firmar y publicar
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
- name: Grant Gradle wrapper permissions
run: chmod +x gradlew
# Reconstruir el keystore desde el secret Base64
- name: Decodificar keystore
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > keystore/mi-app-release.keystore
# Generar el AAB firmado usando las variables de entorno
- name: Generar AAB firmado
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: |
./gradlew bundleRelease \
-Pandroid.injected.signing.store.file=keystore/mi-app-release.keystore \
-Pandroid.injected.signing.store.password=$KEYSTORE_PASSWORD \
-Pandroid.injected.signing.key.alias=$KEY_ALIAS \
-Pandroid.injected.signing.key.password=$KEY_PASSWORD \
--no-daemon
# Guardar el AAB como artefacto del workflow
- name: Subir AAB como artefacto
uses: actions/upload-artifact@v4
with:
name: app-release
path: app/build/outputs/bundle/release/app-release.aab
retention-days: 30
Alternativa: signing config en build.gradle con variables de entornoPodés configurar el signing config en build.gradle para leer las credenciales de variables de entorno en lugar de pasarlas como parámetros de Gradle. Ambos enfoques funcionan — el que se muestra acá es más explícito y no requiere modificar el build.gradle.
Deploy automático al track interno de Play Store
Para publicar en Play Store desde GitHub Actions necesitás una cuenta de servicio de Google Cloud con permisos en tu proyecto de Play Console.
# Pasos para crear la cuenta de servicio (se hace una sola vez):
# 1. Google Play Console → Configuración → Acceso a API
# 2. Vincular con un proyecto de Google Cloud (o crear uno nuevo)
# 3. Google Cloud Console → IAM → Cuentas de servicio → Crear cuenta de servicio
# 4. Asignar el rol "Editor de versiones de la app de Android" en Play Console
# 5. Crear una clave JSON para la cuenta de servicio
# 6. Copiar el contenido del JSON como secret PLAY_SERVICE_ACCOUNT en GitHub
# Agregar al deploy.yml — después del step de generar el AAB:
# Publicar en el track interno de Play Store
- name: Publicar en track interno
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT }}
packageName: ar.pensa.miapp
releaseFiles: app/build/outputs/bundle/release/app-release.aab
track: internal # internal / alpha / beta / production
status: completed # completed = disponible inmediatamente
# whatsNewDirectory: distribution/whatsnew # opcional: release notes
Con esto, cada push a main genera un build firmado y lo sube al track interno de Play Store automáticamente. Los testers ven la nueva versión en minutos sin que nadie tenga que hacer nada manualmente.
Deploy a producción — con aprobación manual
Publicar en producción automáticamente sin revisión es arriesgado. GitHub Environments permite agregar una aprobación manual como gate antes de que el job de producción corra:
# En GitHub → Settings → Environments → New environment → "production"
# Activar "Required reviewers" y agregar los reviewers autorizados
# En deploy.yml — agregar un job separado para producción:
deploy-produccion:
name: Deploy a producción
runs-on: ubuntu-latest
needs: deploy # espera a que el job anterior termine OK
environment: production # requiere aprobación manual del environment
steps:
- name: Checkout
uses: actions/checkout@v4
# Descargar el AAB que generó el job anterior
- name: Descargar AAB
uses: actions/download-artifact@v4
with:
name: app-release
path: release/
- name: Publicar en producción (20% staged rollout)
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT }}
packageName: ar.pensa.miapp
releaseFiles: release/app-release.aab
track: production
status: inProgress # staged rollout en progreso
userFraction: 0.2 # 20% de usuarios inicialmente
El flujo con aprobaciónEl job deploy sube al track interno automáticamente. El job deploy-produccion queda en estado "waiting" hasta que alguien con permisos lo aprueba en la UI de GitHub. Una vez aprobado, corre y publica en producción con staged rollout del 20%. Sin tocar Android Studio ni Play Console.
versionCode automático — nunca olvidarlo de nuevo
El versionCode debe incrementar en cada build que se sube a Play Store. La forma más simple en CI: usar el número del run de GitHub Actions, que es siempre único y creciente.
# En el step de generar el AAB, pasar el versionCode como parámetro:
- name: Generar AAB firmado
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: |
./gradlew bundleRelease \
-Pandroid.injected.signing.store.file=keystore/mi-app-release.keystore \
-Pandroid.injected.signing.store.password=$KEYSTORE_PASSWORD \
-Pandroid.injected.signing.key.alias=$KEY_ALIAS \
-Pandroid.injected.signing.key.password=$KEY_PASSWORD \
-PversionCode=${{ github.run_number }} \
--no-daemon
// build.gradle (app) — leer el versionCode del parámetro de Gradle:
android {
defaultConfig {
// Si el parámetro está presente (en CI), usarlo; si no (en local), usar 1
versionCode = (project.findProperty("versionCode") as String?)?.toInt() ?: 1
versionName = "1.4.0" // este lo seguís manejando vos manualmente
}
}
Con esto, en local el versionCode siempre es 1 (para debug no importa), y en CI es el número del run — único, creciente, y nunca hay que acordarse de incrementarlo.
Matrix builds — testear en múltiples versiones de API
Si querés correr los tests en múltiples versiones del SDK de Android (para detectar problemas de compatibilidad), GitHub Actions soporta matrices:
# En ci.yml — correr tests en API 26, 29 y 33 en paralelo:
jobs:
test:
name: Tests en API ${{ matrix.api-level }}
runs-on: ubuntu-latest
strategy:
matrix:
api-level: [26, 29, 33]
fail-fast: false # si falla uno, los otros siguen corriendo
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
# Para tests instrumentados en emulador (más costoso en tiempo):
- name: AVD cache
uses: actions/cache@v4
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-${{ matrix.api-level }}
- name: Crear AVD y correr tests instrumentados
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ matrix.api-level }}
script: ./gradlew connectedAndroidTest --no-daemon
Los tests instrumentados en CI son lentos y costososArrancar un emulador en CI tarda 3-5 minutos por API level. Con una matriz de 3 versiones son hasta 15 minutos solo en setup. Reservalos para PRs hacia main o para un schedule nocturno — no para cada push de cada rama.
El pipeline completo — resumen
# ── ci.yml ────────────────────────────────────────────────────
# Trigger: push a cualquier rama, PR hacia main/develop
# Jobs:
# build-and-test:
# - checkout
# - setup java 17
# - setup gradle (con caché)
# - assembleDebug → verifica que compila
# - test → unit tests
# - upload test results → visible en la UI de GitHub
# ── deploy.yml ────────────────────────────────────────────────
# Trigger: push a main, o manual (workflow_dispatch)
# Jobs:
# deploy:
# - checkout
# - decodificar keystore desde secret
# - bundleRelease con signing y versionCode=${{ github.run_number }}
# - upload AAB como artefacto
# - publicar en track INTERNO de Play Store
# deploy-produccion:
# - needs: deploy (espera al job anterior)
# - environment: production (requiere aprobación manual)
# - descargar AAB del job anterior
# - publicar en PRODUCCIÓN con staged rollout 20%
Checklist antes de activar el pipeline
# ✓ Secrets configurados en GitHub:
# KEYSTORE_BASE64, KEYSTORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD
# PLAY_SERVICE_ACCOUNT (JSON de la cuenta de servicio)
# ✓ La carpeta keystore/ está en .gitignore
# (el keystore real nunca va al repo, solo el secret)
# ✓ La cuenta de servicio tiene permisos en Play Console
# (rol "Editor de versiones" en la app específica)
# ✓ build.gradle lee versionCode del parámetro de Gradle
# ✓ El environment "production" está creado con reviewers asignados
# ✓ El primer build se corrió manualmente para verificar que todo funciona
# antes de confiar en que el pipeline es correcto