Skip to main content
mobile1 de marzo de 202612 min de lectura

Automatización de despliegues iOS: App Store Connect y TestFlight

Una guía completa para automatizar despliegues de aplicaciones iOS — desde la configuración de Fastlane hasta la distribución en TestFlight y el envío al App Store.

iosfastlanecicd
Automatización de despliegues iOS: App Store Connect y TestFlight

Todo desarrollador iOS ha vivido el ritual: abrir Xcode, incrementar el número de build, seleccionar el esquema de archivo, esperar quince minutos, hacer clic en el Organizer, subir a App Store Connect, esperar un poco más, y luego agregar testers manualmente en TestFlight. Haz eso dos veces por semana en múltiples aplicaciones y empiezas a perder horas que deberían dedicarse a escribir código. La peor parte no es el tiempo — es la inconsistencia. Un paso olvidado, un provisioning profile equivocado, y el build falla silenciosamente o es rechazado por las verificaciones automatizadas de Apple horas después.

Automaticé todo este pipeline para una aplicación iOS basada en Flutter y desde entonces he aplicado los mismos patrones a otros proyectos. La inversión se amortiza después del tercer o cuarto ciclo de lanzamiento.

Por qué los despliegues manuales fallan

El proceso manual tiene tres problemas fundamentales más allá del obvio desperdicio de tiempo.

Primero, concentración de conocimiento. Cuando solo una persona en el equipo sabe cómo desplegar, se convierte en un cuello de botella. Vacaciones, días de enfermedad o simplemente estar en una zona horaria diferente significa que los lanzamientos se estancan. La documentación ayuda, pero una guía de despliegue de 15 pasos es en sí misma una responsabilidad — se desactualiza en el momento en que alguien cambia una configuración de build.

Segundo, error humano en el peor momento. Los lanzamientos tienden a suceder bajo presión. Una corrección de bug crítico, una fecha límite de un cliente, una función que necesita lanzarse antes que un competidor. Estas son exactamente las condiciones donde alguien olvida incrementar el número de build, selecciona el esquema equivocado o sube un build de debug en lugar de release.

Tercero, falta de auditabilidad. Cuando algo sale mal con un lanzamiento, quieres saber exactamente qué se construyó, desde qué commit, con qué configuración. Los procesos manuales no dejan un rastro confiable.

Fastlane: la base

Fastlane es el estándar de facto para la automatización de despliegues iOS, y con buena razón. Maneja la firma de código, compilación, subida y gestión de metadatos a través de un DSL basado en Ruby que se lee como una lista de verificación de despliegue.

Configuración inicial

Configurar Fastlane en un proyecto existente es sencillo.

# Install Fastlane
brew install fastlane

# Initialize in your project directory (for Flutter, run in ios/)
cd ios
fastlane init

Durante la inicialización, Fastlane pregunta qué quieres automatizar. Recomiendo elegir la configuración manual y configurar todo explícitamente — la configuración automatizada hace suposiciones que no siempre coinciden con la estructura de tu proyecto, especialmente para aplicaciones Flutter.

El resultado es un directorio fastlane/ que contiene un Appfile y un Fastfile.

# fastlane/Appfile
app_identifier("com.yourcompany.yourapp")
apple_id("your@email.com")
itc_team_id("123456789")
team_id("ABCD1234EF")

El Appfile almacena los detalles de tu cuenta de Apple Developer. Los IDs de equipo son importantes si tu Apple ID pertenece a múltiples equipos — sin ellos, Fastlane te pedirá interactivamente, lo cual rompe el CI.

Firma de código con Match

La firma de código es donde la mayoría de los esfuerzos de automatización iOS mueren. Provisioning profiles, certificados, entitlements — todo el sistema parece diseñado para castigar la automatización. La herramienta match de Fastlane resuelve esto almacenando tus certificados y profiles en un repositorio Git privado o almacenamiento en la nube, haciéndolos accesibles a cualquier máquina que necesite compilar.

