AndroidGitHub ActionsGoogle PlayCI/CDKotlin

Compilar, firmar y desplegar automáticamente aplicaciones Android en Google Play Console con GitHub Actions

Sloth255
Sloth255
·6 min read·1,219 words

Introducción

¿Sigues publicando tu aplicación Android haciendo clic manualmente en Generate Signed Bundle en Android Studio, y luego abriendo la Play Console para arrastrar y soltar el AAB?

Yo lo hacía así durante un tiempo, pero a medida que aumentaba la frecuencia de publicaciones, encontré los siguientes problemas:

  • Los builds se rompen por diferencias en el entorno local (incompatibilidades de versión de JDK, problemas con la caché de Gradle)
  • La gestión del Keystore se convierte en dependencia de una persona (solo existe en el Mac de un desarrollador específico)
  • El proceso de publicación depende de la documentación, lo que lleva a errores humanos

Para resolver todos estos problemas de una vez, automaticé todo el flujo —compilación, firma y carga a Google Play Console— con GitHub Actions. Aquí está el procedimiento paso a paso.

Visión general

El pipeline que construiremos luce así:

flowchart TD
  A["Push de git tag v1.0.0"]
  B["Se activa GitHub Actions"]
  C["Configurar JDK y restaurar cach\u00e9 de Gradle"]
  D["Restaurar Keystore desde Secrets"]
  E["Compilar y firmar AAB con ./gradlew bundleRelease"]
  F["Distribuir a pruebas internas via r0adkll/upload-google-play"]
  G["Google recibe, re-firma con la clave de firma de app y distribuye a usuarios"]

  A --> B --> C --> D --> E --> F --> G

Los términos "AAB" y "clave de subida" se explican en la siguiente sección.

3 conceptos clave que debes entender primero

Antes de entrar en los pasos concretos, aclaremos los términos clave que aparecen en este artículo. Si estos conceptos no están claros, la parte de Play App Signing puede resultar confusa.

APK vs AAB

APK (Android Package Kit) AAB (Android App Bundle)
Contenido Paquete universal que combina todos los ABI, densidades de pantalla y recursos de idioma en un solo archivo Artefacto de build antes de la optimización para dispositivos específicos
Tamaño del archivo Tiende a ser grande porque incluye todo Google Play genera Split APKs para la distribución, reduciendo el tamaño de descarga del usuario en ~15% de media
Uso principal Distribución directa / distribución interna / tiendas de terceros Distribución en Google Play Store (obligatorio para nuevas apps desde agosto de 2021)
Tarea Gradle ./gradlew assembleRelease ./gradlew bundleRelease

En pocas palabras: "APK = producto terminado que se instala en el dispositivo del usuario" y "AAB = plano que se envía a Google Play". Google Play recibe el AAB y ensambla el APK adecuado para el dispositivo de cada usuario.

Como este artículo apunta a la distribución en Play Store, usaremos AAB (bundleRelease). Para distribución interna u otros casos de uso que requieran APK, usa assembleRelease.

Clave de subida vs clave de firma de aplicación

Play App Signing utiliza dos tipos de claves de firma:

Tipo de clave ¿Quién gestiona? Propósito
Clave de subida (Upload Key) Desarrollador Firma el AAB al subirlo a la Play Console
Clave de firma de aplicación (App Signing Key) Google Firma el APK final distribuido a los usuarios desde Play Store

En otras palabras, la Play Console verifica si el AAB está firmado con la clave de subida correcta, por eso los desarrolladores necesitan firmar con su propia clave de subida = Keystore. Lo que Google hace por ti es solo la re-firma con la clave de firma de aplicación en el momento de la distribución.

flowchart TD
  Dev["Desarrollador"] -->|"Firma con clave de subida"| AAB["AAB"]
  AAB --> Play["Play Console"]
  Play --> Verify["Google verifica la clave de subida"]
  Verify --> Resign["Google re-firma con clave de firma de app"]
  Resign --> User["Usuario"]

Incluso con Play App Signing, el hecho de que "todavía necesitas crear tu propio Keystore" no ha cambiado. Lo que cambió es la red de seguridad: "puedes solicitar un restablecimiento si pierdes tu clave de subida".

Firma de depuración vs firma release

