Crie um app iOS no Windows: como 21 apps foram publicados sem Mac

Resumo: Dá para criar um app iOS no Windows se você alugar a parte do Mac por minuto. 21 apps de iPhone saíram de um PC com Windows para o App Store Connect desse jeito, sem nenhum Mac na mesa. O primeiro app levou 13 execuções de CI com falha. Os últimos dez levaram zero. Aqui está o pipeline inteiro, o código e cada armadilha que custou uma execução.
Primeiro a resposta curta, porque foi isso que você pesquisou. Sim, dá para criar e publicar um app de iPhone a partir do Windows. Você escreve o Swift no seu PC, e uma máquina macOS que você nunca toca faz a compilação, a assinatura e o upload. Essa máquina é um runner macOS do GitHub Actions, e num repositório público o GitHub não cobra por ele.
A pegadinha honesta: a Apple ainda precisa de um Mac em algum lugar, porque o Xcode só roda no macOS. Você também precisa de uma assinatura paga do Apple Developer Program ($99 por ano). Todo o resto, inclusive o certificado de assinatura que a maioria dos guias diz exigir o Keychain Access, pode ser feito num terminal do Windows pela API do App Store Connect.
Entre 10 de setembro e 8 de outubro de 2026, este pipeline rodou 139 builds para a App Store em 21 repositórios de apps. Cada um dos 21 apps tem um upload no log de CI, e 8 estão no ar na App Store dos EUA em 16 de outubro. Veja como funciona, e onde quebrou.

O que você precisa antes de começar
- Um PC com Windows com git, Python e a GitHub CLI. Qualquer editor. Um agente de programação ajuda muito, já que você não vai poder clicar pelo Xcode de qualquer jeito.
- Uma conta paga de Apple Developer.
- Um repositório no GitHub. Público significa minutos de macOS grátis. Privado também funciona, só custa dinheiro (números abaixo).
Duas coisas que só um humano pode fazer, uma vez cada:
- Criar uma chave de API do App Store Connect (Users and Access, Integrations). Você recebe um arquivo
.p8, um key ID e um issuer ID. Isso vai para os secrets do GitHub e para nenhum outro lugar. - Criar o registro do app no App Store Connect. A API consegue ler e editar apps, mas o nosso
POST /v1/appsvoltou com 403, então essa parte é um clique na interface web. Todos os outros campos da página da loja (nome, subtítulo, descrição, palavras-chave, capturas de tela) são feitos por script.
Passo 1: nunca faça commit de um .xcodeproj
Um arquivo de projeto do Xcode (.pbxproj) é um blob gerado enorme que não dá para editar à mão de forma sensata, e você não pode abrir o Xcode no Windows para editá-lo por você. Então não guarde um. O XcodeGen monta o projeto a partir de um arquivo YAML curto no começo de cada execução de CI. Este é o template real do qual todos os apps partiram (templates/project.yml, resumido):
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
O team ID entra por uma variável de ambiente, então nunca fica no repositório. Sem arquivo de projeto no git também não há conflitos de merge num pbxproj, o que, cá entre nós, importa muito quando há agentes editando o código.
Passo 2: alugue um Mac por minuto
O Mac inteiro mora num único arquivo de workflow, .github/workflows/appstore.yml, disparado do Windows com um comando:
gh workflow run appstore.yml -f action=dry_run # or compile, or release
compile faz o build e para. dry_run faz o build, assina e envia para o App Store Connect sem submeter. release também submete para análise. Duas armadilhas do runner custaram execuções de verdade, e as duas agora estão no arquivo como comentários.
Armadilha 1: o Xcode que não está lá. Na imagem macos-15, /Applications/Xcode_26.app existia, mas era um stub sem Contents/Developer dentro, então selecioná-lo falhava enquanto o ls o listava todo feliz. A correção escolhe o Xcode 26 mais novo que realmente tem uma 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"
Armadilha 2: simuladores que mudam de lugar. Um nome de aparelho fixo no código funcionou por seis execuções e falhou na sétima com "Unable to find a device matching the provided destination specifier", porque a frota de runners não trazia sempre os mesmos simuladores. Agora o iPhone mais novo disponível ganha:
name=$(xcrun simctl list devices available \
| grep -oE "iPhone [0-9]+ Pro Max" | sort -Vr | head -1)
echo "SIM_NAME=$name" >> "$GITHUB_ENV"
Quanto custa. A página de cobrança do GitHub diz que o Actions é grátis para repositórios públicos em runners hospedados padrão. Num repositório privado, um runner macOS de 3 ou 4 núcleos custa $0,062 por minuto. As nossas 322 execuções em macOS (workflows de App Store, Build e vídeo para a análise) somam cerca de 2.573 minutos da fila até o fim, o que daria aproximadamente $160 num repositório privado. Pagamos $0, porque os repositórios dos apps são públicos. Um único release como o Chain 1.2 (4 min 22 s) custaria uns 27 centavos.
Passo 3: assinatura sem Mac (a parte que quebrou)
A assinatura de código é como a Apple sabe que um build veio de você: um certificado (chave pública assinada pela Apple) mais a chave privada dele, e um provisioning profile que amarra os dois ao app. Num Mac, o Xcode esconde tudo isso. Num runner de CI, foi onde o primeiro app passou a manhã inteira.