Configurando Match

fastlane match init

Esto crea un Matchfile donde configuras tu backend de almacenamiento.

# fastlane/Matchfile
git_url("https://github.com/your-org/ios-certificates.git")
storage_mode("git")
type("appstore")
app_identifier(["com.yourcompany.yourapp"])

Luego genera y almacena tus certificados:

# Generate development certificates and profiles
fastlane match development

# Generate App Store distribution certificates and profiles
fastlane match appstore

Match encripta todo antes de hacer push al repositorio. Estableces una contraseña durante la configuración que necesitarás en cada máquina — incluyendo CI.

Por qué Match en lugar de firma manual

La alternativa — gestionar certificados manualmente a través del portal de Apple Developer y distribuir archivos .p12 — funciona hasta que deja de hacerlo. Los modos de fallo comunes incluyen:

  • El certificado de un desarrollador expira y nadie lo nota hasta que un build de release falla.
  • Alguien revoca un certificado mientras depura un problema de firma, invalidando todos los profiles que dependen de él.
  • Una nueva máquina de CI necesita provisionamiento y la persona que configuró la máquina original ya no está en el equipo.

Match elimina todo esto al ser la única fuente de verdad. Si un certificado expira, ejecutas fastlane match nuke y regeneras. Cada máquina obtiene del mismo repositorio.

Firma de código en CI

En CI, necesitas manejar el Keychain de macOS ya que no hay GUI para desbloquearlo. Fastlane proporciona helpers para esto:

lane :ci_setup do
  create_keychain(
    name: "ci_keychain",
    password: ENV["KEYCHAIN_PASSWORD"],
    default_keychain: true,
    unlock: true,
    timeout: 3600,
    lock_when_sleeps: false
  )

  match(
    type: "appstore",
    keychain_name: "ci_keychain",
    keychain_password: ENV["KEYCHAIN_PASSWORD"],
    readonly: true
  )
end

La bandera readonly: true es crítica para CI. Sin ella, match podría intentar crear nuevos certificados si no puede encontrar los existentes, lo cual fallará sin autorización interactiva y puede revocar certificados existentes.

Compilación con Gym

Gym es la herramienta de compilación de Fastlane. Envuelve xcodebuild con valores predeterminados sensatos y mejor salida de errores.

lane :build do
  gym(
    scheme: "Runner",
    workspace: "Runner.xcworkspace",
    export_method: "app-store",
    output_directory: "./build",
    output_name: "YourApp.ipa",
    clean: true,
    include_bitcode: false,
    export_options: {
      provisioningProfiles: {
        "com.yourcompany.yourapp" => "match AppStore com.yourcompany.yourapp"
      }
    }
  )
end

Para proyectos Flutter, tienes dos opciones: ejecutar flutter build ipa y manejar la salida, o usar gym directamente en el workspace de Xcode que Flutter genera. Prefiero gym porque da más control sobre las opciones de firma y exportación, pero ambos funcionan.

# Flutter-specific build lane
lane :build_flutter do
  Dir.chdir("..") do
    sh("flutter build ios --release --no-codesign")
  end

  gym(
    scheme: "Runner",
    workspace: "Runner.xcworkspace",
    export_method: "app-store",
    clean: false,  # Flutter already built, no need to clean
    include_bitcode: false
  )
end

La bandera --no-codesign en el paso de build de Flutter es intencional — dejamos que gym maneje la firma con los profiles gestionados por match en lugar de depender de la firma automática de Xcode.

Subida a TestFlight con Pilot

Pilot maneja las subidas a TestFlight y la gestión de beta testers.

lane :beta do
  ci_setup
  build_flutter

  pilot(
    skip_waiting_for_build_processing: true,
    apple_id: "1234567890",
    distribute_external: false,
    notify_external_testers: false
  )
end