Cuando pulsaste Run por primera vez en Android Studio sin ninguna configuración, se construyó un APK y se ejecutó en tu dispositivo porque AGP (Android Gradle Plugin) genera automáticamente ~/.android/debug.keystore y firma automáticamente los builds de depuración.

Firma de depuración Firma release
Keystore ~/.android/debug.keystore (generado automáticamente por AGP) Creado y gestionado por ti (release.keystore en este artículo)
Contraseña Fija: android Definida por ti
Alias Fijo: androiddebugkey Definido por ti
Expiración ~30 años Definida por ti (~27 años en este artículo)
Uso Pruebas de desarrollo / ./gradlew assembleDebug Distribución Play Store / ./gradlew bundleRelease
Gestión Git No necesaria (OK si existe por máquina) Conservar de forma segura (puede restablecerse via Play App Signing si se pierde)

Dos propiedades importantes:

  1. La Play Console rechaza categóricamente los AAB firmados con firma de depuración
    → La CI necesita un release.keystore separado
  2. La firma de depuración y la firma release son completamente independientes
    → Agregar la configuración de firma release para CI no tiene ningún efecto en los llamados locales ./gradlew assembleDebug

El signingConfigs.create("release") condicional en las secciones 5 y 6 existe precisamente para aprovechar estas propiedades, creando un estado donde los desarrolladores sin la clave aún pueden ejecutar builds de depuración normalmente.

Requisitos previos

  • Aplicación Android (usando build.gradle o build.gradle.kts)
  • Repositorio GitHub
  • Cuenta de desarrollador de Google Play Console
  • La primera publicación de la app ya debe haberse realizado manualmente en la Play Console (la creación inicial a través de la API no es posible)
  • Play App Signing debe estar activado para la app objetivo

Para apps creadas después de agosto de 2021, Play App Signing se activa automáticamente, por lo que no se requieren pasos especiales. Para apps más antiguas creadas antes de eso con el método legacy, primero necesitas migrar a Play App Signing (tratado en la sección 7-4).

1. Crear la clave de subida (Keystore)

Genera el Keystore localmente para firmar el AAB. En una configuración de Play App Signing, esta clave funciona como la "clave de subida" (= tu identidad al enviar el AAB a la Play Console). La entrega final a los usuarios está firmada por la "clave de firma de aplicación" de Google, por lo que incluso si pierdes esta clave, puedes solicitar un restablecimiento desde la Play Console (detalles en 7-3).

Dicho esto, perderla en las operaciones diarias detendrá las publicaciones hasta que se complete el restablecimiento, así que guárdala de forma segura.

# macOS / Linux
keytool -genkey -v \
  -keystore release.keystore \
  -alias my-release-key \
  -keyalg RSA \
  -keysize 2048 \
  -validity 10000

En Windows (PowerShell / Símbolo del sistema), la continuación de línea con backslash no funciona, así que ejecútalo en una sola línea o usa el backtick ` para los saltos de línea en PowerShell.

keytool -genkey -v `
  -keystore release.keystore `
  -alias my-release-key `
  -keyalg RSA `
  -keysize 2048 `
  -validity 10000

Introduce la contraseña, nombre, organización, etc. de forma interactiva.

2. Codificar el Keystore en Base64

GitHub Secrets no puede almacenar binario directamente, así que conviértelo en una cadena Base64. El workflow elimina los saltos de línea con tr -d '\n\r' durante la decodificación, pero una salida sin saltos de línea es más segura.

macOS

base64 -i release.keystore -o release.keystore.base64
cat release.keystore.base64 | pbcopy

Linux

base64 -w 0 release.keystore > release.keystore.base64
cat release.keystore.base64 | xclip -selection clipboard

Windows (PowerShell)

[Convert]::ToBase64String([IO.File]::ReadAllBytes("release.keystore")) `
  | Set-Clipboard

Para escribir en un archivo:

[Convert]::ToBase64String([IO.File]::ReadAllBytes("release.keystore")) `
  | Out-File -Encoding ascii -NoNewline release.keystore.base64

Windows (Símbolo del sistema)

certutil funciona pero añade encabezados, pies de página y saltos de línea que deben eliminarse.

certutil -encode release.keystore release.keystore.base64

Elimina las líneas -----BEGIN CERTIFICATE----- y -----END CERTIFICATE----- y todos los saltos de línea del archivo de salida. Se recomienda usar PowerShell.

