Introducción
¿Todavía haces Archive → Distribute... manualmente desde Xcode cada vez que lanzas tu app iOS?
Este proceso consume mucho tiempo y tiende a crear dependencias de entorno — el famoso problema: "en mi Mac compila, pero en el de otro falla."
En este artículo vamos a construir un pipeline completo para compilar, firmar y subir automáticamente una app iOS a TestFlight con GitHub Actions.
Muchos tutoriales usan fastlane, pero aquí adoptamos un enfoque diferente: usar únicamente xcodebuild y xcrun altool, las herramientas nativas de Apple — sin fastlane. No es necesario introducir un entorno Ruby, el flujo de trabajo queda más simple y te libras de los problemas de compatibilidad de fastlane con las actualizaciones de Xcode.
Para la autenticación, usamos una clave API de App Store Connect — más segura y no afectada por la autenticación de dos factores — en lugar de Apple ID + contraseña.
Requisitos previos
- Inscripción en el Apple Developer Program (de pago)
- Registro de la app en App Store Connect
- Proyecto iOS subido a un repositorio GitHub
- Compilación local verificada en macOS / Xcode
Flujo general
El resumen del pipeline que vamos a construir es el siguiente:
- El workflow se activa al hacer push en
maino manualmente - Se prepara Xcode en el runner macOS de GitHub Actions
- Se importan temporalmente el certificado y el perfil de aprovisionamiento al llavero
- Se actualiza el número de build
- Se genera
.xcarchiveconxcodebuild archive - Se exporta a
.ipaconxcodebuild -exportArchive - Se sube a TestFlight con
xcrun altool - Se elimina el llavero temporal y se limpia
push → Actions activado → Instalar certificado → archive → export → altool → TestFlight
Paso 1: Preparar certificados y perfil de aprovisionamiento
Para realizar la firma de código en CI, los dos archivos siguientes se codifican en Base64 y se almacenan en GitHub Secrets.
1-1. Exportar el certificado de distribución (.p12)
Abre "Acceso a Llaveros.app" en tu Mac local, haz clic derecho en el certificado Apple Distribution o iPhone Distribution y expórtalo en formato .p12. Anota la contraseña que configures durante la exportación — la necesitarás más adelante.
Si no tienes certificado, créalo desde Certificates en el sitio Apple Developer.
1-2. Descargar el perfil de aprovisionamiento (.mobileprovision)
Descarga el perfil de aprovisionamiento para distribución en App Store desde Profiles en el sitio Apple Developer. Anota también el nombre del perfil (ej.: YourApp AppStore) — lo necesitarás en ExportOptions.plist.
1-3. Codificar en Base64
Ejecuta los siguientes comandos en el terminal para convertir los archivos a cadenas Base64.
# Certificado
base64 -i Certificates.p12 | pbcopy
# Perfil de aprovisionamiento
base64 -i YourApp_AppStore.mobileprovision | pbcopy
El contenido se copia al portapapeles con pbcopy y puede pegarse directamente en GitHub Secrets.
Paso 2: Generar una clave API de App Store Connect
Para evitar complicaciones con la autenticación de dos factores, usamos una clave API.
- Inicia sesión en App Store Connect
- Selecciona "Usuarios y acceso" → "Integrations" → "App Store Connect API"
- Genera una clave con el botón "+" (el rol debe ser App Manager o superior)
- Guarda el archivo
AuthKey_XXXXXXXXXX.p8descargado (no se puede volver a descargar — tenlo en cuenta) - Anota la Key ID y el Issuer ID que se muestran al generarla
Codifica también el archivo .p8 en Base64:
base64 -i AuthKey_XXXXXXXXXX.p8 | pbcopy
Paso 3: Registrar en GitHub Secrets
En Settings → Secrets and variables → Actions del repositorio, registra los siguientes Secrets:
| Nombre del Secret | Contenido |
|---|---|
BUILD_CERTIFICATE_BASE64 |
Cadena Base64 del certificado .p12 |
P12_PASSWORD |
Contraseña configurada al exportar el .p12 |
BUILD_PROVISION_PROFILE_BASE64 |
Cadena Base64 del .mobileprovision |
KEYCHAIN_PASSWORD |
Contraseña arbitraria para el llavero temporal |
APP_STORE_CONNECT_API_KEY_ID |
Key ID de la clave API |
APP_STORE_CONNECT_API_ISSUER_ID |
Issuer ID |
APP_STORE_CONNECT_API_KEY_BASE64 |
Cadena Base64 del .p8 |
Paso 4: Crear ExportOptions.plist
Este archivo de configuración es necesario para generar un .ipa desde un .xcarchive. Commitéalo en el repositorio como ios/ExportOptions.plist (no contiene información sensible).
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store</string>
<key>teamID</key>
<string>ABCD123456</string>
<key>signingStyle</key>
<string>manual</string>
<key>signingCertificate</key>
<string>Apple Distribution</string>
<key>provisioningProfiles</key>
<dict>
<key>com.example.yourapp</key>
<string>YourApp AppStore</string>
</dict>
<key>uploadSymbols</key>
<true/>
<key>uploadBitcode</key>
<false/>
</dict>
</plist>
Paso 5: Crear el workflow de GitHub Actions
Aquí va la parte principal. Crea .github/workflows/ios-testflight.yml.
name: iOS TestFlight Deploy
on:
push:
branches: [main]
workflow_dispatch:
env:
SCHEME: YourApp
WORKSPACE: YourApp.xcworkspace # Usar PROJECT para .xcodeproj
CONFIGURATION: Release
EXPORT_OPTIONS_PLIST: ios/ExportOptions.plist
jobs:
deploy:
runs-on: macos-14
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_15.4.app
- name: Install the Apple certificate and provisioning profile
env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_BASE64 }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
# Decodificar Base64
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH
# Crear llavero temporal
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Importar certificado
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" \
-A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: \
-k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
# Colocar el perfil de aprovisionamiento
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles
- name: Set build number
run: |
# Usar el número de ejecución de GitHub Actions como número de build (unicidad garantizada)
BUILD_NUMBER=${{ github.run_number }}
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" \
YourApp/Info.plist
echo "Build number set to $BUILD_NUMBER"
- name: Build archive
run: |
xcodebuild archive \
-workspace "$WORKSPACE" \
-scheme "$SCHEME" \
-configuration "$CONFIGURATION" \
-archivePath "$RUNNER_TEMP/YourApp.xcarchive" \
-destination "generic/platform=iOS" \
-allowProvisioningUpdates \
CODE_SIGNING_ALLOWED=YES \
| xcbeautify
- name: Export IPA
run: |
xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/YourApp.xcarchive" \
-exportOptionsPlist "$EXPORT_OPTIONS_PLIST" \
-exportPath "$RUNNER_TEMP/export" \
| xcbeautify
- name: Prepare API key for altool
env:
APP_STORE_CONNECT_API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
run: |
# altool lee desde ~/.appstoreconnect/private_keys/ o ./private_keys/
mkdir -p ~/.appstoreconnect/private_keys
echo -n "$APP_STORE_CONNECT_API_KEY_BASE64" | base64 --decode \
-o ~/.appstoreconnect/private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8
- name: Upload to TestFlight
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
run: |
xcrun altool --upload-app \
--type ios \
--file "$RUNNER_TEMP/export/YourApp.ipa" \
--apiKey "$APP_STORE_CONNECT_API_KEY_ID" \
--apiIssuer "$APP_STORE_CONNECT_API_ISSUER_ID"
- name: Clean up keychain and profiles
if: always()
run: |
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
rm -f ~/Library/MobileDevice/Provisioning\ Profiles/build_pp.mobileprovision
rm -rf ~/.appstoreconnect/private_keys
Puntos clave
Usar github.run_number como número de build
En lugar del increment_build_number de fastlane, aprovechamos el número de ejecución asignado automáticamente por GitHub Actions. Está garantizado como monotónicamente creciente y cumple el requisito de TestFlight de "no números de build duplicados". También funciona un número basado en fecha ($(date +%Y%m%d%H%M)).
Formatear logs con xcbeautify
Los logs brutos de xcodebuild son muy difíciles de leer, por lo que los canalizamos a través de xcbeautify. Viene preinstalado en los runners macos-14. Si no está disponible, añade brew install xcbeautify como un paso adicional.
Ubicación del archivo de clave API
xcrun altool detecta automáticamente la clave API desde cualquiera de los siguientes rutas. No hay flag para especificar la ruta explícitamente, por lo que el archivo debe colocarse en uno de los directorios designados:
./private_keys/AuthKey_<KEY_ID>.p8~/private_keys/AuthKey_<KEY_ID>.p8~/.private_keys/AuthKey_<KEY_ID>.p8~/.appstoreconnect/private_keys/AuthKey_<KEY_ID>.p8
Por qué usar un llavero temporal
Importar certificados en el llavero predeterminado del entorno CI es difícil de limpiar y plantea problemas de seguridad. La mejor práctica es crear un llavero por job y eliminarlo al finalizar.
Limpieza con if: always()
El llavero temporal y los archivos de claves siempre se eliminan aunque el build falle. Esto es especialmente importante al usar runners self-hosted.
Paso 6: Verificación
Haz push en la rama main o ejecútalo manualmente desde la pestaña Actions con Run workflow.
Se completa en unos 5-10 minutos y aparece un nuevo build en la pestaña TestFlight de App Store Connect. El paso del estado "Procesando" a completado puede tardar otros 10-30 minutos adicionales.
Opciones de gestión de certificados y configuraciones recomendadas
Hemos construido el pipeline usando el método de codificar .p12 en Base64 y registrarlo en GitHub Secrets, pero GitHub Actions ofrece otras opciones. Elige la más adecuada según el tamaño y las necesidades de tu equipo.
Opción 1: Almacenar como Base64 en Secrets (método de este artículo)
Funcionamiento: .p12 y .mobileprovision se codifican en Base64 y se guardan como cadenas en GitHub Secrets.
Ventajas
- Sin dependencias externas; todo funciona dentro de GitHub
- Configuración inicial más sencilla
.p12suele ser de pocos KB a decenas de KB, bien por debajo del límite de 64 KB de Secrets
Desventajas
- Actualización manual de Secrets al rotar certificados
- Gestionar varias apps multiplica los Secrets y se vuelve engorroso
- Los Secrets no pueden tratarse como archivos al estilo de Azure DevOps Secure Files
Ideal para: desarrolladores individuales, equipos pequeños de 1-3 personas, una sola app
Opción 2: Commitear archivos cifrados en el repositorio
Funcionamiento: .p12 se cifra con GPG u OpenSSL y se commitea en el repositorio; solo la frase de contraseña de descifrado se almacena en Secrets.
# Cifrar localmente
gpg --symmetric --cipher-algo AES256 Certificates.p12
# → Commitear Certificates.p12.gpg en el repositorio
En el workflow:
- name: Decrypt certificate
run: |
gpg --quiet --batch --yes --decrypt \
--passphrase="$CERT_PASSPHRASE" \
--output $RUNNER_TEMP/cert.p12 \
secrets/Certificates.p12.gpg
env:
CERT_PASSPHRASE: ${{ secrets.CERT_PASSPHRASE }}
Ventajas
- Gestionable como archivo; historial de rotaciones rastreable via Git
- Sin límite de tamaño de Secrets
- Lo más cercano a Azure DevOps Secure Files
Desventajas
- Si el repositorio se ve comprometido, la fortaleza de la frase de contraseña es la última línea de defensa
- Los archivos
.gpgen el repositorio pueden resultar visualmente molestos para algunos
Ideal para: migración desde Azure DevOps, gestión como archivos deseada, repositorios privados
Opción 3: fastlane match
Funcionamiento: Los certificados cifrados se almacenan en un repositorio privado dedicado (o S3/GCS), y fastlane se encarga automáticamente de recuperarlos, instalarlos y descifrarlos.
Ventajas
- Gestión centralizada de certificados para múltiples apps y entornos (dev/adhoc/appstore)
- Los nuevos miembros del equipo configuran su entorno local con un solo
fastlane match - Soporte para regeneración automática de certificados
Desventajas
- Requiere Ruby y fastlane
- Posibles problemas de compatibilidad de fastlane con actualizaciones de Xcode
- Va en contra del enfoque sin fastlane de este artículo
Ideal para: gestión de múltiples apps, equipos iOS de 5+ personas, rotación frecuente de certificados
Opción 4: AWS Parameter Store + Integración OIDC
Funcionamiento: Las cadenas Base64 de .p12 y las contraseñas se almacenan como SecureString en AWS Systems Manager Parameter Store y se recuperan desde GitHub Actions via OIDC. No se necesitan credenciales de larga duración — una gran ventaja.
Parameter Store vs. Secrets Manager
AWS tiene dos servicios similares; aquí una comparación:
| Elemento | Parameter Store | Secrets Manager |
|---|---|---|
| Coste (Estándar) | Gratuito | $0,40/secret/mes + tarifas API |
| Coste (Advanced) | $0,05/10.000 llamadas API | Igual |
| Límite de tamaño | Estándar 4 KB / Advanced 8 KB | 64 KB |
| Rotación automática | No | Sí |
| Cifrado KMS | Via tipo SecureString | Nativo |
Los archivos .p12 codificados en Base64 suelen tener varias decenas de KB hasta 30 KB, lo cual es el punto de decisión. Si cabe en 8 KB, Parameter Store es significativamente más barato; si lo supera, Secrets Manager o el enfoque híbrido con S3 descrito a continuación es más apropiado.
Manejo de archivos de más de 8 KB
Si el tamaño es demasiado grande, una configuración híbrida que almacene el .p12 cifrado en S3 y solo guarde la clave S3 y la contraseña de descifrado en Parameter Store es prácticamente limpia. Combina la simplicidad de Parameter Store con la libertad de tamaño de S3.
Configuración en AWS
Primero, crea un proveedor de identidad OIDC para GitHub Actions en IAM (omite si ya está creado):
- Provider URL:
https://token.actions.githubusercontent.com - Audience:
sts.amazonaws.com
Luego, almacena los parámetros en Parameter Store (ejemplo con AWS CLI):
# Cuerpo del certificado
aws ssm put-parameter \
--name /ios/yourapp/dist-cert-base64 \
--type SecureString \
--value "$(base64 -i Certificates.p12)"
# Contraseña del .p12
aws ssm put-parameter \
--name /ios/yourapp/p12-password \
--type SecureString \
--value "your-p12-password"
# Perfil de aprovisionamiento
aws ssm put-parameter \
--name /ios/yourapp/provisioning-profile-base64 \
--type SecureString \
--value "$(base64 -i YourApp_AppStore.mobileprovision)"
# Clave API de App Store Connect (.p8)
aws ssm put-parameter \
--name /ios/yourapp/asc-api-key-base64 \
--type SecureString \
--value "$(base64 -i AuthKey_XXXXXXXXXX.p8)"
Crea un rol IAM para GitHub Actions y restringe el Assume Role a repositorios y ramas específicos en la Trust Policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main"
}
}
}
]
}
Política de permisos mínimos — solo GetParameter para la ruta objetivo:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["ssm:GetParameter", "ssm:GetParameters"],
"Resource": "arn:aws:ssm:ap-northeast-1:123456789012:parameter/ios/yourapp/*"
},
{
"Effect": "Allow",
"Action": "kms:Decrypt",
"Resource": "arn:aws:kms:ap-northeast-1:123456789012:key/your-key-id"
}
]
}
Workflow de GitHub Actions
No olvides configurar id-token: write en el bloque permissions. Sin esto, no se puede obtener el token OIDC.
permissions:
id-token: write # Necesario para la obtención del token OIDC
contents: read
jobs:
deploy:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-ios-deploy
aws-region: ap-northeast-1
- name: Fetch secrets from Parameter Store
env:
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
# Obtener desde Parameter Store (SecureString requiere --with-decryption)
aws ssm get-parameter \
--name /ios/yourapp/dist-cert-base64 \
--with-decryption \
--query Parameter.Value --output text | base64 --decode > $CERTIFICATE_PATH
aws ssm get-parameter \
--name /ios/yourapp/provisioning-profile-base64 \
--with-decryption \
--query Parameter.Value --output text | base64 --decode > $PP_PATH
P12_PASSWORD=$(aws ssm get-parameter \
--name /ios/yourapp/p12-password \
--with-decryption \
--query Parameter.Value --output text)
# Crear llavero e importar certificado
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" \
-A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: \
-k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles
# Los pasos de archive / export / altool son iguales al Paso 5
Ventajas
- Coste casi nulo: los parámetros estándar son completamente gratuitos; Advanced es extremadamente barato
- Sin credenciales de larga duración con OIDC: no hay que almacenar claves de acceso en GitHub (mayor ventaja)
- Logs de auditoría con CloudTrail: registro completo de quién accedió al certificado y cuándo
- Control granular de permisos con IAM/KMS: roles separados para entornos dev y prod
- Compartible entre múltiples sistemas CI/CD: el mismo certificado puede usarse desde GitHub Actions, CodeBuild, Jenkins y desarrollo local
- Historial de versiones: posibilidad de volver a versiones anteriores
Desventajas
- Cuenta AWS requerida (alto coste de configuración inicial para equipos que no la usan)
- Workaround necesario si
.p12supera 8 KB (usar S3 en paralelo o Secrets Manager) - Cierta curva de aprendizaje para la configuración OIDC (solo una vez)
Ideal para: equipos que ya usan AWS, requisitos de logs de auditoría, referenciar el mismo certificado desde múltiples herramientas CI/CD, gestión de calidad enterprise a bajo coste
Opción 5: AWS Secrets Manager + Integración OIDC
Funcionamiento: .p12 se almacena directamente como binario (SecretBinary) en AWS Secrets Manager y se recupera desde GitHub Actions via OIDC. A diferencia de Parameter Store, la característica clave es poder manipularlo como archivo sin preocuparse por la conversión Base64.
Diferencias con Parameter Store
| Elemento | Parameter Store | Secrets Manager |
|---|---|---|
| Formato de almacenamiento | Solo cadenas (binario requiere Base64) | Binario directo OK (tipo SecretBinary) |
| Límite de tamaño | 4 KB (Estándar) / 8 KB (Advanced) | 64 KB |
| Coste | Gratuito a muy barato | $0,40/mes/secret + tarifas API |
| Rotación automática | No | Sí (integración Lambda) |
| Dirección de uso | Valores de configuración, secretos pequeños | Secretos más grandes, operaciones en producción |
Si .p12 supera 8 KB, o si priorizas la facilidad de manejo como archivo, Secrets Manager es más directo. Para 1-2 certificados, el coste mensual está por debajo de un dólar.
Configuración en AWS
La configuración del proveedor de identidad OIDC se comparte con la Opción 4, así que la omitimos aquí.
Registra en Secrets Manager como binario usando el prefijo fileb://, que indica al AWS CLI que envíe el archivo tal cual como binario:
# Cuerpo del certificado (se puede registrar directamente como binario)
aws secretsmanager create-secret \
--name ios/yourapp/dist-cert \
--secret-binary fileb://Certificates.p12
# Perfil de aprovisionamiento como binario
aws secretsmanager create-secret \
--name ios/yourapp/provisioning-profile \
--secret-binary fileb://YourApp_AppStore.mobileprovision
# Clave API App Store Connect (.p8) como binario
aws secretsmanager create-secret \
--name ios/yourapp/asc-api-key \
--secret-binary fileb://AuthKey_XXXXXXXXXX.p8
# Contraseñas como cadena
aws secretsmanager create-secret \
--name ios/yourapp/p12-password \
--secret-string "your-p12-password"
Para actualizaciones, usa update-secret:
aws secretsmanager update-secret \
--secret-id ios/yourapp/dist-cert \
--secret-binary fileb://Certificates_new.p12
La política de permisos del rol IAM permite Secrets Manager en lugar de Parameter Store:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue"],
"Resource": "arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:ios/yourapp/*"
},
{
"Effect": "Allow",
"Action": "kms:Decrypt",
"Resource": "arn:aws:kms:ap-northeast-1:123456789012:key/your-key-id"
}
]
}
Workflow de GitHub Actions
Los valores recuperados via SecretBinary se devuelven codificados en Base64 en la respuesta de la API, así que decodifica con base64 --decode y escribe en un archivo:
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-ios-deploy
aws-region: ap-northeast-1
- name: Fetch certificates from Secrets Manager
env:
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
# SecretBinary se devuelve codificado en Base64; decodificar y escribir en archivo
aws secretsmanager get-secret-value \
--secret-id ios/yourapp/dist-cert \
--query SecretBinary --output text | base64 --decode > $CERTIFICATE_PATH
aws secretsmanager get-secret-value \
--secret-id ios/yourapp/provisioning-profile \
--query SecretBinary --output text | base64 --decode > $PP_PATH
# SecretString se puede recuperar directamente
P12_PASSWORD=$(aws secretsmanager get-secret-value \
--secret-id ios/yourapp/p12-password \
--query SecretString --output text)
# Colocar la clave API (.p8) en la ruta que espera altool
API_KEY_ID="${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}"
mkdir -p ~/.appstoreconnect/private_keys
aws secretsmanager get-secret-value \
--secret-id ios/yourapp/asc-api-key \
--query SecretBinary --output text | base64 --decode \
> ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8
# Crear llavero e importar certificado
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" \
-A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: \
-k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles
# Los pasos de archive / export / altool son iguales al Paso 5
Ventajas
.p12se puede registrar y recuperar directamente como archivo (lo más cercano a Azure DevOps Secure Files)- Soporte hasta 64 KB: sin preocupaciones por límites de tamaño
- Sin credenciales de larga duración con OIDC: mismos beneficios de seguridad que la Opción 4
- Logs de auditoría con CloudTrail disponibles de forma nativa
- Rotación automática: posible via Lambda (aunque la integración con la API de Apple tiene alto coste de construcción)
- Control granular de permisos con IAM
Desventajas
- Genera coste: ~$0,40/secret/mes + tarifas API; unos pocos dólares al mes con 3-5 certificados
- Cuenta AWS requerida
- Curva de aprendizaje para configuración OIDC (compartida con Opción 4)
Ideal para: prioridad a la facilidad como archivo, archivos .p12 grandes, muchos archivos de certificados en múltiples apps, rotación automática considerada a futuro, experiencia equivalente a Azure DevOps Secure Files deseada
Opción 6: Otros gestores de secretos en la nube
Para equipos que no usan AWS, las siguientes opciones funcionan con el mismo concepto. Todas soportan integración OIDC:
| Servicio | Coste | Límite de tamaño | Soporte binario |
|---|---|---|---|
| Azure Key Vault | $0,03/10.000 ops | 25 KB | ○ (tipo Certificate) |
| Google Secret Manager | $0,06/mes/secret | 64 KB | ○ |
| HashiCorp Vault (OSS) | Solo costes de servidor | Sin límite | ○ |
Azure Key Vault en particular tiene un tipo Certificate con soporte oficial para importación/exportación directa en formato .p12 — ideal para equipos que usan el ecosistema Azure.
Opción 7: Protección adicional con GitHub Environments
Esta opción puede combinarse con cualquiera de las anteriores. Configurando GitHub Environments, puedes restringir el acceso a certificados solo a despliegues desde ramas específicas o añadir un paso de aprobación manual:
jobs:
deploy:
runs-on: macos-14
environment: production # ← Los Secrets vinculados a este Environment son utilizables
Al poder aislar los Secrets por Environment, se evita usar accidentalmente certificados de distribución de producción para builds de desarrollo.
Configuraciones recomendadas por escala
| Escala | Configuración recomendada |
|---|---|
| Desarrollador individual / app hobby | Opción 1 (Base64 + Secrets) — la simplicidad gana |
| Startup / equipo pequeño (1-3 personas) | Opción 1 u Opción 2 (archivos cifrados) |
| Equipo mediano (varias apps o 5+ personas) | Opción 3 (fastlane match) u Opción 5 (Secrets Manager) |
| Usuarios AWS, conscientes del coste | Opción 4 (Parameter Store + OIDC) |
| Usuarios AWS, orientados a operaciones | Opción 5 (Secrets Manager + OIDC) — amigable con archivos |
| Enterprise / requisitos de auditoría | Opción 4 o 5 + Opción 7 (Environments) combinadas |
| Migración desde Azure DevOps | Opción 2 (archivos cifrados) u Opción 5 (Secrets Manager) — lo más cercano a Secure Files |
Problemas comunes y soluciones
No signing certificate "iOS Distribution" found
Ocurre frecuentemente cuando la importación del certificado en el llavero falla o falta set-key-partition-list. Añade security find-identity -v -p codesigning $KEYCHAIN_PATH como paso para verificar si el certificado es visible — eso facilita el diagnóstico.
error: exportArchive: "YourApp.app" requires a provisioning profile
El perfil de aprovisionamiento no se colocó correctamente, o el Bundle ID y el nombre del perfil en provisioningProfiles en ExportOptions.plist no coinciden. El nombre del perfil es el "nombre mostrado en el sitio Apple Developer", no el nombre del archivo.
altool: Invalid API key
Suele deberse a saltos de línea o espacios extra mezclados en el archivo .p8 codificado en Base64. Genera con base64 -i file.p8 | pbcopy y pega directamente. Si el nombre del archivo no sigue la convención AuthKey_<KEY_ID>.p8, altool no lo reconocerá — constrúyelo con precisión mediante variables de entorno.
xcodebuild: error: The operation couldn't be completed. No such file or directory
Ocurre cuando se usa .xcworkspace pero se especifica -project (o viceversa). Si usas un workspace de CocoaPods o Swift Package Manager, especifica -workspace.
Parameter Store: ParameterNotFound o AccessDenied
Errores durante la integración AWS. Verifica en este orden:
- ¿Hay errores tipográficos en la ruta del parámetro (jerarquía como
/ios/yourapp/...)? - ¿Está
ssm:GetParameterpermitido para la ruta objetivo en la política de permisos del rol IAM? - Para SecureString: ¿Se incluye el flag
--with-decryption? - ¿Tiene el rol IAM el permiso
kms:Decryptpara la clave KMS? - ¿La condición
suben la Trust Policy coincide con la rama de ejecución del workflow?
Mejoras adicionales
Hemos construido el pipeline con una configuración mínima. Aquí algunas extensiones posibles:
- Notificaciones Slack: Integrar
slackapi/slack-github-actionpara notificar automáticamente cuando se complete la subida. - Notas de versión automáticas: Extraer cambios de
git loge introducirlos automáticamente en el campo "What to Test" de TestFlight (difícil con altool solo; requiere llamar directamente a la App Store Connect API). - Trigger por push de tag: Disparar con un push de tag
v*en lugar de push enmainpara un flujo de lanzamiento más intencional. - Ejecutar pruebas unitarias primero: Ejecutar
xcodebuild testantes del archive y detener el despliegue si las pruebas fallan.
Conclusión
Una configuración sin fastlane tiene las ventajas de ser intuitiva y fácil de entender, no requerir un entorno Ruby y no verse afectada por problemas de compatibilidad de fastlane con las actualizaciones de Xcode. A cambio, cosas como la gestión del número de build deben manejarse manualmente, y al escalar a la gestión de múltiples apps, la carga de gestión aumenta. En ese punto, migrar a fastlane match o AWS Secrets Manager / Parameter Store es una opción práctica.
El mayor obstáculo en la configuración inicial es sin duda la gestión de certificados — esperamos que este artículo y la sección de "Opciones de gestión de certificados" te ayuden a superar ese escollo.
