Email me

Traducido del original en inglés del 24 de octubre de 2026. Las cifras y las fuentes son las mismas que en el original. Leer el original en inglés →

Dev · · 10 min de lectura · Matt Busel

Archivado en iOS y Swift Desarrollo de apps Guías

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

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ó.

Diagrama animado del pipeline: un PC con Windows ejecuta gh workflow run, un runner de macOS de GitHub elige Xcode 26 y compila en 52 segundos, importa el certificado de firma en 1 segundo y después fastlane compila y sube en 2 minutos y 31 segundos, 4 minutos y 22 segundos en total.
Cómo Chain 1.2 pasó de un solo comando en Windows a App Store Connect el 8 oct 2026. Tiempos de cada paso tomados del log de ejecución de GitHub Actions.

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:

  1. 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.
  2. Crear el registro de la app en App Store Connect. La API puede leer y editar apps, pero nuestro POST /v1/apps devolvió 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.

Captura de las ejecuciones del workflow de App Store de Glyphstorm en GitHub Actions, 10 sep 2026: la ejecución 10 en verde a las 5:49 AM, la 11 y de la 13 a la 19 fallidas, la 12 cancelada, la 20 en verde a las 6:38 AM EDT.
Las ejecuciones de App Store de Glyphstorm el 10 sep 2026, de 5:49 a 6:38 AM ET (github.com/Mattbusel/glyphstorm, capturado el 16 oct).

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 como false sigue 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:

Captura del job de GitHub Actions de Chain 1.2: seleccionar Xcode 26 4 s, instalar xcodegen y fastlane 7 s, elegir un simulador 25 s, compilar 52 s, escribir la clave de firma 0 s, importar el certificado de firma 1 s, compilar, subir y enviar 2 min 31 s. Job completado con éxito en 4 min 9 s.
Chain 1.2, 8 oct 2026: 4 min 9 s de tiempo de job, 4 min 22 s con la cola. El paso se llama Build, upload and submit; esto era un dry_run, así que se detuvo después de la subida.

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.

Cuatro de las capturas de Chain para la App Store: Five seconds a day, Watch your year fill in, Missed a day? Save the chain y Know your best days, cada una mostrando la interfaz oscura de la app con cuadrados de hábitos en verde lima.
Cuatro de las diez capturas de Chain para la App Store: capturas en bruto del simulador de la CI, con titulares añadidos por un script en Windows.

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.

Gráfico de barras de ejecuciones fallidas del workflow de App Store por app, en orden de lanzamiento: Glyphstorm 13, Clear the Strait 13, Pocket Beings 5, Minder 3, Fairway Ledger 2, Baseline Ledger 2, Ironbook 2, Race Fuel 1, Chain 1, Quiver 0, Rooms 1, y después Clockout, Odometer, Curve, Pricebook, Pawprint, Chores, Climbbook, Cutoff, Parked y Race Pace, todas con 0.
Ejecuciones fallidas del workflow de App Store por app, desde el principio, apps en el orden de su primera ejecución (del 10 sep al 8 oct 2026). Fuente: API de GitHub Actions, consultada el 16 oct 2026.

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

  1. Elimina el .xcodeproj de git y escribe un project.yml para XcodeGen. Genéralo en la CI.
  2. Crea el certificado en tu propia máquina a través de la API, guárdalo como .p12 en formato heredado y ponlo en un secret de GitHub. Si la CI ya creó algunos, lista y revoca los huérfanos.
  3. En la CI: keychain desechable, import, set-key-partition-list. Saltarse el último paso es un cuelgue garantizado.
  4. Firma de forma manual con un perfil de App Store. No dejes que la firma automática busque un dispositivo que no tienes.
  5. Número de build = última build de TestFlight + 1, pasado como ajuste de compilación.
  6. 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.
  7. Añade un argumento de arranque -shot para que la CI pueda hacer tus capturas para la App Store.
  8. Usa dry_run hasta que la ficha se vea bien, y después release.

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

Todos los artículos →

Más en español

Español: Tensorust →

Up next

Most read on the site this weekcrates.io download count: 80% of ours arrived with a new versionWhat the crates.io download count really measures: 69 Rust crates, 90 days of daily data, 80% within a day of a publish, 0 outside dependents.Read it next →

Get the receipts by email.

What my AI agents tried this week, with the real numbers: what worked, what flopped, what made money. One email when there is something worth reading.

No spam, never sold. Every email has a one-click unsubscribe.

Popular in the shop

All 124 tools →

Browse: Bundles and kitsHome, family and schoolMoney and businessAI toolsSports and fitnessMaking and hobbiesFood and drinkIndie dev and launchTrading and quant

Free: Landing page teardown iPhone app cost calculator LLM API cost calculator Paid online: $49 Launch Check App builds Articles FAQ