Copia la cadena del portapapeles y pégala en GitHub Secrets en el siguiente paso.

3. Crear una cuenta de servicio para Google Play Console

Las cargas automatizadas a la Play Console requieren una cuenta de servicio de Google Cloud.

3-1. Habilitar la API de Google Play Android Developer

Primero, habilita la API en el lado de GCP. Omitir este paso resultará en un error 403 más adelante, así que hazlo primero.

  1. Ve a Google Play Android Developer API
  2. Haz clic en Habilitar

3-2. Crear una cuenta de servicio en Google Cloud Console

  1. Ve a Google Cloud Console
  2. Crea un proyecto (o selecciona uno existente)
  3. En IAM y administración → Cuentas de servicio, crea una nueva. No asignes roles de IAM (los permisos se gestionan en el lado de la Play Console)
  4. Después de crearla, selecciona Agregar clave → Crear nueva clave → JSON y descarga el archivo JSON

3-3. Otorgar permisos en la Play Console

  1. Abre la Play Console
  2. Abre Usuarios y permisos, haz clic en Invitar nuevos usuarios
  3. Introduce la dirección de correo electrónico de la cuenta de servicio creada en el paso 3-2
  4. En la pestaña Permisos de la aplicación, agrega la app objetivo y otorga los siguientes permisos:
    • Publicar en tracks de prueba — necesario para la distribución al track de pruebas internas
    • Ver información de la app y descargar informes masivos — permiso de lectura

4. Registrar valores en GitHub Secrets

Desde Settings → Secrets and variables → Actions del repositorio, registra lo siguiente:

Nombre del Secret Valor
KEYSTORE_BASE64 Cadena Base64 del paso 2
KEYSTORE_PASSWORD Contraseña del Keystore
KEY_ALIAS Ej.: my-release-key
KEY_PASSWORD Contraseña de la clave
SERVICE_ACCOUNT_JSON Pega directamente el contenido del archivo JSON descargado en el paso 3-2

5. Configurar build.gradle para leer la información de firma desde variables de entorno

Configúralo para que la CI lea desde variables de entorno y el desarrollo local lea desde keystore.properties, y que incluso los entornos sin Keystore puedan ejecutar builds de depuración normalmente.

Edita app/build.gradle.kts de la siguiente manera:

import java.util.Properties
import java.io.FileInputStream

// --- Resolver información de firma ---
// Prioridad: variables de entorno (CI) > keystore.properties (local) > ninguna (solo build debug)
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties().apply {
    if (keystorePropertiesFile.exists()) {
        load(FileInputStream(keystorePropertiesFile))
    }
}

fun resolveSigning(key: String, envKey: String): String? =
    System.getenv(envKey) ?: keystoreProperties.getProperty(key)

val releaseStoreFile = resolveSigning("storeFile", "KEYSTORE_FILE")
val releaseStorePassword = resolveSigning("storePassword", "KEYSTORE_PASSWORD")
val releaseKeyAlias = resolveSigning("keyAlias", "KEY_ALIAS")
val releaseKeyPassword = resolveSigning("keyPassword", "KEY_PASSWORD")

val hasReleaseSigning = listOf(
    releaseStoreFile, releaseStorePassword, releaseKeyAlias, releaseKeyPassword
).all { !it.isNullOrBlank() }

android {
    signingConfigs {
        if (hasReleaseSigning) {
            create("release") {
                storeFile = file(releaseStoreFile!!)
                storePassword = releaseStorePassword
                keyAlias = releaseKeyAlias
                keyPassword = releaseKeyPassword
            }
        }
    }

    buildTypes {
        getByName("debug") {
            // Agregar applicationIdSuffix para que los builds release y debug
            // puedan coexistir en el mismo dispositivo
            applicationIdSuffix = ".debug"
            versionNameSuffix = "-debug"
            // signingConfig usa el debug.keystore auto-generado por AGP
        }
        getByName("release") {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
            // Solo asignar release signingConfig si la información de firma está disponible
            // (= los desarrolladores sin la clave aún pueden ejecutar assembleDebug)
            if (hasReleaseSigning) {
                signingConfig = signingConfigs.getByName("release")
            }
        }
    }
}

