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