Compilar una app movil 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 situacion 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 especifica o cada tag nuevo dispara un pipeline que compila, firma y sube los artefactos listos para distribuir. El equipo deja de depender de "la maquina de Juan que tiene el keystore" y el proceso se vuelve reproducible y auditable.
En este articulo voy a cubrir la configuracion completa para proyectos que usan Capacitor como bridge nativo, aunque los conceptos aplican a cualquier proyecto React Native o nativo puro con ajustes minimos.
Antes de compilar el proyecto nativo, Capacitor necesita sincronizar el codigo 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 critico y muchos pipelines fallan por omitirlo. El cap sync no solo copia archivos, tambien 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 version para evitar sorpresas:
- name: Setup Java JDK
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
cache: 'gradle'
La opcion cache: 'gradle' es importante. Gradle descarga decenas de dependencias en cada build, y sin cache un build que deberia 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.
Ademas 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: los runners macOS en GitHub Actions consumen minutos a una tasa 10x mayor que Linux.
build-ios:
runs-on: macos-14
Usar macos-14 te da un runner con Apple Silicon (M1), que es significativamente mas rapido para compilar que los runners Intel (macos-13). Ademas, las versiones recientes de Xcode solo estan disponibles en runners ARM.
Esta es la parte mas compleja de todo el pipeline. Apple requiere un certificado de distribucion (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 instalacion 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 metodo de exportacion (app-store, ad-hoc, enterprise). Para distribucion via TestFlight necesitas app-store.
CocoaPods puede tomar varios minutos en instalar. El cache reduce esto drasticamente:
- name: Cache CocoaPods
uses: actions/cache@v4
with:
path: ios/App/Pods
key: pods-${{ hashFiles('ios/App/Podfile.lock') }}
restore-keys: pods-
En la practica, no quieres compilar en cada push a cualquier rama. La estrategia mas comun es compilar solo cuando se crea un tag de version:
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 convencion 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 version 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 version del binario coincida con el tag de git.
Firma de Android falla con "keystore was tampered with": Generalmente ocurre cuando el base64 se corrompio al copiar. Verifica que usaste base64 -w 0 (sin line wraps) al codificar.
Xcode version mismatch: El runner puede tener una version de Xcode diferente a la que esperas. Fija la version con:
- name: Select Xcode version
run: sudo xcode-select -s /Applications/Xcode_15.4.app
Puedes ver las versiones disponibles en la documentacion 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: Ademas 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 ano de vigencia. Configura un recordatorio o usa fastlane match para automatizar la renovacion.
Automatizar builds moviles con GitHub Actions elimina friccion 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.