Puntos clave de este código:

  • signingConfigs.create("release") está protegido, por lo que la configuración release no se crea en entornos sin información de firma
  • El tipo de build debug usa el debug.keystore auto-generado por AGP y no se ve afectado en absoluto por la configuración de firma release
  • Las variables de entorno tienen la mayor prioridad, por lo que no se necesita ningún archivo keystore.properties en CI

6. Configuración adicional para no romper el desarrollo local

"Modifiqué build.gradle para la CI y ahora los builds de depuración locales están rotos" es un accidente común en configuraciones Android × CI. Para evitar esto, configura lo siguiente:

6-1. Crear keystore.properties (solo local)

local.properties está destinado a rutas SDK, y mezclar información de firma ahí hace las cosas confusas. Crear un archivo keystore.properties separado para la información de firma es el enfoque recomendado en la documentación oficial de Android.

Crea keystore.properties en la raíz del proyecto (un nivel por encima de app/):

storeFile=/Users/yourname/keys/release.keystore
storePassword=your-store-password
keyAlias=my-release-key
keyPassword=your-key-password

Este archivo nunca debe ir a Git, así que agrégalo al .gitignore (ver abajo).

6-2. Qué agregar al .gitignore

Las entradas relacionadas con firma a menudo se olvidan ya que el .gitignore generado por Android Studio no las incluye. Agrégalas explícitamente:

# Firma - nunca commitear
*.keystore
*.jks
keystore.properties
release.keystore.base64

# JSON de cuenta de servicio
*-service-account*.json
play-publisher.json

# Archivos de autenticación temporales generados por google-github-actions/auth (usando WIF)
gha-creds-*.json

# Creado por Android Studio pero verificar de todas formas
local.properties

local.properties generalmente lo agrega Android Studio, pero verifica si falta en proyectos más antiguos.

6-3. Coexistencia de Release y Debug con applicationIdSuffix

El snippet build.gradle.kts anterior incluye:

debug {
    applicationIdSuffix = ".debug"
}

Esto permite instalar simultáneamente la versión release de Play Store y una versión debug construida localmente en el mismo dispositivo. Sin esto, instalar un build de depuración sobreescribiría la versión de Play Store en tu dispositivo de prueba.

No está directamente relacionado con CI, pero una vez que empiezas a automatizar las publicaciones, ejecutarás con frecuencia la versión Play Store en tu dispositivo de prueba, así que recomiendo encarecidamente agregarlo.

6-4. Usar ~/.gradle/gradle.properties

Otra opción es escribir la información de firma en las propiedades Gradle del directorio home:

# macOS/Linux: ~/.gradle/gradle.properties
# Windows:     %USERPROFILE%\.gradle\gradle.properties

MYAPP_KEYSTORE_FILE=/Users/yourname/keys/release.keystore
MYAPP_KEYSTORE_PASSWORD=xxxx
MYAPP_KEY_ALIAS=my-release-key
MYAPP_KEY_PASSWORD=xxxx

En build.gradle.kts, léelo con findProperty("MYAPP_KEYSTORE_FILE") as String?.

La ventaja es que no hay información secreta en el directorio del proyecto en absoluto. La desventaja es que comunicar los pasos de configuración a nuevos desarrolladores requiere un poco más de esfuerzo. Mi recomendación: ~/.gradle/gradle.properties para proyectos en solitario, keystore.properties protegido por .gitignore para proyectos en equipo.

6-5. Probar builds release sin firmar en Android Studio

Aunque ./gradlew assembleDebug siempre funciona, es posible que quieras ejecutar ./gradlew assembleRelease sin firma (por ejemplo, para verificar la configuración de ProGuard para builds release).

En ese caso, cuando hasReleaseSigning es false, el tipo de build release no tendrá signingConfig, por lo que ejecutar assembleRelease produce un APK sin firmar (no se puede instalar en un dispositivo, pero se puede verificar el tamaño y mapping.txt).

7. Notas operacionales sobre Play App Signing

Con los pasos 1–6 completados, la configuración de Play App Signing ya está terminada. El release.keystore creado en el paso 1 funciona como clave de subida en el contexto de Play App Signing.

Esta sección contiene notas adicionales útiles en la operación.

7-1. Qué ocurre en la primera subida

Para una nueva app, cuando subes el AAB a la Play Console por primera vez:

  • El certificado usado en ese AAB se registra automáticamente como "certificado de clave de subida"
  • Google genera automáticamente una nueva "clave de firma de aplicación"
  • Play App Signing se activa