Isto é o que aquelas execuções vermelhas de 10 de setembro realmente disseram, direto dos logs:
Your team has no devices from which to generate a provisioning profile.A assinatura automática pede um profile de desenvolvimento durante o archive, e um profile de desenvolvimento precisa de um iPhone registrado. Um build para a App Store não precisa de aparelho nenhum, mas você recebe essa mensagem mesmo assim.Could not find the newly generated certificate installed. A etapa de certificado do fastlane criou um certificado novo no runner.MAC verification failed during PKCS12 import (wrong password?). A senha estava certa. O formato do arquivo é que não estava.Error resolving conflict between options: 'adhoc' and 'development'. Passar essas flags comofalseainda conta como passá-las.
A segunda é o erro caro. Quando o CI cria um certificado, a chave privada nasce numa máquina que é apagada minutos depois. A Apple guarda um certificado que ninguém nunca mais vai conseguir usar, e a Apple limita quantos certificados de distribuição uma conta pode ter. Três execuções com falha já tinham deixado três deles órfãos antes que alguém percebesse.
Nunca deixe o CI criar um certificado. Gere a chave uma vez na sua própria máquina e deixe o CI apenas importá-la.
Então a chave é gerada no Windows. O Store/signing.py a cria com a biblioteca Python cryptography, envia uma solicitação de assinatura ao endpoint de criação de certificado da Apple e grava um .p12 (resumido):
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()))
O script manda o .p12 em base64 direto para o gh secret set, então ele nunca vai parar num lugar onde poderia ser commitado. As chamadas de API usam um token ES256 de vida curta assinado com o .p8; a Apple rejeita tokens que duram mais de 20 minutos, então um token novo é gerado a cada chamada.
No runner, a importação é um keychain descartável mais uma linha que todo guia de assinatura em CI deveria imprimir em negrito:
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
(A referência do comando security, se você quiser todas as flags.) Depois disso, o build pede o profile da App Store diretamente com get_provisioning_profile e assina manualmente, o que pula a etapa do profile de desenvolvimento, que não tem como dar certo num time sem aparelhos. Oito execuções com falha e 37 minutos depois do início da briga, a execução das 6h38 da manhã ficou verde, e às 6h43 da manhã (ET) de 10 de setembro o log imprimiu Successfully submitted the app for review!. O primeiro commit daquele app tinha sido às 4h06 da manhã.
Passo 4: o fastlane faz a parte chata
O fastlane é uma ferramenta em Ruby que empacota as etapas de build e upload da Apple em "lanes". Ele lê a chave de API do secret com app_store_connect_api_key, então não existe senha de Apple ID nem prompt de autenticação em dois fatores em lugar nenhum. Mais uma armadilha mora aqui: os números de build. Envie o mesmo número de build duas vezes e a Apple recusa, então cada build pergunta ao App Store Connect qual é o mais recente e soma um (fastlane/Fastfile):
next_build = latest_testflight_build_number(
api_key: api_key,
app_identifier: APP_IDENTIFIER,
initial_build_number: 0
) + 1
Ele entra como build setting (CURRENT_PROJECT_VERSION=#{next_build}), não pelo increment_build_number, que aciona o agvtool da Apple e falhou num projeto gerado com Cannot find "MyApp.xcodeproj/../YES". Depois o upload_to_app_store envia o binário, os textos e as capturas de tela, com submit_for_review true para release e false para dry_run.
Quando tudo isso está no lugar, um release é entediante, que é exatamente o objetivo:

Capturas de tela para a App Store sem Mac
A Apple quer capturas de tela no tamanho de 6,9 polegadas (1320 por 2868). Sem Mac não dá para ficar clicando num simulador, então o app recebe um argumento de inicialização e abre a tela certa com dados de demonstração. Isto vem do Sources/App.swift do 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
}
}
O workflow Build então cria um simulador novo, ajusta a barra de status para o clássico 9:41 com bateria cheia e faz o loop:
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
Um simulador novo faz diferença: inicializar o simulador padrão do runner travou por mais de 15 minutos uma vez. Os PNGs brutos voltam como artefato do build, e um pequeno script no Windows coloca um título sobre cada um.