La bandera skip_waiting_for_build_processing importa mucho para CI. Sin ella, Fastlane consultará App Store Connect hasta que Apple termine de procesar tu build — lo cual puede tomar de 15 a 45 minutos. Tu runner de CI estaría inactivo todo ese tiempo, quemando créditos de cómputo. Es mejor subir y dejar que el procesamiento ocurra asincrónicamente.

Gestionando testers de TestFlight

También puedes automatizar la gestión de grupos de testers:

lane :distribute_to_testers do
  pilot(
    distribute_external: true,
    groups: ["External Beta Testers"],
    changelog: "Bug fixes and performance improvements",
    notify_external_testers: true
  )
end

Para testers externos, recuerda que Apple requiere Beta App Review para el primer build enviado a grupos externos y siempre que agregues nuevos grupos de testing externo. Esta es una puerta manual que no puedes automatizar — planifica un retraso de 24-48 horas en las primeras distribuciones externas.

Envío al App Store con Deliver

Una vez que tu aplicación está lista para producción, deliver maneja el envío al App Store.

lane :release do
  ci_setup
  build_flutter

  deliver(
    submit_for_review: true,
    automatic_release: false,
    force: true,  # Skip HTML preview verification
    submission_information: {
      add_id_info_uses_idfa: false
    },
    precheck_include_in_app_purchases: false
  )
end

Deliver también puede gestionar los metadatos de tu App Store — capturas de pantalla, descripciones, palabras clave, notas de lanzamiento — todo almacenado como archivos en tu repositorio.

fastlane/metadata/en-US/
├── description.txt
├── keywords.txt
├── name.txt
├── release_notes.txt
├── subtitle.txt
└── privacy_url.txt

fastlane/screenshots/en-US/
├── iPhone 6.5" Display/
│   ├── 01_home.png
│   ├── 02_detail.png
│   └── 03_settings.png
└── iPad Pro 12.9" Display/
    ├── 01_home.png
    └── 02_detail.png

Esta es una de las funciones más subestimadas de Fastlane. Tener tu listado del App Store en control de versiones significa que puedes revisar cambios de metadatos en pull requests, rastrear cuándo se actualizaron las descripciones y revertir si un redactor comete un error.

Estrategias de versionado

La gestión de versiones es sorprendentemente polémica. El enfoque que he adoptado usa una combinación de versionado semántico para la versión de marketing y un número de build auto-incremental.

lane :bump_patch do
  increment_version_number(bump_type: "patch")
  increment_build_number
  commit_version_bump(message: "Bump version to #{get_version_number} (#{get_build_number})")
end

lane :bump_minor do
  increment_version_number(bump_type: "minor")
  increment_build_number(build_number: 1)
  commit_version_bump(message: "Bump version to #{get_version_number} (#{get_build_number})")
end

Para el número de build específicamente, recomiendo usar el número de build de CI o un timestamp en lugar de un entero simple incremental. Esto evita conflictos cuando se están compilando múltiples ramas simultáneamente.

lane :set_ci_build_number do
  build_number = ENV["GITHUB_RUN_NUMBER"] || Time.now.strftime("%Y%m%d%H%M")
  increment_build_number(build_number: build_number)
end

Para proyectos Flutter, también necesitas mantener pubspec.yaml sincronizado con el proyecto de Xcode:

lane :sync_flutter_version do
  version = get_version_number
  build = get_build_number
  Dir.chdir("..") do
    sh("sed -i '' 's/^version: .*/version: #{version}+#{build}/' pubspec.yaml")
  end
end

El Fastfile completo

Aquí hay un Fastfile de producción que combina todo:

default_platform(:ios)