Por lo tanto, no se necesita configuración adicional de la Play Console – seguir los pasos de este artículo tal cual establecerá la configuración de Play App Signing.

flowchart TD
  CI["CI"] -->|"Firma con release.keystore"| AAB["AAB"]
  AAB --> Play["Play Console"]
  Play --> Verify["Google verifica el certificado de clave de subida"]
  Verify --> Resign["Google re-firma con clave de firma de app"]
  Resign --> Device["Dispositivo del usuario"]

7-2. Ir un paso más allá: Crear una clave de subida dedicada

Incluso para nuevas apps, puedes elegir "registrar la clave de subida por separado" en la Play Console.

  • Durante la primera subida, selecciona "Usar clave de firma de app generada por Google" en la Play Console
  • En la misma pantalla, sube el certificado de clave de subida por separado

En este caso, release.keystore contiene una "clave completamente dedicada solo a la subida".

Patrón estándar (7-1) Enfoque de claves separadas (7-2)
Contenido de release.keystore Clave de subida = clave que firmó el primer AAB Clave de subida pura
Si se compromete Puede recuperarse con solicitud de restablecimiento Puede recuperarse con solicitud de restablecimiento
Nivel de seguridad Suficiente Más estricto

Los pasos de CI son idénticos para ambos patrones. La única diferencia es cómo creas release.keystore.

7-3. Qué hacer si pierdes la clave de subida

Esta es la mayor ventaja de Play App Signing. Puedes solicitar un restablecimiento desde la Play Console.

  1. Crea un nuevo Keystore localmente

  2. Exporta el certificado en formato PEM

    keytool -export -rfc \
      -keystore release.keystore \
      -alias my-release-key \
      -file upload_certificate.pem
    
  3. En Play Console → Configuración → Integridad de la app → Firma de la app → Solicitar restablecimiento de clave de subida, sube el certificado

  4. El soporte de Google verifica (normalmente 1-2 días hábiles)

  5. Tras la aprobación, los AAB firmados con la nueva clave serán aceptados

Con el método legacy, "perder tu clave significaba que nunca podrías actualizar el mismo nombre de paquete"—un callejón sin salida permanente. Esta opción de recuperación por sí sola hace que valga la pena adoptar Play App Signing.

7-4. Migrar una app existente (legacy)

Para apps creadas antes de agosto de 2021 que aún no han migrado a Play App Signing, debes entregar la clave de firma actual a Google:

  1. Play Console → app objetivo → Configuración → Integridad de la app → Firma de la app

  2. Descarga la herramienta PEPK (Play Encrypt Private Key) proporcionada por Google

  3. Usa PEPK para exportar el Keystore actual en forma cifrada

    java -jar pepk.jar \
      --keystore=existing-release.keystore \
      --alias=my-release-key \
      --output=encrypted-key.zip \
      --include-cert \
      --rsa-aes-encryption \
      --encryption-key-path=public-key.pem
    
  4. Sube el archivo de salida (encrypted-key.zip) a la Play Console

  5. Después de la migración, crea y registra una nueva clave de subida si es necesario (continuar usando la clave existente como clave de subida también es posible)

public-key.pem es la clave pública que aparece en la pantalla de migración en la Play Console, guardada como archivo.

Después de la migración, los pasos 1–6 de este artículo se aplican directamente.

8. Escribir el workflow de GitHub Actions

Ahora el tema principal. Crea .github/workflows/release.yml:

name: Android Release

on:
  push:
    tags:
      - 'v*'

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: '17'

      - name: Cache Gradle packages
        uses: actions/cache@v4
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: |
            ${{ runner.os }}-gradle-

      - name: Grant execute permission for gradlew
        run: chmod +x ./gradlew

      - name: Decode Keystore
        run: |
          echo "${{ secrets.KEYSTORE_BASE64 }}" | tr -d '\n\r' | base64 --decode > ${{ github.workspace }}/release.keystore

      - name: Build Release AAB
        env:
          KEYSTORE_FILE: ${{ github.workspace }}/release.keystore
          KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
        run: ./gradlew bundleRelease --no-daemon

      - name: Upload AAB as artifact
        uses: actions/upload-artifact@v4
        with:
          name: release-aab
          path: app/build/outputs/bundle/release/app-release.aab

      - name: Deploy to Play Store (Internal Track)
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
          packageName: com.example.myapp
          releaseFiles: app/build/outputs/bundle/release/app-release.aab
          tracks: internal
          status: completed
          whatsNewDirectory: distribution/whatsnew

