Build, firma y App Store: lo que EAS te escondía
Todo lo que EAS Build hacía por ti y ahora es tuyo: certificados, provisioning profiles y entitlements explicados de una vez, Fastlane match para compartir la firma, TestFlight, las guidelines de App Review que más rechazan, el Privacy Manifest y Tuist para acabar con los conflictos del .pbxproj.
eas build --platform ios era una línea, y detrás de esa línea Expo creaba el certificado de distribución, registraba los dispositivos, generaba el provisioning profile, lo guardaba en sus servidores, compilaba en una máquina remota y te dejaba un .ipa listo para TestFlight. Cuando algo fallaba, eas credentials lo arreglaba con un menú. En nativo, cada uno de esos pasos existe, tiene nombre, caduca en fechas distintas y falla con mensajes que asumen que sabes qué es un entitlement. Este post explica el sistema de firma de una vez, la forma de compartirlo en equipo sin pasar archivos por Slack, el camino a TestFlight y App Review con las guidelines que más rechazos producen, el Privacy Manifest que desde 2024 es obligatorio, y Tuist como respuesta al archivo de proyecto que rompe el merge.
Resumen para perezosos
- La firma es tres cosas: un certificado (quién eres, con clave privada en tu Mac), un App ID con capacidades (qué puede hacer la app) y un provisioning profile (la unión de las dos con la lista de dispositivos, para desarrollo, o sin ella, para la tienda). Los entitlements son la copia dentro del binario de esas capacidades, y deben coincidir con el perfil.
- Fastlane match guarda certificados y perfiles cifrados en un repositorio de git y los instala en cualquier máquina o CI con un comando. Es lo que EAS hacía en sus servidores, y es la forma correcta de firmar en equipo.
- Tuist genera el proyecto de Xcode desde un archivo Swift: el
.pbxprojdeja de estar en git y los conflictos de merge desaparecen. Junto con los packages, es la diferencia entre un equipo que teme agregar archivos y uno que no.
En este artículo:
- La firma — Certificados, App IDs y perfiles · Entitlements · Fastlane match
- La entrega — Build y TestFlight · App Review · Privacy Manifest
- El proyecto — Tuist y el .pbxproj
Certificados, App IDs y provisioning profiles: el sistema de una vez
El sistema de firma de Apple tiene tres piezas, y toda la confusión viene de no tener claro qué hace cada una.
El certificado identifica a quién firma. Es un par de claves: la privada vive en el Keychain de la máquina que lo generó, y la pública la firma Apple y queda en el portal de desarrollador. Hay dos tipos que importan: Development (para instalar en dispositivos de prueba desde Xcode) y Distribution (para TestFlight y App Store). Caducan al año, y una cuenta tiene un límite de certificados de distribución activos, que es la causa del error clásico “ya tienes el máximo de certificados” cuando alguien nuevo intenta crear uno.
El App ID identifica a la app: el bundle identifier (com.empresa.app) más la lista de capacidades que puede usar (push, Sign in with Apple, Associated Domains, App Groups, In-App Purchase, iCloud). Se registra en el portal, y cada capacidad que activas ahí es una que el perfil va a permitir.
El provisioning profile une las otras dos y dice en qué contexto vale la firma: qué certificado firma, qué App ID, qué capacidades, y para desarrollo o distribución ad hoc, qué dispositivos (por UDID, hasta cien por tipo por año). Un perfil de App Store no lleva dispositivos, porque la tienda instala en cualquiera. El perfil se incrusta en la app al compilar, y el sistema lo comprueba al instalar.
Certificado (quién firma) App ID + capacidades (qué app, qué puede)
clave privada en tu Keychain bundle id, push, sign in, domains...
│ │
└──────────────┬───────────────────────┘
▼
Provisioning profile
├── development: + lista de dispositivos (UDID)
├── ad hoc: + lista de dispositivos, sin Xcode
└── app store: sin dispositivos
│
▼
Build firmado (.ipa) con el perfil incrustado
Con eso, los errores habituales se leen solos:
- “No profiles for ‘com.empresa.app’ were found”: no hay perfil para ese App ID con ese tipo (desarrollo o distribución) en esta máquina.
- “Provisioning profile doesn’t include the currently selected device”: perfil de desarrollo sin ese UDID; hay que registrarlo y regenerar el perfil.
- “Provisioning profile doesn’t support the Push Notifications capability”: el App ID tiene la capacidad, pero el perfil se generó antes de activarla. Regenerar.
- “Missing private key”: el certificado está en el portal pero la clave privada se creó en otra máquina. No se puede recuperar; hay que revocar y crear otro, o exportar el
.p12desde la máquina original.
Xcode ofrece firma automática (“Automatically manage signing”), que crea certificados y perfiles por ti y los regenera cuando cambian las capacidades. Para una persona, funciona. Para un equipo, produce exactamente el problema de “missing private key” cada vez que alguien nuevo abre el proyecto, porque cada Mac crea su propio certificado hasta agotar el límite. Ahí entra match.
Entitlements: lo que el binario dice que puede hacer
Los entitlements son un archivo (App.entitlements, un plist) que se compila dentro del binario y declara qué capacidades reclama la app: aps-environment para push, com.apple.developer.associated-domains con la lista de dominios, com.apple.security.application-groups para compartir datos con extensiones, y así. Xcode lo edita cuando activas una capacidad en la pestaña Signing & Capabilities.
La regla que causa la mitad de los rechazos al subir: los entitlements del binario deben ser un subconjunto de lo que el perfil permite. Si el archivo declara aps-environment y el perfil se generó sin la capacidad de push, la subida falla con un error de entitlements que no coinciden. Y al revés no pasa nada: un perfil con más capacidades que el binario es válido.
Dos detalles que EAS resolvía y en nativo son tuyos:
aps-environmentcambia entredevelopmentyproductionsegún el perfil; Xcode lo ajusta al archivar. Si ves push funcionando en desarrollo y no en TestFlight, es el primer sitio donde mirar, junto con el servidor que debe usar el endpoint de producción de APNs.- Cada extensión (widget, notification service) tiene su propio bundle id, su propio perfil y sus propios entitlements. Compartir datos entre la app y el widget requiere un App Group en los entitlements de los dos.
Fastlane match: la firma compartida sin pasar archivos
match es la herramienta de Fastlane que resuelve la firma en equipo con una idea simple: un solo certificado de distribución y un solo perfil por app y tipo, generados una vez, guardados cifrados en un repositorio de git (o en un bucket de S3 o Google Cloud), e instalados en cualquier máquina o CI con un comando y una contraseña.
# Una vez, quien administra la cuenta:
fastlane match init # apunta al repo de certificados
fastlane match appstore # crea certificado + perfil de App Store, los sube cifrados
fastlane match development # lo mismo para desarrollo
# Cada persona nueva, y el CI:
fastlane match appstore --readonly # descarga e instala; no crea nada
Con --readonly, nadie más crea certificados por accidente, y la clave privada existe una sola vez, en el repositorio cifrado. Es literalmente lo que EAS hacía con eas credentials en sus servidores, con la diferencia de que el almacén es tuyo.
Lo que va en el Matchfile: el repositorio, el app_identifier (o varios, incluidos los de las extensiones), el team_id y el tipo. La contraseña de cifrado (MATCH_PASSWORD) va en el gestor de secretos del CI, nunca en el repositorio. Y para que match cree y renueve sin que una persona inicie sesión con dos factores, se usa una App Store Connect API key (.p8 con key id e issuer id), que también sirve para subir builds.
La Fastfile mínima para un build de TestFlight queda así:
lane :beta do
app_store_connect_api_key(
key_id: ENV["ASC_KEY_ID"],
issuer_id: ENV["ASC_ISSUER_ID"],
key_content: ENV["ASC_KEY_CONTENT"],
)
match(type: "appstore", readonly: true)
increment_build_number(build_number: latest_testflight_build_number + 1)
build_app(scheme: "App", export_method: "app-store")
upload_to_testflight(skip_waiting_for_build_processing: true)
end
En GitHub Actions eso corre en un runner de macOS con Xcode preinstalado; en Xcode Cloud, el servicio de Apple, la firma la gestiona Apple con la cuenta del equipo y match no hace falta. Para equipos pequeños con un solo producto, Xcode Cloud es la opción con menos piezas; para varios productos, CI propio con match es más controlable.
Build, archive y TestFlight: el camino al tester
En nativo, el .ipa sale de un archive: Product > Archive en Xcode, o xcodebuild archive seguido de xcodebuild -exportArchive con un ExportOptions.plist que dice el método (app-store, ad-hoc, development). build_app de Fastlane envuelve los dos. El archivo resultante incluye los símbolos de depuración (dSYM), que hay que subir al crash reporter para que los stack traces tengan nombres.
TestFlight es el reemplazo de los builds internos de EAS y de la distribución ad hoc:
- Testers internos (hasta cien miembros del equipo de App Store Connect): reciben el build al procesarse, sin revisión.
- Testers externos (hasta diez mil, por enlace público o correo): el primer build de cada versión pasa por una revisión de TestFlight, más rápida que la de App Store pero revisión al fin. Los builds siguientes de la misma versión suelen ir directos.
- Cada build caduca a los noventa días.
Lo que cambia respecto a EAS: no hay canales de actualización, no hay eas update. Cada cambio es un build nuevo, con número de build incrementado (CFBundleVersion, que debe ser único por versión y creciente). Por eso el increment_build_number en la lane.
Un detalle que sorprende: el procesamiento después de subir tarda entre minutos y una hora, y durante ese tiempo el build no aparece en TestFlight ni se puede enviar a revisión. Los correos de “Missing Compliance” (cifrado de exportación) se evitan con ITSAppUsesNonExemptEncryption en false en Info.plist si la app solo usa HTTPS.
App Review: las guidelines que más rechazan
Ya pasaste por App Review con React Native, así que las reglas no son nuevas. Lo que cambia es que ahora hay más superficies (extensiones, entitlements, capacidades) y ninguna herramienta que te avise antes de subir. Las guidelines que más rechazos producen en apps que vienen de un equipo de React Native, por mi experiencia y por lo que reportan los foros de desarrolladores:
| Guideline | Qué pide | Rechazo típico |
|---|---|---|
| 2.1 Completitud | La app funciona completa, sin placeholders ni crashes | Un flujo que requiere una cuenta y no diste credenciales de prueba en las notas de revisión |
| 2.3 Metadata precisa | Capturas, descripción y permisos coinciden con la app | Capturas de otra plataforma o de una versión anterior |
| 3.1.1 Compras in-app | Bienes y servicios digitales consumidos en la app pasan por IAP | Un botón que lleva a pagar una suscripción en la web para desbloquear contenido en la app |
| 4.2 Funcionalidad mínima | La app no es un sitio web envuelto | Una WKWebView con el sitio y nada nativo |
| 4.8 Sign in with Apple | Si hay login de terceros, también Apple | Login con Google sin el botón de Apple |
| 5.1.1 Privacidad, datos | Cada permiso con propósito claro; no pedir datos que no se usan | Pedir ubicación al arrancar sin función que la use; texto de permiso genérico |
| 5.1.2 Uso y compartición | La etiqueta de privacidad y el manifest coinciden con lo que la app hace | Un SDK de analytics no declarado |
| 5.2.1 Propiedad intelectual | Derechos sobre el contenido y la marca | Una app para un cliente subida desde tu cuenta personal en vez de la del cliente |
Tres prácticas que reducen rechazos de forma medible:
- Notas de revisión completas: credenciales de prueba, cómo llegar a cada función con permisos, y un video si hay hardware o un flujo difícil de reproducir.
- Cuenta del cliente, no la tuya: las apps de clientes se publican desde la cuenta de desarrollador del cliente, con tu usuario como miembro del equipo. Cambiar de cuenta después es una migración con pérdida de reseñas.
- Enviar un día laborable temprano: la revisión típica es de uno a dos días; si hay rechazo, responder en el Resolution Center suele ser más rápido que subir un build nuevo, salvo que el rechazo sea por un bug.
Privacy Manifest y la etiqueta de privacidad
Desde la primavera de 2024, Apple exige que las apps y los SDK que usan ciertas APIs incluyan un Privacy Manifest: un archivo PrivacyInfo.xcprivacy que declara qué datos recoge la app, con qué fin, si se vinculan al usuario y si se usan para seguimiento, y los motivos aprobados por los que la app usa APIs consideradas sensibles (UserDefaults, marcas de tiempo de archivos, espacio en disco, tiempo de arranque del sistema, teclados activos).
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string> <!-- leer y escribir preferencias de la propia app -->
</array>
</dict>
</array>
Si tu app usa UserDefaults (todas lo hacen) y no declara el motivo, App Store Connect envía un correo de advertencia al subir y, pasado el período de gracia, rechaza. Lo mismo para cada SDK de terceros de la lista que Apple publica: el SDK debe traer su propio manifest, y si no lo trae, la responsabilidad es de la app. Es una de las razones para preferir SDK mantenidos por SPM.
La etiqueta de privacidad de la ficha de App Store (las “tarjetas” de datos recogidos) se rellena en App Store Connect y desde 2024 Apple genera un informe a partir de los manifests de la app y sus SDK para compararla. Si la etiqueta dice “no recogemos datos” y el manifest de un SDK dice lo contrario, hay rechazo por la guideline 5.1.2.
En EAS, el config plugin de cada módulo agregaba su fragmento al manifest. En nativo, el archivo lo escribes tú y lo revisas cada vez que agregas un SDK.
Tuist y el .pbxproj que rompía el merge
El post de arquitectura ya mencionó el problema: project.pbxproj es un archivo de texto con identificadores hexadecimales para cada archivo, grupo, target y fase de build, y dos personas que agregan archivos en ramas distintas producen conflictos de merge que hay que resolver a mano en un formato que no está hecho para eso. En un equipo de tres personas pasa cada semana.
Tuist resuelve el problema eliminando el archivo de git. El proyecto se describe en Swift, en Project.swift, y tuist generate produce el .xcodeproj en la máquina de cada persona, que queda en .gitignore:
import ProjectDescription
let project = Project(
name: "App",
targets: [
.target(
name: "App",
destinations: .iOS,
product: .app,
bundleId: "com.empresa.app",
deploymentTargets: .iOS("17.0"),
infoPlist: .extendingDefault(with: [
"NSCameraUsageDescription": "Para escanear tus recibos.",
"ITSAppUsesNonExemptEncryption": false,
]),
sources: ["App/Sources/**"],
resources: ["App/Resources/**"],
entitlements: "App/App.entitlements",
dependencies: [
.target(name: "Widgets"),
.package(product: "Features"),
]
),
.target(
name: "Widgets",
destinations: .iOS,
product: .appExtension,
bundleId: "com.empresa.app.widgets",
infoPlist: .extendingDefault(with: [
"NSExtension": ["NSExtensionPointIdentifier": "com.apple.widgetkit-extension"],
]),
sources: ["Widgets/Sources/**"],
entitlements: "Widgets/Widgets.entitlements"
),
]
)
Agregar un archivo es crear el archivo: el patrón Sources/** lo incluye al regenerar. Agregar un target es agregar un bloque de Swift que se revisa en un PR como cualquier código. La configuración de firma, los entitlements y los Info.plist viven en el mismo archivo, versionados y legibles.
Lo que Tuist agrega además del proyecto:
- Caché de módulos compilados (
tuist cache): los packages que no cambiaron se descargan como binarios en vez de compilarse, lo que en un proyecto modularizado reduce los tiempos de compilación limpia de forma sustancial. - Gráfico de dependencias (
tuist graph) que muestra qué depende de qué y detecta ciclos. - Generación selectiva:
tuist generate Features/Ordersabre un proyecto con solo ese módulo y sus dependencias, que compila en una fracción del tiempo.
La alternativa sin herramienta es XcodeGen (un project.yml que genera el proyecto), más simple y sin caché. Y la alternativa sin generar nada es lo que ya dijo el post de arquitectura: mover todo el código a packages de SPM, para que el .pbxproj del target de la app sea tan pequeño que casi nunca cambie. Las tres funcionan; Tuist es la que uso cuando hay más de un target o más de dos personas.
El día que el equipo dejó de ver
project.pbxprojen los diffs, cambió una cosa que no esperaba: la gente empezó a crear archivos pequeños. Antes, cada archivo nuevo era un conflicto potencial, y la respuesta inconsciente era agregar el código a un archivo que ya existía.
Preguntas frecuentes
¿Puedo seguir usando EAS Build para una app nativa?
No. EAS Build compila proyectos de Expo y React Native. Para nativo, las opciones son Xcode Cloud, un CI con runners de macOS (GitHub Actions, Bitrise, Codemagic, CircleCI) con Fastlane, o compilar y subir desde una Mac del equipo. Codemagic y Bitrise tienen flujos de firma parecidos a EAS si quieres algo gestionado.
¿Qué hago cuando caduca el certificado de distribución?
Los builds ya publicados siguen funcionando: la firma de la tienda es de Apple, no tuya. Lo que deja de funcionar es crear builds nuevos. Con match, fastlane match nuke distribution seguido de fastlane match appstore crea el nuevo y lo distribuye al equipo. Sin match, se crea en el portal y se comparte el .p12. Conviene ponerlo en el calendario un mes antes.
¿Cómo distribuyo una app interna sin App Store?
Con un perfil ad hoc (hasta cien dispositivos registrados por año) y un enlace de instalación, o con TestFlight con testers internos. Para empresas con Apple Business Manager, la distribución privada (Custom Apps) permite publicar solo para la organización sin ficha pública. El Apple Developer Enterprise Program existe pero es difícil de obtener y no es la respuesta para la mayoría.
¿Los builds de release deben llevar símbolos?
El .ipa de App Store va sin símbolos legibles, y Apple guarda los dSYM si activas “Upload symbols”. Para Crashlytics o Sentry, hay que subir los dSYM a ellos también, con un paso en la lane (upload_symbols_to_crashlytics o sentry_upload_dif). Sin eso, los crashes llegan como direcciones de memoria.
¿Vale la pena Xcode Cloud frente a GitHub Actions?
Xcode Cloud gestiona la firma con la cuenta del equipo, se configura desde Xcode y tiene una cuota gratuita de horas al mes. Su límite es la flexibilidad: los workflows son los que Apple ofrece, y las integraciones con herramientas externas se hacen con scripts en fases fijas. Para una app con un flujo estándar, es el camino más corto. Para varios productos, monorepos o pasos personalizados, GitHub Actions con Fastlane.
Conclusión
Lo que EAS escondía era un sistema con tres piezas (certificado, App ID con capacidades, provisioning profile) que se puede entender en una tarde y que, una vez entendido, hace legibles todos los errores de firma. Fastlane match es la respuesta al problema del equipo: una firma compartida, cifrada en git, instalada con un comando. TestFlight reemplaza a los builds internos, sin canales OTA y con un número de build que sube en cada envío. App Review no cambió, pero ahora hay más superficies que revisar y ninguna herramienta que avise antes. El Privacy Manifest es obligatorio y es tuyo. Y Tuist elimina el .pbxproj de git, que es la diferencia entre un equipo que teme agregar archivos y uno que no.
Para empezar: configura match antes de que una segunda persona abra el proyecto, escribe la lane de TestFlight en la primera semana, crea el PrivacyInfo.xcprivacy con el motivo de UserDefaults el mismo día, y genera el proyecto con Tuist si hay más de un target. El siguiente post cubre lo que hace que todo lo anterior se pueda sostener: Swift Testing, snapshots, mocks sin jest.mock, XCUITest frente a Maestro, Instruments y cómo bajar los tiempos de compilación.