Crear una app de iOS en Windows: cómo se publicaron 21 apps sin Mac

En resumen: puedes crear una app de iOS en Windows si alquilas la parte del Mac por minutos. Así llegaron 21 apps de iPhone desde un PC con Windows hasta App Store Connect, sin ningún Mac en el escritorio. La primera app costó 13 ejecuciones de CI fallidas. Las diez últimas, cero. Aquí tienes el pipeline completo, el código y cada trampa que nos costó una ejecución.
Primero la respuesta corta, porque es lo que buscabas. Sí, puedes crear y publicar una app de iPhone desde Windows. Escribes el Swift en tu PC, y una máquina con macOS que nunca tocas se encarga de compilar, firmar y subir. Esa máquina es un runner de macOS de GitHub Actions, y en un repo público GitHub no lo cobra.
El inconveniente, siendo honestos: Apple sigue necesitando un Mac en algún sitio, porque Xcode solo funciona en macOS. También necesitas una membresía de pago del Apple Developer Program ($99 al año). Todo lo demás, incluido el certificado de firma que la mayoría de las guías dicen que necesita Keychain Access, se puede hacer desde una terminal de Windows a través de la API de App Store Connect.
Entre el 10 de septiembre y el 8 de octubre de 2026, este pipeline ejecutó 139 builds para la App Store en 21 repos de apps. Las 21 apps tienen una subida en su log de CI, y 8 están publicadas en la App Store de EE. UU. a 16 de octubre. Así es como funciona y estos son los puntos donde falló.

Lo que necesitas antes de empezar
- Un PC con Windows con git, Python y la GitHub CLI. Cualquier editor. Un agente de programación ayuda mucho, ya que de todos modos no puedes ir haciendo clic por Xcode.
- Una cuenta de pago de Apple Developer.
- Un repo de GitHub. Público significa minutos de macOS gratis. Privado también funciona, solo que cuesta dinero (cifras más abajo).
Dos cosas que solo puede hacer un humano, una vez cada una:
- Crear una clave de API de App Store Connect (Users and Access, Integrations). Obtienes un archivo
.p8, un key ID y un issuer ID. Van a los secrets de GitHub y a ningún otro sitio. - Crear el registro de la app en App Store Connect. La API puede leer y editar apps, pero nuestro
POST /v1/appsdevolvió un 403, así que esto es un clic en la interfaz web. Todos los demás campos de la ficha (nombre, subtítulo, descripción, palabras clave, capturas) se hacen con scripts.
Paso 1: nunca hagas commit de un .xcodeproj
Un archivo de proyecto de Xcode (.pbxproj) es un enorme bloque generado que no se puede editar a mano con sensatez, y en Windows no puedes abrir Xcode para que lo edite por ti. Así que no lo guardes. XcodeGen genera el proyecto a partir de un archivo YAML corto al inicio de cada ejecución de CI. Esta es la plantilla real de la que partió cada app (templates/project.yml, recortada):
name: MyApp
options:
bundleIdPrefix: com.example
deploymentTarget:
iOS: "17.0"
settings:
base:
DEVELOPMENT_TEAM: ${DEVELOPMENT_TEAM}
CODE_SIGN_STYLE: Manual
MARKETING_VERSION: "1.0.0"
targets:
MyApp:
type: application
platform: iOS
sources:
- path: Sources
resources:
- path: Resources
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.example.myapp
GENERATE_INFOPLIST_FILE: YES
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
El team ID llega desde una variable de entorno, así que nunca está en el repo. No tener archivo de proyecto en git también significa no tener conflictos de merge en un pbxproj, algo que importa bastante más de lo que parece cuando hay agentes editando el código.
Paso 2: alquila un Mac por minutos
Todo el Mac vive en un solo archivo de workflow, .github/workflows/appstore.yml, que se lanza desde Windows con un solo comando:
gh workflow run appstore.yml -f action=dry_run # or compile, or release
compile compila y se detiene. dry_run compila, firma y sube a App Store Connect sin enviar a revisión. release además envía a revisión. Dos trampas del runner nos costaron ejecuciones reales, y ahora las dos están en el archivo como comentarios.
Trampa 1: el Xcode que no está. En la imagen macos-15, /Applications/Xcode_26.app existía, pero era un esqueleto sin Contents/Developer dentro, así que seleccionarlo fallaba mientras ls lo listaba tan tranquilo. La solución elige el Xcode 26 más reciente que de verdad tenga toolchain:
chosen=""
for app in $(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -Vr); do
if [ -d "$app/Contents/Developer" ]; then chosen="$app"; break; fi
done
sudo xcode-select -s "$chosen/Contents/Developer"
Trampa 2: simuladores que cambian. Un nombre de dispositivo fijo en el código funcionó en seis ejecuciones y falló en la séptima con "Unable to find a device matching the provided destination specifier", porque la flota de runners no traía siempre los mismos simuladores. Ahora gana el iPhone disponible más reciente:
name=$(xcrun simctl list devices available \
| grep -oE "iPhone [0-9]+ Pro Max" | sort -Vr | head -1)
echo "SIM_NAME=$name" >> "$GITHUB_ENV"
Lo que cuesta. La página de facturación de GitHub dice que Actions es gratis para repos públicos en los runners alojados estándar. En un repo privado, un runner de macOS de 3 o 4 núcleos cuesta $0,062 por minuto. Nuestras 322 ejecuciones de macOS (workflows de App Store, de Build y de vídeo para la revisión) suman unos 2.573 minutos desde la cola hasta el final, lo que en privado serían unos $160. Pagamos $0, porque los repos de las apps son públicos. Una sola publicación como Chain 1.2 (4 min 22 s) costaría unos 27 centavos.
Paso 3: firmar sin Mac (la parte que falló)
La firma de código es la forma en que Apple sabe que una build viene de ti: un certificado (una clave pública firmada por Apple) más su clave privada, y un perfil de aprovisionamiento que los vincula a la app. En un Mac, Xcode lo esconde todo. En un runner de CI, es donde la primera app pasó la mañana.

