如何在 Windows 上建置並提交 iOS 應用程式:21 款毋須本地 Mac
Build an iOS app on Windows: how 21 apps shipped with no Mac
作者以 Windows 配合 GitHub Actions macOS runner,將 21 款 iPhone 應用程式建置並提交至 App Store Connect,毋須本地 Mac。
Originally published at tensorust.com.
TL;DR: You can build an iOS app on Windows if you rent the Mac part by the minute. 21 iPhone apps went from a Windows PC to App Store Connect this way, with no Mac on the desk. The first app took 13 failed CI runs. The last ten took zero. Here is the whole pipeline, the code, and every trap that cost a run.
Short answer first, because that is what you searched for. Yes, you can build and ship an iPhone app from Windows. You write the Swift on your PC, and a macOS machine you never touch does the compiling, signing and uploading. That machine is a GitHub Actions macOS runner, and on a public repo GitHub doesn't charge for it.
The honest catch: Apple still needs a Mac somewhere, because Xcode only runs on macOS. You also need a paid Apple Developer Program membership ($99 a year). Everything else, including the signing certificate most guides say needs Keychain Access, can be done from a Windows terminal through the App Store Connect API.
Between September 10 and October 8, 2026, this pipeline ran 139 App Store builds across 21 app repos. Every one of the 21 apps has an upload in its CI log, and 8 are live on the US App Store as of October 16. Here's how it works, and where it broke.

How Chain 1.2 went from one command on Windows to App Store Connect on Oct 8 2026. Step times from the GitHub Actions run log.
What you need before you start
- A Windows PC with git, Python and the GitHub CLI. Any editor. A coding agent helps a lot, since you can't click around Xcode anyway.
- A paid Apple Developer account.
- A GitHub repo. Public means free macOS minutes. Private works too, it just costs money (numbers below).
Two things only a human can do, once each:
-
Create an App Store Connect API key (Users and Access, Integrations). You get a
.p8file, a key ID and an issuer ID. These go into GitHub secrets and nowhere else. -
Create the app record in App Store Connect. The API can read and edit apps, but our
POST /v1/appscame back 403, so this one is a click in the web UI. Every other listing field (name, subtitle, description, keywords, screenshots) is scripted.
Step 1: never commit an .xcodeproj
An Xcode project file (.pbxproj) is a huge generated blob you can't sensibly edit by hand, and you can't open Xcode on Windows to edit it for you. So don't keep one. XcodeGen builds the project from a short YAML file at the start of every CI run. This is the real template every app started from (templates/project.yml, trimmed):
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
The team ID comes in from an environment variable, so it never sits in the repo. No project file in git also means no merge conflicts in a pbxproj, which lowkey matters a lot once agents are editing the code.
Step 2: rent a Mac by the minute
The whole Mac lives in one workflow file, .github/workflows/appstore.yml, started from Windows with one command:
gh workflow run appstore.yml -f action=dry_run # or compile, or release
compile builds and stops. dry_run builds, signs and uploads to App Store Connect without submitting. release also submits for review. Two runner traps cost real runs, and both are in the file as comments now.
Trap 1: the Xcode that isn't there. On the macos-15 image, /Applications/Xcode_26.app existed but was a stub with no Contents/Developer inside, so selecting it failed while ls listed it happily. The fix picks the newest Xcode 26 that actually has a 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"
Trap 2: simulators that move. A hardcoded device name worked for six runs and failed on the seventh with "Unable to find a device matching the provided destination specifier", because the runner fleet didn't carry the same simulators every time. Now the newest available iPhone wins:
name=$(xcrun simctl list devices available \
| grep -oE "iPhone [0-9]+ Pro Max" | sort -Vr | head -1)
echo "SIM_NAME=$name" >> "$GITHUB_ENV"
What it costs. GitHub's billing page says Actions are free for public repos on standard hosted runners. On a private repo, a 3 or 4 core macOS runner is $0.062 a minute. Our 322 macOS runs (App Store, Build and review-video workflows) add up to about 2,573 minutes from queue to finish, which would be roughly $160 private. We paid $0, because the app repos are public. A single release like Chain 1.2 (4 min 22 s) would be about 27 cents.
Step 3: signing without a Mac (the part that broke)
Code signing is how Apple knows a build came from you: a certificate (Apple-signed public key) plus its private key, and a provisioning profile that ties them to the app. On a Mac, Xcode hides all of it. On a CI runner it's where the first app spent its morning.