Uma opinião aqui: as capturas que vão para a loja ficam commitadas no repositório, e a lane release nunca captura de novo. As imagens que a Apple vê são exatamente as que um humano olhou, e cada nova tentativa de resolver um problema de assinatura fica dez minutos mais curta.
O que 139 execuções nos ensinaram
Puxar todas as execuções pela API do GitHub Actions mostra uma curva de aprendizado limpa. Em 21 repositórios houve 139 execuções do workflow App Store: 81 com sucesso, 43 com falha, 15 canceladas. A execução bem-sucedida mediana levou cerca de 8 minutos da fila até o fim.

Os dois primeiros apps, Glyphstorm e Clear the Strait, consumiram 13 falhas cada, a maioria em 10 de setembro. O Pocket Beings, iniciado um dia depois, teve 5 no total. No lote de 24 de setembro já era quase sempre zero, e os últimos dez apps foram publicados sem uma única execução App Store com falha. Climbbook, Cutoff, Parked e Race Pace chegaram ao App Store Connect na primeira execução, em 27 de setembro. Mesmo pipeline, mesmos comentários no arquivo. Cada correção ficou anotada onde o próximo app ia ler.
Mas a reviravolta é real. Esses apps sem nenhuma falha são justamente os que o App Review barrou. Em 5 de outubro, a Apple rejeitou 11 deles pela Guideline 4.3 (spam) e pela 4.2.6. A minha leitura: quando publicar não custa nada, o gargalo sai do CI e vai para "este app é diferente o bastante para merecer uma página na loja". O pipeline não responde isso por você. O seu produto é que tem de responder.
Contas novas também devem esperar uma Guideline 2.1 "Information Needed" na primeira submissão. Os nossos dois primeiros apps receberam uma cada. Uma gravação de tela curta do app resolve, e o mesmo runner pode gravar isso também.
Faça isto no seu projeto hoje
- Apague o .xcodeproj do git e escreva um
project.ymlpara o XcodeGen. Gere o projeto no CI. - Crie o certificado na sua própria máquina pela API, salve como
.p12no formato legado e coloque num secret do GitHub. Se o CI já criou alguns, liste e revogue os órfãos. - No CI: keychain descartável, importação, set-key-partition-list. Pular o último é travamento garantido.
- Assine manualmente com um profile da App Store. Não deixe a assinatura automática sair procurando um aparelho que você não tem.
- Número de build = build mais recente do TestFlight + 1, passado como build setting.
- Nunca deixe fixo no código o caminho do Xcode nem o nome do simulador. Escolha o mais novo de verdade em tempo de execução.
- Adicione um argumento de inicialização
-shotpara que o CI possa tirar as suas capturas de tela da App Store. - Use
dry_runaté a página da loja ficar certa, e depoisrelease.
Se você cria apps em SwiftUI desse jeito, o texto sobre a spring animation do Minder é sobre um dos apps que saíram deste pipeline. E se um agente de programação está pilotando o loop de CI para você, leia antes como impedir agentes de programação de editar os seus testes e o que quebrou rodando 20 agentes num único PC, porque um agente consertando assinatura às 6 da manhã vai, todo contente, "consertar" a verificação em vez do problema.
O pipeline completo (o workflow com cada armadilha comentada, o Fastfile, o signing.py, os scripts do App Store Connect e uma skill de agente que conduz o processo) está empacotado como o Ship iOS from Windows kit. Se preferir que alguém crie o app em si, aqui estão os nossos preços fechados.
Qual é a etapa da Apple que você ainda acha que precisa de um Mac? Conte para a gente e vamos verificar se a API consegue fazer.
Publicado originalmente aqui no tensorust.com em 16 de out. de 2026. Também no X (@TensorustAI) e no dev.to. Relacionados: Configuramos este pipeline no seu repositório: $299, Apps para iPhone com preço fechado, os apps para iPhone, Ship iOS from Windows kit.
Leia também
- Dev · 19 de out. de 2026 · 15 min de leituraA checagem de spam 4.3 da Apple é um job em lote de 30 segundos. 22 rejeições provam isso
- Dev · 18 de out. de 2026 · 29 min de leituraDesenvolvimento iOS no Windows: o guia completo (21 apps, sem Mac)
- Dev · 18 de out. de 2026 · 13 min de leituraTempo de análise da App Store: 64 decisões reais, mediana de 73 horas