Puntos clave

  • El trigger de tag (v*) evita el despliegue accidental en cada push a main
  • La caché de Gradle reduce el tiempo de compilación (de ~6 minutos a ~2 minutos en ejecuciones posteriores)
  • La subida de artefacto sirve como respaldo — si la subida a la Play Console falla, el AAB puede descargarse manualmente desde la interfaz de Actions
  • tracks: internal puede cambiarse a alpha, beta, production, etc.
  • whatsNewDirectory es el directorio para notas de versión por idioma, ej. distribution/whatsnew/whatsnew-ja-JP

8-2. Configuración más segura: Usar Workload Identity Federation

En lugar de almacenar claves JSON de larga duración en GitHub Secrets, Workload Identity Federation (WIF) permite a GitHub Actions obtener credenciales temporales para acceder a la Play Console. Esto elimina el riesgo de filtración de claves JSON y se recomienda para producción.

Requisitos previos (lado GCP)

La configuración de WIF se realiza una sola vez. Sigue los pasos en google-github-actions/auth. Con este enfoque, no se genera ninguna clave JSON para la cuenta de servicio, por lo que el paso 4 en la sección 3-2 (crear/descargar clave) y registrar SERVICE_ACCOUNT_JSON en la sección 4 no son necesarios.

Configura con los siguientes comandos de gcloud. Reemplaza cada placeholder con tus valores reales:

Placeholder Descripción
${PROJECT_ID} ID del proyecto GCP
${GITHUB_ORG} Nombre de la organización GitHub o nombre de usuario
${REPO} Nombre del repositorio en formato org/repo
${SERVICE_ACCOUNT} Nombre de la cuenta de servicio del paso 3-2 (la parte antes de @ en el correo)
${WORKLOAD_IDENTITY_POOL_ID} ID completo del Pool obtenido en el paso 2
# 1. Crear Workload Identity Pool
gcloud iam workload-identity-pools create "github" \
  --project="${PROJECT_ID}" \
  --location="global" \
  --display-name="GitHub Actions Pool"

# 2. Obtener el ID completo del Pool
gcloud iam workload-identity-pools describe "github" \
  --project="${PROJECT_ID}" \
  --location="global" \
  --format="value(name)"
# Ejemplo: projects/123456789/locations/global/workloadIdentityPools/github

# 3. Crear Workload Identity Provider
#    --attribute-condition restringe a tokens solo de esta organización
gcloud iam workload-identity-pools providers create-oidc "my-repo" \
  --project="${PROJECT_ID}" \
  --location="global" \
  --workload-identity-pool="github" \
  --issuer-uri="https://token.actions.githubusercontent.com" \
  --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner" \
  --attribute-condition="assertion.repository_owner == '${GITHUB_ORG}'"

# 4. Otorgar roles/iam.workloadIdentityUser a la cuenta de servicio
#    principalSet's attribute.repository restringe a este repositorio específico
#    ${WORKLOAD_IDENTITY_POOL_ID} = ID completo obtenido en el paso 2
gcloud iam service-accounts add-iam-policy-binding \
  "${SERVICE_ACCOUNT}@${PROJECT_ID}.iam.gserviceaccount.com" \
  --project="${PROJECT_ID}" \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/${WORKLOAD_IDENTITY_POOL_ID}/attribute.repository/${REPO}"

Después de la configuración, anota el ID completo del Provider (projects/.../providers/...). Se usa en el campo workload_identity_provider del YAML de GitHub Actions.

YAML del workflow (versión WIF)

Agrega el permiso id-token: write al repositorio y un paso google-github-actions/auth:

name: Android Release

on:
  push:
    tags:
      - 'v*'