Glyphstorm's App Store runs on Sep 10 2026, 5:49 to 6:38 AM ET (github.com/Mattbusel/glyphstorm, captured Oct 16).
Here is what those red runs on September 10 actually said, straight from the logs:
-
Your team has no devices from which to generate a provisioning profile.Automatic signing asks for a development profile during archive, and a development profile needs a registered iPhone. An App Store build needs no device at all, but you still get told this. -
Could not find the newly generated certificate installed. fastlane's certificate step made a fresh certificate on the runner. -
MAC verification failed during PKCS12 import (wrong password?). The password was fine. The file format wasn't. -
Error resolving conflict between options: 'adhoc' and 'development'. Passing those flags asfalsestill counts as passing them.
The second one is the expensive mistake. When CI creates a certificate, the private key is born on a machine that gets deleted minutes later. Apple keeps a certificate nobody can ever use again, and Apple limits how many distribution certificates an account can hold. Three failed runs had already orphaned three of them before anyone noticed.
Never let CI create a certificate. Make the key once on your own machine, and let CI only import it.
So the key is made on Windows. Store/signing.py generates it with the Python cryptography library, sends a signing request to Apple's create certificate endpoint, and writes a .p12 (trimmed):
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()))
The script pipes the base64 .p12 straight into gh secret set, so it never lands anywhere it could be committed. The API calls use a short-lived ES256 token signed with the .p8; Apple rejects tokens that live longer than 20 minutes, so a fresh one is minted per call.
On the runner, the import is a throwaway keychain plus one line that every CI signing guide should print in bold:
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
(The security command reference if you want every flag.) After that the build asks for the App Store profile directly with get_provisioning_profile and signs manually, which skips the development-profile step that can't succeed on a team with no devices. Eight failed runs and 37 minutes after the fight started, the 6:38 AM run went green, and at 6:43 AM ET on September 10 the log printed Successfully submitted the app for review!. The first commit of that app was at 4:06 AM.
Step 4: fastlane does the boring part
fastlane is a Ruby tool that wraps the Apple build and upload steps into "lanes". It reads the API key from the secret with app_store_connect_api_key, so there's no Apple ID password or two-factor prompt anywhere. One more trap lives here: build numbers. Upload the same build number twice and Apple refuses it, so every build asks App Store Connect for the latest one and adds one (fastlane/Fastfile):
next_build = latest_testflight_build_number(
api_key: api_key,
app_identifier: APP_IDENTIFIER,
initial_build_number: 0
) + 1
It goes in as a build setting (CURRENT_PROJECT_VERSION=#{next_build}), not through increment_build_number, which drives Apple's agvtool and failed on a generated project with Cannot find "MyApp.xcodeproj/../YES". Then upload_to_app_store sends the binary, the text and the screenshots, with submit_for_review true for release and false for dry_run.
When all of that is in place, a release is boring, which is the goal:

Chain 1.2, Oct 8 2026: 4m 9s of job time, 4m 22s with the queue. The step is named Build, upload and submit; this was a dry_run, so it stopped after the upload.
App Store screenshots without a Mac
Apple wants screenshots at the 6.9 inch size (1320 by 2868). No Mac means no clicking around a simulator, so the app takes a launch argument and opens the right screen with demo data. This is from Chain's Sources/App.swift:
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
}
}
The Build workflow then makes a fresh simulator, sets the status bar to the classic 9:41 with a full battery, and loops:
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
A fresh simulator matters: booting the runner's default one hung for over 15 minutes once. The raw PNGs come back as a build artifact, and a small script on Windows puts a headline over each one.

Four of Chain's ten App Store screenshots: raw captures from the CI simulator, headlines added by a script on Windows.
One opinion here: the shots that ship are committed to the repo and the release lane never re-captures. The images Apple sees are the exact ones a human looked at, and every retry of a signing problem is ten minutes shorter.
What 139 runs taught us
Pulling every run from the GitHub Actions API gives a clean learning curve. Across 21 repos there were 139 App Store workflow runs: 81 succeeded, 43 failed, 15 were cancelled. The median successful run took about 8 minutes from queue to finish.

Failed App Store workflow runs per app, all time, apps in order of their first run (Sep 10 to Oct 8 2026). Source: GitHub Actions API, pulled Oct 16 2026.
The first two apps, Glyphstorm and Clear the Strait, ate 13 failures each, most of them on September 10. Pocket Beings, started a day later, had 5 in total. By the September 24 batch it was mostly zero, and the last ten apps shipped without a single failed App Store run. Climbbook, Cutoff, Parked and Race Pace each went to App Store Connect on their first run on September 27. Same pipeline, same comments in the file. Every fix was written down where the next app would read it.
The plot twist is real though. Those zero-failure apps are the same ones App Review pushed back on. On October 5, Apple rejected 11 of them under Guideline 4.3 (spam) and 4.2.6. My read: once shipping costs nothing, the bottleneck moves from CI to "is this app different enough to deserve a listing". The pipeline can't answer that one for you. Your product has to.
New accounts should also expect a Guideline 2.1 "Information Needed" on the first submission. Both of our first two apps got one. A short screen recording of the app answers it, and the same runner can record that too.
Do this in your project today
-
Delete the .xcodeproj from git and write a
project.ymlfor XcodeGen. Generate it in CI. -
Create the certificate on your own machine through the API, save it as a legacy-format
.p12, and put it in a GitHub secret. If CI already made some, list and revoke the orphans. - In CI: throwaway keychain, import, set-key-partition-list. Skipping the last one is a guaranteed hang.
- Sign manually with an App Store profile. Don't let automatic signing go looking for a device you don't have.
- Build number = latest TestFlight build + 1, passed as a build setting.
- Never hardcode the Xcode path or the simulator name. Pick the newest real one at runtime.
-
Add a
-shotlaunch argument so CI can take your App Store screenshots. -
Use
dry_rununtil the listing looks right, thenrelease.
If you build SwiftUI apps this way, the spring animation write-up for Minder is one of the apps that came out of this pipeline. And if a coding agent is driving the CI loop for you, read how to stop coding agents editing your tests and what broke running 20 agents on one PC first, because an agent fixing signing at 6 AM will happily "fix" the check instead.
The full pipeline (the workflow with every trap commented, the Fastfile, signing.py, the App Store Connect scripts and an agent skill that walks through it) is packaged as the Ship iOS from Windows kit. If you'd rather have someone build the app itself, here are our fixed prices.
What's the one Apple step you still think needs a Mac? Tell us and we'll check whether the API can do it.
來源:DEV Community · dev.to