Compilar una app móvil manualmente es un proceso tedioso. En Android necesitas abrir Android Studio, esperar que Gradle sincronice, firmar el APK con tu keystore y exportarlo. En iOS la situación es peor: abrir Xcode, gestionar certificados y provisioning profiles, compilar, archivar y firmar. Multiplicar esto por cada release o cada fix urgente convierte el proceso en un cuello de botella real.
Con GitHub Actions puedes automatizar todo esto. Cada push a una rama específica o cada tag nuevo dispara un pipeline que compila, firma y sube los artefactos listos para distribuir. El equipo deja de depender de "la máquina de Juan que tiene el keystore" y el proceso se vuelve reproducible y auditable.
En este artículo voy a cubrir la configuración completa para proyectos que usan Capacitor como bridge nativo, aunque los conceptos aplican a cualquier proyecto React Native o nativo puro con ajustes mínimos.
Antes de compilar el proyecto nativo, Capacitor necesita sincronizar el código web con las plataformas nativas. Esto significa que tu pipeline debe:
npm run build)npx cap sync para copiar el bundle a android/ e ios/Este paso es crítico y muchos pipelines fallan por omitirlo. El cap sync no solo copia archivos, también actualiza plugins nativos y sus dependencias.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build web assets
run: npm run build
- name: Sync Capacitor
run: npx cap sync
Android necesita el JDK y las herramientas del SDK. Los runners de GitHub (ubuntu-latest) ya traen Java preinstalado, pero conviene fijar la versión para evitar sorpresas:
- name: Setup Java JDK
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
cache: 'gradle'
La opción cache: 'gradle' es importante. Gradle descarga decenas de dependencias en cada build, y sin cache un build que debería tomar 3 minutos puede tomar 12.
Nunca debes subir tu keystore al repositorio. En su lugar, lo codificas en base64 y lo guardas como secret de GitHub:
# En tu maquina local
base64 -w 0 mi-app-release.keystore > keystore-base64.txt
Luego creas estos secrets en GitHub:
ANDROID_KEYSTORE_BASE64: el contenido del archivo base64ANDROID_KEYSTORE_PASSWORD: password del keystoreANDROID_KEY_ALIAS: alias de la keyANDROID_KEY_PASSWORD: password de la keyEn el workflow decodificas el keystore antes de compilar:
- name: Decode keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > android/app/release.keystore
- name: Build signed APK
working-directory: android
run: |
./gradlew assembleRelease \
-Pandroid.injected.signing.store.file=$PWD/app/release.keystore \
-Pandroid.injected.signing.store.password=${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \
-Pandroid.injected.signing.key.alias=${{ secrets.ANDROID_KEY_ALIAS }} \
-Pandroid.injected.signing.key.password=${{ secrets.ANDROID_KEY_PASSWORD }}
Si prefieres generar un AAB (Android App Bundle) para Google Play en lugar de un APK, reemplaza assembleRelease por bundleRelease. El AAB genera paquetes optimizados por dispositivo pero no se puede instalar directamente — necesitas bundletool para testear localmente.
Además del cache integrado de setup-java, puedes cachear el directorio .gradle del proyecto:
- name: Cache Gradle wrapper
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-
iOS requiere un runner macOS. No hay forma de compilar con Xcode en Linux. Esto tiene implicaciones de costo: desde el cambio de precios de enero de 2026, GitHub factura los runners por minuto en dólares, y un runner macOS cuesta 0,062 USD por minuto frente a 0,006 USD de un Linux de 2 núcleos, es decir, alrededor de diez veces más caro.
build-ios:
runs-on: macos-14
Usar macos-14 te da un runner con Apple Silicon (M1), que es significativamente más rápido para compilar que los runners Intel (macos-13). Además, las versiones recientes de Xcode solo están disponibles en runners ARM.
Esta es la parte más compleja de todo el pipeline. Apple requiere un certificado de distribución (un archivo .p12) y un provisioning profile (.mobileprovision) para firmar la app.
Debes crear estos secrets:
IOS_CERTIFICATE_BASE64: certificado .p12 en base64IOS_CERTIFICATE_PASSWORD: password del certificadoIOS_PROVISION_PROFILE_BASE64: provisioning profile en base64La instalación en el runner requiere crear un keychain temporal:
- name: Install Apple certificate and provisioning profile
env:
CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }}
CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
PROVISION_PROFILE_BASE64: ${{ secrets.IOS_PROVISION_PROFILE_BASE64 }}
run: |
# Crear variables
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
# Decodificar archivos
echo -n "$CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
echo -n "$PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH
# Crear keychain temporal
security create-keychain -p "" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "" $KEYCHAIN_PATH
# Importar certificado
security import $CERTIFICATE_PATH -P "$CERTIFICATE_PASSWORD" \
-A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: \
-k "" $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
# Instalar provisioning profile
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles
Con los certificados instalados, el build usa xcodebuild:
- name: Install CocoaPods dependencies
working-directory: ios/App
run: pod install
- name: Build iOS archive
working-directory: ios/App
run: |
xcodebuild archive \
-workspace App.xcworkspace \
-scheme App \
-configuration Release \
-archivePath $RUNNER_TEMP/App.xcarchive \
-destination 'generic/platform=iOS' \
CODE_SIGN_IDENTITY="iPhone Distribution" \
PROVISIONING_PROFILE_SPECIFIER="Mi App Distribution"
- name: Export IPA
run: |
xcodebuild -exportArchive \
-archivePath $RUNNER_TEMP/App.xcarchive \
-exportOptionsPlist ios/App/ExportOptions.plist \
-exportPath $RUNNER_TEMP/output
El archivo ExportOptions.plist define el método de exportación (app-store, ad-hoc, enterprise). Para distribución vía TestFlight necesitas app-store.
CocoaPods puede tomar varios minutos en instalar. El cache reduce esto drásticamente:
- name: Cache CocoaPods
uses: actions/cache@v4
with:
path: ios/App/Pods
key: pods-${{ hashFiles('ios/App/Podfile.lock') }}
restore-keys: pods-
En la práctica, no quieres compilar en cada push a cualquier rama. La estrategia más común es compilar solo cuando se crea un tag de versión:
name: Build Mobile Apps
on:
push:
tags:
- 'v*'
jobs:
build-web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build
- run: npx cap sync
- uses: actions/upload-artifact@v4
with:
name: web-assets
path: |
android/
ios/
build-android:
needs: build-web
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: web-assets
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
cache: 'gradle'
- name: Decode keystore
run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > android/app/release.keystore
- name: Build APK
working-directory: android
run: |
./gradlew assembleRelease \
-Pandroid.injected.signing.store.file=$PWD/app/release.keystore \
-Pandroid.injected.signing.store.password=${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \
-Pandroid.injected.signing.key.alias=${{ secrets.ANDROID_KEY_ALIAS }} \
-Pandroid.injected.signing.key.password=${{ secrets.ANDROID_KEY_PASSWORD }}
- uses: actions/upload-artifact@v4
with:
name: android-apk
path: android/app/build/outputs/apk/release/*.apk
build-ios:
needs: build-web
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: web-assets
- name: Install certificates
run: |
# (bloque completo de certificados mostrado arriba)
echo "Certificates installed"
- name: Install CocoaPods
working-directory: ios/App
run: pod install
- name: Build and archive
working-directory: ios/App
run: |
xcodebuild archive \
-workspace App.xcworkspace \
-scheme App \
-configuration Release \
-archivePath $RUNNER_TEMP/App.xcarchive \
-destination 'generic/platform=iOS'
- name: Export IPA
run: |
xcodebuild -exportArchive \
-archivePath $RUNNER_TEMP/App.xcarchive \
-exportOptionsPlist ios/App/ExportOptions.plist \
-exportPath $RUNNER_TEMP/output
- uses: actions/upload-artifact@v4
with:
name: ios-ipa
path: ${{ runner.temp }}/output/*.ipa
Una convención que funciona bien es usar Semantic Versioning con tags de git:
git tag v1.2.0
git push origin v1.2.0
Dentro del workflow, puedes extraer la versión del tag para inyectarla en la app:
- name: Extract version from tag
id: version
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
Luego usas ${{ steps.version.outputs.VERSION }} para actualizar build.gradle o Info.plist antes de compilar. Esto garantiza que la versión del binario coincida con el tag de git.
Firma de Android falla con "keystore was tampered with": Generalmente ocurre cuando el base64 se corrompió al copiar. Verifica que usaste base64 -w 0 (sin line wraps) al codificar.
Xcode version mismatch: El runner puede tener una versión de Xcode diferente a la que esperas. Fija la versión con:
- name: Select Xcode version
run: sudo xcode-select -s /Applications/Xcode_15.4.app
Puedes ver las versiones disponibles en la documentación de GitHub sobre runners macOS.
CocoaPods falla con errores de CDN: A veces el CDN de CocoaPods tiene problemas. Agrega reintentos o usa --repo-update:
- run: pod install --repo-update || pod install --repo-update
El build de Android es lento: Además del cache de Gradle, agrega estas propiedades al gradle.properties del proyecto:
org.gradle.daemon=true
org.gradle.parallel=true
org.gradle.caching=true
Provisioning profile expira: Los provisioning profiles tienen un año de vigencia. Configura un recordatorio o usa fastlane match para automatizar la renovación.
Automatizar builds móviles con GitHub Actions elimina fricción del proceso de release y reduce errores humanos. El costo de los runners macOS es el principal inconveniente, pero compilar solo en tags mantiene el gasto controlado. Una vez configurado, el flujo se vuelve tan simple como git tag v1.3.0 && git push origin v1.3.0 — y en minutos tienes APK e IPA listos para distribuir.