Esto es lo que decían de verdad esas ejecuciones en rojo del 10 de septiembre, sacado directamente de los logs:
Your team has no devices from which to generate a provisioning profile.La firma automática pide un perfil de desarrollo durante el archive, y un perfil de desarrollo necesita un iPhone registrado. Una build para la App Store no necesita ningún dispositivo, pero igualmente te sale este mensaje.Could not find the newly generated certificate installed. El paso de certificados de fastlane creó un certificado nuevo en el runner.MAC verification failed during PKCS12 import (wrong password?). La contraseña estaba bien. El formato del archivo no.Error resolving conflict between options: 'adhoc' and 'development'. Pasar esas opciones comofalsesigue contando como pasarlas.
El segundo es el error caro. Cuando la CI crea un certificado, la clave privada nace en una máquina que se borra minutos después. Apple conserva un certificado que nadie podrá volver a usar nunca, y Apple limita cuántos certificados de distribución puede tener una cuenta. Tres ejecuciones fallidas ya habían dejado huérfanos tres de ellos antes de que nadie se diera cuenta.
Nunca dejes que la CI cree un certificado. Crea la clave una sola vez en tu propia máquina y deja que la CI solo la importe.
Así que la clave se crea en Windows. Store/signing.py la genera con la biblioteca cryptography de Python, envía una solicitud de firma al endpoint de creación de certificados de Apple y escribe un .p12 (recortado):
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
csr = (x509.CertificateSigningRequestBuilder()
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Glyphstorm Distribution")]))
.sign(key, hashes.SHA256()))
made = call("POST", "/v1/certificates", {"data": {"type": "certificates", "attributes": {
"certificateType": "DISTRIBUTION",
"csrContent": csr.public_bytes(serialization.Encoding.PEM).decode()}}})
# Legacy PKCS#12 (3DES + SHA-1). macOS `security import` can't read the AES-256 form
# and reports it as "MAC verification failed ... (wrong password?)".
encryption = (PrivateFormat.PKCS12.encryption_builder()
.key_cert_algorithm(pkcs12.PBES.PBESv1SHA1And3KeyTripleDESCBC)
.hmac_hash(hashes.SHA1())
.build(P12_PASSWORD.encode()))
El script pasa el .p12 en base64 directamente a gh secret set, así que nunca acaba en ningún sitio donde se pueda hacer commit. Las llamadas a la API usan un token ES256 de corta duración firmado con el .p8; Apple rechaza los tokens que duran más de 20 minutos, así que se genera uno nuevo en cada llamada.
En el runner, la importación es un keychain desechable más una línea que todas las guías de firma en CI deberían poner en negrita:
security import "$RUNNER_TEMP/dist.p12" -k "$keychain" \
-P "$DIST_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
# Without the partition list, codesign blocks on a GUI prompt that
# nobody can answer and the job hangs until it times out.
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "" "$keychain" >/dev/null
(La referencia del comando security, si quieres ver todas las opciones.) Después, la build pide directamente el perfil de App Store con get_provisioning_profile y firma de forma manual, lo que se salta el paso del perfil de desarrollo, que no puede salir bien en un equipo sin dispositivos. Ocho ejecuciones fallidas y 37 minutos después de empezar la pelea, la ejecución de las 6:38 AM salió en verde, y a las 6:43 AM ET del 10 de septiembre el log mostró Successfully submitted the app for review!. El primer commit de esa app fue a las 4:06 AM.
Paso 4: fastlane hace la parte aburrida
fastlane es una herramienta en Ruby que envuelve los pasos de compilación y subida de Apple en "lanes". Lee la clave de API desde el secret con app_store_connect_api_key, así que no hay contraseña de Apple ID ni solicitud de doble factor en ninguna parte. Aquí vive una trampa más: los números de build. Si subes dos veces el mismo número de build, Apple lo rechaza, así que cada build le pide a App Store Connect el último y le suma uno (fastlane/Fastfile):
next_build = latest_testflight_build_number(
api_key: api_key,
app_identifier: APP_IDENTIFIER,
initial_build_number: 0
) + 1
Entra como un ajuste de compilación (CURRENT_PROJECT_VERSION=#{next_build}), no mediante increment_build_number, que usa el agvtool de Apple y falló con un proyecto generado con Cannot find "MyApp.xcodeproj/../YES". Después, upload_to_app_store envía el binario, los textos y las capturas, con submit_for_review a true para release y a false para dry_run.
Cuando todo eso está en su sitio, una publicación es aburrida, que es justo el objetivo:

Capturas para la App Store sin Mac
Apple quiere capturas en el tamaño de 6,9 pulgadas (1320 por 2868). Sin Mac no puedes ir haciendo clic por un simulador, así que la app recibe un argumento de arranque y abre la pantalla correcta con datos de demostración. Esto es del Sources/App.swift de Chain:
func applyShotArgs(_ s: Store) {
let a = ProcessInfo.processInfo.arguments
guard let i = a.firstIndex(of: "-shot"), i + 1 < a.count else { return }
switch a[i + 1] {
case "year": tab = .year
case "stats": tab = .stats
case "rescue": rescue = s.rescues().first
case "themes": shop = true
default: break
}
}
Después, el workflow de Build crea un simulador nuevo, pone la barra de estado en las clásicas 9:41 con la batería llena y hace un bucle:
for shot in today widgets year detail rescue themes week stats edit paywall onboarding done; do
xcrun simctl terminate "$udid" com.mattbusel.chainhabits || true
xcrun simctl launch "$udid" com.mattbusel.chainhabits -shot $shot
sleep 8
xcrun simctl io "$udid" screenshot "shots/$(printf %02d $i)-$shot.png"
i=$((i+1))
done
Que el simulador sea nuevo importa: arrancar el que trae el runner por defecto se colgó una vez durante más de 15 minutos. Los PNG en bruto vuelven como artefacto de la build, y un pequeño script en Windows pone un titular encima de cada uno.

Una opinión aquí: las capturas que se publican están en el repo, y el lane release nunca vuelve a capturarlas. Las imágenes que ve Apple son exactamente las que miró un humano, y cada reintento por un problema de firma dura diez minutos menos.
Lo que nos enseñaron 139 ejecuciones
Sacar todas las ejecuciones de la API de GitHub Actions da una curva de aprendizaje limpia. En 21 repos hubo 139 ejecuciones del workflow de App Store: 81 con éxito, 43 fallidas y 15 canceladas. La ejecución exitosa mediana tardó unos 8 minutos desde la cola hasta el final.

Las dos primeras apps, Glyphstorm y Clear the Strait, se comieron 13 fallos cada una, la mayoría el 10 de septiembre. Pocket Beings, empezada un día después, tuvo 5 en total. En la tanda del 24 de septiembre ya eran casi todo ceros, y las diez últimas apps se publicaron sin una sola ejecución fallida de App Store. Climbbook, Cutoff, Parked y Race Pace llegaron cada una a App Store Connect en su primera ejecución, el 27 de septiembre. El mismo pipeline, los mismos comentarios en el archivo. Cada corrección quedó escrita donde la siguiente app la iba a leer.
Pero el giro de guion es real. Esas apps sin fallos son las mismas que App Review rechazó. El 5 de octubre, Apple rechazó 11 de ellas por la Guideline 4.3 (spam) y la 4.2.6. Mi interpretación: cuando publicar no cuesta nada, el cuello de botella pasa de la CI a "¿es esta app lo bastante distinta para merecer una ficha?". El pipeline no puede responder a eso por ti. Tiene que hacerlo tu producto.
Las cuentas nuevas también deberían contar con un "Information Needed" de la Guideline 2.1 en el primer envío. Nuestras dos primeras apps recibieron uno. Una grabación de pantalla corta de la app lo responde, y el mismo runner también puede grabarla.
Haz esto hoy en tu proyecto
- Elimina el .xcodeproj de git y escribe un
project.ymlpara XcodeGen. Genéralo en la CI. - Crea el certificado en tu propia máquina a través de la API, guárdalo como
.p12en formato heredado y ponlo en un secret de GitHub. Si la CI ya creó algunos, lista y revoca los huérfanos. - En la CI: keychain desechable, import, set-key-partition-list. Saltarse el último paso es un cuelgue garantizado.
- Firma de forma manual con un perfil de App Store. No dejes que la firma automática busque un dispositivo que no tienes.
- Número de build = última build de TestFlight + 1, pasado como ajuste de compilación.
- Nunca fijes en el código la ruta de Xcode ni el nombre del simulador. Elige el más reciente que exista de verdad en tiempo de ejecución.
- Añade un argumento de arranque
-shotpara que la CI pueda hacer tus capturas para la App Store. - Usa
dry_runhasta que la ficha se vea bien, y despuésrelease.
Si creas apps en SwiftUI de esta forma, el artículo sobre la animación spring de Minder trata de una de las apps que salieron de este pipeline. Y si un agente de programación controla el ciclo de CI por ti, lee antes cómo evitar que los agentes de programación editen tus tests y qué falló al ejecutar 20 agentes en un solo PC, porque un agente arreglando la firma a las 6 AM "arreglará" encantado la comprobación en su lugar.
El pipeline completo (el workflow con cada trampa comentada, el Fastfile, signing.py, los scripts de App Store Connect y una skill para agentes que lo recorre paso a paso) está empaquetado como el kit Ship iOS from Windows. Si prefieres que alguien cree la app por ti, aquí tienes nuestros precios cerrados.
¿Cuál es el único paso de Apple que todavía crees que necesita un Mac? Cuéntanoslo y comprobaremos si la API puede hacerlo.
Publicado originalmente aquí, en tensorust.com, el 16 de octubre de 2026. También en X (@TensorustAI) y en dev.to. Relacionado: Montamos este pipeline en tu repo: $299, Apps de iPhone a precio cerrado, las apps de iPhone, kit Ship iOS from Windows.
Lee a continuación
- Dev · 19 oct 2026 · 15 min de lecturaEl control de spam 4.3 de Apple es un proceso por lotes de 30 segundos. 22 rechazos lo demuestran
- Dev · 18 oct 2026 · 29 min de lecturaDesarrollo para iOS en Windows: la guía completa (21 apps, sin Mac)
- Dev · 18 oct 2026 · 13 min de lecturaTiempo de revisión en la App Store: 64 decisiones reales, mediana de 73 horas