iOSGitHub ActionsTestFlightXcodeCI/CDCode Signing

Compilar, firmar y subir automáticamente apps iOS a TestFlight con GitHub Actions (sin fastlane)

Sloth255
Sloth255
·14 min read·3,004 words

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:

  1. El workflow se activa al hacer push en main o manualmente
  2. Se prepara Xcode en el runner macOS de GitHub Actions
  3. Se importan temporalmente el certificado y el perfil de aprovisionamiento al llavero
  4. Se actualiza el número de build
  5. Se genera .xcarchive con xcodebuild archive
  6. Se exporta a .ipa con xcodebuild -exportArchive
  7. Se sube a TestFlight con xcrun altool
  8. 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.

  1. Inicia sesión en App Store Connect
  2. Selecciona "Usuarios y acceso" → "Integrations" → "App Store Connect API"
  3. Genera una clave con el botón "+" (el rol debe ser App Manager o superior)
  4. Guarda el archivo AuthKey_XXXXXXXXXX.p8 descargado (no se puede volver a descargar — tenlo en cuenta)
  5. 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 SettingsSecrets and variablesActions 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).

ios/ExportOptions.plist
<?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.

.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
  • .p12 suele 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 .gpg en 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
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:

trust-policy.json
{
  "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:

permission-policy.json
{
  "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.

.github/workflows/ios-testflight.yml
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 .p12 supera 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 (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:

permission-policy.json
{
  "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:

.github/workflows/ios-testflight.yml
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

  • .p12 se 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:

  1. ¿Hay errores tipográficos en la ruta del parámetro (jerarquía como /ios/yourapp/...)?
  2. ¿Está ssm:GetParameter permitido para la ruta objetivo en la política de permisos del rol IAM?
  3. Para SecureString: ¿Se incluye el flag --with-decryption?
  4. ¿Tiene el rol IAM el permiso kms:Decrypt para la clave KMS?
  5. ¿La condición sub en 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-action para notificar automáticamente cuando se complete la subida.
  • Notas de versión automáticas: Extraer cambios de git log e 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 en main para un flujo de lanzamiento más intencional.
  • Ejecutar pruebas unitarias primero: Ejecutar xcodebuild test antes 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.