permissions:
  contents: read
  id-token: write          # Requerido para la adquisición del token WIF

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: '17'

      - name: Cache Gradle packages
        uses: actions/cache@v4
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: |
            ${{ runner.os }}-gradle-

      - name: Grant execute permission for gradlew
        run: chmod +x ./gradlew

      - name: Decode Keystore
        run: |
          echo "${{ secrets.KEYSTORE_BASE64 }}" | tr -d '\n\r' | base64 --decode > ${{ github.workspace }}/release.keystore

      - name: Build Release AAB
        env:
          KEYSTORE_FILE: ${{ github.workspace }}/release.keystore
          KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
        run: ./gradlew bundleRelease --no-daemon

      - name: Upload AAB as artifact
        uses: actions/upload-artifact@v4
        with:
          name: release-aab
          path: app/build/outputs/bundle/release/app-release.aab

      - name: Authenticate to Google Cloud (WIF)
        uses: google-github-actions/auth@v3
        id: auth
        with:
          workload_identity_provider: projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID
          service_account: SERVICE_ACCOUNT@PROJECT_ID.iam.gserviceaccount.com

      - name: Deploy to Play Store (Internal Track)
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJson: ${{ steps.auth.outputs.credentials_file_path }}
          packageName: com.example.myapp
          releaseFiles: app/build/outputs/bundle/release/app-release.aab
          tracks: internal
          status: completed
          whatsNewDirectory: distribution/whatsnew

Reemplaza workload_identity_provider y service_account con los valores confirmados en la consola de GCP después de la configuración de WIF. La única diferencia con la versión de clave JSON es pasar la ruta del archivo de credenciales a serviceAccountJson en lugar de serviceAccountJsonPlainText — todo lo demás es idéntico.

9. Crear el directorio de notas de versión

Crea distribution/whatsnew/ en la raíz del proyecto y coloca los archivos:

distribution/whatsnew/
├── whatsnew-en-US
└── whatsnew-ja-JP

Cada archivo contiene notas de versión en el idioma respectivo (hasta 500 caracteres).

10. Vamos a probarlo

Una vez aquí, solo queda hacer push de un tag:

git tag v1.0.0
git push origin v1.0.0

Abre la pestaña Actions en GitHub y el workflow comenzará a ejecutarse. Si tiene éxito, aparecerá un nuevo release en Pruebas internas en la Play Console.

11. Trampas comunes y soluciones

Algunos escollos en los que caí durante la operación real:

Olvidar incrementar versionCode

La Play Console no permite valores versionCode duplicados. Configurar la numeración automática usando el número de ejecución de GitHub Actions (GITHUB_RUN_NUMBER) previene accidentes. Usa el nombre del tag sin v para versionName.

android {
    defaultConfig {
        // En CI, usar GITHUB_RUN_NUMBER como versionCode (fallback a 1 en local)
        versionCode = (System.getenv("GITHUB_RUN_NUMBER")?.toInt() ?: 1) + 1000
        // En CI, usar el tag git (v1.0.0 → 1.0.0) como versionName
        versionName = System.getenv("GITHUB_REF_NAME")?.removePrefix("v") ?: "1.0.0-local"
    }
}

Retraso en la propagación de permisos de cuenta de servicio

Ejecutar Actions inmediatamente después de otorgar permisos puede resultar en 403 The caller does not have permission. Los cambios de permisos tardan algo de tiempo en propagarse, así que sé paciente en la primera ejecución.

Saltos de línea mezclados en la decodificación Base64

El comando base64 se comporta de manera diferente en distintos sistemas operativos:

  • macOS: Inserta automáticamente saltos de línea cada 76 caracteres → usa -i / -o para salida limpia
  • Linux: Inserta saltos de línea por defecto → usa -w 0 para sin saltos de línea
  • Windows (certutil): Encabezados, pies de página y saltos de línea todos incluidos → se requiere post-procesamiento; [Convert]::ToBase64String de PowerShell es más seguro

Siempre verifica que no haya saltos de línea antes de pegar en GitHub Secrets. Agregar tr -d '\n\r' al decodificar en CI es práctica estándar.

- name: Decode Keystore
  run: |
    echo "${{ secrets.KEYSTORE_BASE64 }}" | tr -d '\n\r' | base64 --decode > release.keystore

Conclusión

Con GitHub Actions, el proceso de publicación se convierte en:

"Solo crear un tag y hacer push"

Esto permite aumentar la frecuencia de publicaciones con una carga operacional casi nula.

Para equipos con varios desarrolladores, resolver únicamente el problema de "solo ese Mac puede hacer publicaciones" ya vale la pena adoptarlo. Si tienes las mismas dificultades en el desarrollo de apps Android, pruébalo.

Referencias

Android / Firma

Google Play Console

GitHub Actions

Gradle