platform :ios do
  before_all do
    setup_ci if ENV["CI"]
  end

  desc "Setup CI environment"
  lane :ci_setup do
    create_keychain(
      name: "ci_keychain",
      password: ENV["KEYCHAIN_PASSWORD"],
      default_keychain: true,
      unlock: true,
      timeout: 3600,
      lock_when_sleeps: false
    )

    match(
      type: "appstore",
      keychain_name: "ci_keychain",
      keychain_password: ENV["KEYCHAIN_PASSWORD"],
      readonly: true
    )
  end

  desc "Build the Flutter iOS app"
  lane :build_flutter do
    Dir.chdir("..") do
      sh("flutter build ios --release --no-codesign")
    end

    gym(
      scheme: "Runner",
      workspace: "Runner.xcworkspace",
      export_method: "app-store",
      output_directory: "./build",
      clean: false,
      include_bitcode: false
    )
  end

  desc "Deploy to TestFlight"
  lane :beta do
    ci_setup if ENV["CI"]
    set_ci_build_number
    build_flutter

    pilot(
      skip_waiting_for_build_processing: true,
      distribute_external: false
    )

    slack(
      message: "New TestFlight build uploaded!",
      slack_url: ENV["SLACK_WEBHOOK"]
    ) if ENV["SLACK_WEBHOOK"]
  end

  desc "Deploy to App Store"
  lane :release do
    ci_setup if ENV["CI"]
    set_ci_build_number
    build_flutter

    deliver(
      submit_for_review: true,
      automatic_release: false,
      force: true,
      precheck_include_in_app_purchases: false
    )
  end

  desc "Set build number from CI"
  private_lane :set_ci_build_number do
    build_number = ENV["GITHUB_RUN_NUMBER"] || Time.now.strftime("%Y%m%d%H%M")
    increment_build_number(build_number: build_number)
  end
end

Integración con GitHub Actions

El workflow de CI une todo. Aquí hay un workflow completo de GitHub Actions para despliegue en TestFlight:

name: Deploy to TestFlight

on:
  push:
    branches: [main]
  workflow_dispatch:

jobs:
  deploy:
    runs-on: macos-14
    timeout-minutes: 45

    steps:
      - uses: actions/checkout@v4

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.27.x'
          channel: stable
          cache: true

      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2'
          bundler-cache: true
          working-directory: ios

      - name: Flutter dependencies
        run: flutter pub get

      - name: Deploy to TestFlight
        working-directory: ios
        env:
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.ASC_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY: ${{ secrets.ASC_API_KEY }}
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_AUTH }}
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
        run: bundle exec fastlane beta

API keys de App Store Connect

Nota que estoy usando API keys en lugar de credenciales de Apple ID. Esto es importante para CI — la autenticación con Apple ID requiere autenticación de dos factores, que no funciona en entornos no interactivos. Las API keys se crean en App Store Connect bajo Usuarios y acceso, y nunca expiran a menos que las revoques.

# In your Appfile or Fastfile
app_store_connect_api_key(
  key_id: ENV["APP_STORE_CONNECT_API_KEY_ID"],
  issuer_id: ENV["APP_STORE_CONNECT_API_ISSUER_ID"],
  key_content: ENV["APP_STORE_CONNECT_API_KEY"],
  is_key_content_base64: true,
  in_house: false
)

Almacena el contenido del archivo .p8 como un secret codificado en base64 en GitHub Actions. Esto evita problemas de rutas de archivo y mantiene la clave fuera de tu repositorio.

Errores comunes y cómo solucionarlos

Desajustes de provisioning profile

La falla de build más común es un desajuste entre el provisioning profile y los entitlements en tu aplicación. Los síntomas incluyen errores como "no provisioning profile matching" o "code signing entitlements not valid."

La solución casi siempre es regenerar tus profiles de match:

fastlane match nuke appstore
fastlane match appstore

Luego verifica que el bundle identifier de tu proyecto Xcode coincida exactamente con lo que hay en tu Matchfile y Appfile.

Problemas de entitlements

Si tu aplicación usa notificaciones push, associated domains, Sign in with Apple u otras capacidades, estas deben estar configuradas tanto en el portal de Apple Developer como en tu archivo Runner.entitlements. Un desajuste entre ambos causa fallos de firma que producen mensajes de error engañosos.

<!-- ios/Runner/Runner.entitlements -->
<?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>aps-environment</key>
    <string>production</string>
    <key>com.apple.developer.associated-domains</key>
    <array>
        <string>applinks:yourdomain.com</string>
    </array>
</dict>
</plist>

Rechazo de número de build

App Store Connect rechaza subidas donde el número de build no es estrictamente mayor que la subida anterior para la misma versión. Si usas números de build de CI, esto generalmente no es un problema. Pero si reinicias tu CI o cambias de proveedor de CI, puedes encontrarte con esto. La solución es usar app_store_build_number para obtener el último número de build e incrementar desde ahí:

lane :smart_build_number do
  current = app_store_build_number(live: false)
  increment_build_number(build_number: current + 1)
end

Desajustes de versión de Xcode

Apple regularmente deja de soportar builds realizados con versiones antiguas de Xcode. Tu runner de CI necesita coincidir con la versión de Xcode con la que desarrollas. En GitHub Actions, puedes especificar esto:

- name: Select Xcode version
  run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer

Consulta la documentación de Apple para la versión mínima de Xcode requerida para envíos al App Store — esto cambia con cada lanzamiento mayor de iOS.

Configuraciones específicas por entorno

La mayoría de las aplicaciones necesitan diferentes configuraciones para diferentes entornos — diferentes endpoints de API, diferentes proyectos de Firebase, diferentes feature flags. Maneja esto en tu Fastfile:

lane :beta_staging do
  Dir.chdir("..") do
    sh("flutter build ios --release --no-codesign --dart-define=ENV=staging")
  end
  # ... rest of build and upload
end

lane :beta_production do
  Dir.chdir("..") do
    sh("flutter build ios --release --no-codesign --dart-define=ENV=production")
  end
  # ... rest of build and upload
end

Para Firebase, usa diferentes archivos GoogleService-Info.plist por entorno y copia el correcto durante el build:

lane :configure_firebase do |options|
  env = options[:env] || "production"
  sh("cp ../firebase/#{env}/GoogleService-Info.plist ../Runner/GoogleService-Info.plist")
end

Monitoreo de la salud del build

Una vez que tu pipeline está funcionando, quieres visibilidad de su estado. Agrego algunas cosas a cada pipeline de despliegue:

Seguimiento del tiempo de build — si tus builds empiezan a tardar más, quieres saberlo antes de que alcancen el límite de timeout.

Notificaciones de Slack — notificaciones de éxito y fallo mantienen al equipo informado sin que nadie necesite vigilar el dashboard de CI.

error do |lane, exception|
  slack(
    message: "Build failed: #{exception.message}",
    success: false,
    slack_url: ENV["SLACK_WEBHOOK"]
  )
end

Retención de artefactos — conserva los archivos dSYM para simbolización de crashes. Súbelos a tu servicio de reporte de crashes automáticamente:

lane :upload_symbols do
  upload_symbols_to_crashlytics(
    dsym_path: "./build/YourApp.app.dSYM.zip",
    gsp_path: "./Runner/GoogleService-Info.plist"
  )
end

El retorno de inversión

Configurar todo este pipeline toma aproximadamente un día para alguien que lo ha hecho antes, y dos a tres días para alguien que lo hace por primera vez — la mayor parte del tiempo se gasta luchando con problemas de firma de código, no escribiendo configuración de Fastlane. Después de esa inversión inicial, cada lanzamiento posterior pasa de un proceso manual de 30-60 minutos a un git push.

El valor real ni siquiera es el ahorro de tiempo. Es la confianza. Cuando desplegar es un solo comando o un trigger automático, despliegas con más frecuencia. Despliegues más pequeños significan menos riesgo por lanzamiento, retroalimentación más rápida de los testers y una iteración más rápida en funcionalidades. El pipeline se paga solo no en horas ahorradas, sino en un flujo de trabajo de desarrollo fundamentalmente mejor.

DU

Danil Ulmashev

Full Stack Developer

Interesado en trabajar juntos?