在 Windows 上开发 iOS App:没有 Mac,21 款 App 是怎么上架的

太长不看:只要把需要 Mac 的那部分按分钟租用,你就能在 Windows 上开发 iOS App。21 款 iPhone App 就是这样从一台 Windows PC 送进 App Store Connect 的,桌上没有一台 Mac。第一款 App 经历了 13 次失败的 CI 运行,最后十款是 零。下面是完整的流水线、代码,以及每一个让我白跑一次的坑。
先给简短答案,因为这就是你搜索的问题。可以,你可以在 Windows 上构建并发布 iPhone App。你在 PC 上写 Swift,一台你从来不用碰的 macOS 机器负责编译、签名和上传。这台机器就是 GitHub Actions 的 macOS runner,公开仓库用它是免费的。
老实说也有代价:Apple 仍然需要某个地方有一台 Mac,因为 Xcode 只能在 macOS 上运行。你还需要付费的 Apple Developer Program 会员(每年 $99)。其余所有事情,包括大多数教程说必须用钥匙串访问来搞定的签名证书,都可以在 Windows 终端里通过 App Store Connect API 完成。
2026 年 9 月 10 日到 10 月 8 日,这条流水线在 21 个 App 仓库上跑了 139 次 App Store 构建。21 款 App 每一款的 CI 日志里都有上传记录,截至 10 月 16 日,有 8 款已在美区 App Store 上架。下面讲它怎么运作,以及在哪里出过问题。

开始之前你需要什么
- 一台装有 git、Python 和 GitHub CLI 的 Windows PC。编辑器随意。有个编程 agent 会帮上大忙,反正你也没法在 Xcode 里点来点去。
- 一个付费的 Apple 开发者账号。
- 一个 GitHub 仓库。公开仓库意味着免费的 macOS 分钟数。私有仓库也行,只是要花钱(数字见下文)。
有两件事只能由人来做,各做一次:
- 创建 App Store Connect API key(用户和访问,集成)。你会拿到一个
.p8文件、一个 key ID 和一个 issuer ID。它们只放进 GitHub secrets,别的地方都不放。 - 在 App Store Connect 里创建 App 记录。API 可以读取和编辑 App,但我们调用
POST /v1/apps返回了 403,所以这一步得在网页上点一下。其余所有商品页字段(名称、副标题、描述、关键词、截图)都用脚本搞定。
第 1 步:永远不要提交 .xcodeproj
Xcode 项目文件(.pbxproj)是一大坨自动生成的内容,没法合理地手工编辑,而你在 Windows 上也打不开 Xcode 来帮你编辑。所以干脆别留它。XcodeGen 会在每次 CI 运行开始时,根据一个简短的 YAML 文件生成项目。下面是每款 App 起步时用的真实模板(templates/project.yml,有删减):
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
Team ID 从环境变量传入,所以它永远不会出现在仓库里。git 里没有项目文件,也就不会有 pbxproj 的合并冲突,一旦有 agent 在改代码,这一点其实非常重要。
第 2 步:按分钟租一台 Mac
整台 Mac 就住在一个 workflow 文件里:.github/workflows/appstore.yml,在 Windows 上用一条命令启动:
gh workflow run appstore.yml -f action=dry_run # or compile, or release
compile 只构建,然后停下。dry_run 会构建、签名并上传到 App Store Connect,但不提交审核。release 还会提交审核。有两个 runner 的坑实实在在地浪费了运行次数,现在它们都作为注释写在文件里。
坑 1:不存在的 Xcode。在 macos-15 镜像上,/Applications/Xcode_26.app 确实存在,但只是个空壳,里面没有 Contents/Developer,所以 ls 能愉快地列出它,选中它却会失败。修复方法是挑选最新的、真正带工具链的 Xcode 26:
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"
坑 2:会变的模拟器。一个写死的设备名在前六次运行中都没问题,第七次却失败了,报错“Unable to find a device matching the provided destination specifier”,因为 runner 集群并不是每次都带着相同的模拟器。现在改成选用最新的可用 iPhone:
name=$(xcrun simctl list devices available \
| grep -oE "iPhone [0-9]+ Pro Max" | sort -Vr | head -1)
echo "SIM_NAME=$name" >> "$GITHUB_ENV"
花多少钱。GitHub 的计费页面说明,公开仓库在标准托管 runner 上使用 Actions 是免费的。私有仓库的话,3 核或 4 核 macOS runner 每分钟 $0.062。我们 322 次 macOS 运行(App Store、Build 和审核视频这几个 workflow),从排队到结束加起来大约 2,573 分钟,如果是私有仓库,大约要 $160。我们付了 $0,因为 App 仓库都是公开的。像 Chain 1.2 这样的一次发布(4 分 22 秒)大约 27 美分。
第 3 步:没有 Mac 的签名(出问题的部分)
代码签名是 Apple 确认构建确实出自你手的方式:一张证书(Apple 签发的公钥)加上它的私钥,再加一个把它们和 App 绑定在一起的描述文件(provisioning profile)。在 Mac 上,Xcode 把这些全都藏起来了。在 CI runner 上,第一款 App 一整个早上都耗在这里。

下面是 9 月 10 日那些红色运行的真实报错,直接取自日志:
Your team has no devices from which to generate a provisioning profile.自动签名在 archive 时会去要一个开发描述文件,而开发描述文件需要一台已注册的 iPhone。App Store 构建根本不需要设备,但你还是会收到这条报错。Could not find the newly generated certificate installed。fastlane 的证书步骤在 runner 上新建了一张证书。MAC verification failed during PKCS12 import (wrong password?)。密码没问题,问题出在文件格式上。Error resolving conflict between options: 'adhoc' and 'development'。把这些参数设为false传进去,照样算是传了。
第二个是代价最高的错误。当 CI 创建证书时,私钥诞生在一台几分钟后就会被销毁的机器上。Apple 会留下一张再也没人能用的证书,而 Apple 限制了一个账号能持有的分发证书数量。还没等有人注意到,三次失败的运行就已经留下了三张孤儿证书。
永远不要让 CI 创建证书。在你自己的机器上生成一次密钥,CI 只负责导入。
所以密钥是在 Windows 上生成的。Store/signing.py 用 Python 的 cryptography 库生成密钥,向 Apple 的创建证书接口发送签名请求,再写出一个 .p12(有删减):
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()))
脚本把 base64 编码的 .p12 直接通过管道传给 gh secret set,所以它永远不会落在任何可能被提交的地方。API 调用使用由 .p8 签名的短期 ES256 token;Apple 会拒绝有效期超过 20 分钟的 token,所以每次调用都现签一个新的。
在 runner 上,导入用的是一个用完即弃的钥匙串,外加一行每篇 CI 签名教程都应该加粗印出来的命令:
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
(想看全部参数的话,这里是 security 命令参考。)之后,构建直接用 get_provisioning_profile 申请 App Store 描述文件并手动签名,跳过那个在没有设备的团队里注定失败的开发描述文件步骤。战斗开始后经历了八次失败的运行、37 分钟,上午 6:38 那次运行终于变绿,9 月 10 日美东时间上午 6:43,日志打印出 Successfully submitted the app for review!。这款 App 的第一次提交是在凌晨 4:06。
第 4 步:枯燥的部分交给 fastlane
fastlane 是一个 Ruby 工具,把 Apple 的构建和上传步骤封装成一条条“lane”。它通过 app_store_connect_api_key 从 secret 中读取 API key,所以整个过程不需要 Apple ID 密码,也不会弹出双重认证。这里还有一个坑:构建号。同一个构建号上传两次,Apple 会拒收,所以每次构建都会向 App Store Connect 查询最新构建号再加一(fastlane/Fastfile):
next_build = latest_testflight_build_number(
api_key: api_key,
app_identifier: APP_IDENTIFIER,
initial_build_number: 0
) + 1
它以构建设置的形式传入(CURRENT_PROJECT_VERSION=#{next_build}),而不是通过 increment_build_number,后者调用的是 Apple 的 agvtool,在自动生成的项目上会报错 Cannot find "MyApp.xcodeproj/../YES"。然后 upload_to_app_store 把二进制包、文案和截图一起发出去,release 时 submit_for_review 为 true,dry_run 时为 false。
这一切都就位之后,发布就变得很无聊,而这正是目标:

没有 Mac 怎么做 App Store 截图
Apple 要求 6.9 英寸尺寸的截图(1320 x 2868)。没有 Mac 就没法在模拟器里点来点去,所以 App 接受一个启动参数,带着演示数据直接打开对应的界面。下面摘自 Chain 的 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
}
}
然后 Build workflow 新建一个模拟器,把状态栏设成经典的 9:41、满电,再循环截图:
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
新建模拟器很重要:有一次启动 runner 默认的模拟器卡了 15 分钟以上。原始 PNG 作为构建产物传回来,Windows 上的一个小脚本再给每张图加上标题。

这里说一个个人观点:最终发布的截图会提交到仓库里,release lane 从不重新截图。Apple 看到的图片,就是真人看过的那几张,而签名出问题时的每一次重试也能少花十分钟。
139 次运行教会了我们什么
从 GitHub Actions API 拉取全部运行记录,能得到一条清晰的学习曲线。21 个仓库共有 139 次 App Store workflow 运行:81 次成功,43 次失败,15 次取消。成功运行从排队到结束的中位耗时大约 8 分钟。

头两款 App,Glyphstorm 和 Clear the Strait,各吃了 13 次失败,大部分发生在 9 月 10 日。晚一天开始的 Pocket Beings 总共失败 5 次。到 9 月 24 日那一批,基本都是零了,最后十款 App 没有一次 App Store 运行失败。Climbbook、Cutoff、Parked 和 Race Pace 都在 9 月 27 日第一次运行就进了 App Store Connect。同一条流水线,文件里同样的注释。每个修复都写在下一款 App 会读到的地方。
不过剧情反转也是真的。那些零失败的 App,恰恰就是被 App Review 打回来的那些。10 月 5 日,Apple 以 Guideline 4.3(spam)和 4.2.6 拒了其中 11 款。我的理解是:一旦发布不再有成本,瓶颈就从 CI 转移到了“这款 App 是否足够不同,值得单独上架”。这个问题流水线替你回答不了,得靠你的产品来回答。
新账号还应该预料到,第一次提交会收到 Guideline 2.1“Information Needed”。我们最早的两款 App 都收到了。一段简短的 App 录屏就能应对,而同一个 runner 也能把录屏做了。
今天就在你的项目里这么做
- 把 .xcodeproj 从 git 里删掉,为 XcodeGen 写一个
project.yml。在 CI 里生成项目。 - 在你自己的机器上通过 API 创建证书,保存为旧格式的
.p12,放进 GitHub secret。如果 CI 已经创建过一些,把这些孤儿证书列出来并吊销。 - 在 CI 里:临时钥匙串、导入、set-key-partition-list。漏掉最后一步,必定卡死。
- 用 App Store 描述文件手动签名。别让自动签名去找一台你根本没有的设备。
- 构建号 = 最新 TestFlight 构建号 + 1,作为构建设置传入。
- 永远不要写死 Xcode 路径或模拟器名称。在运行时挑选最新的、真实可用的那个。
- 加一个
-shot启动参数,让 CI 替你截 App Store 截图。 - 在商品页看起来没问题之前,一直用
dry_run,然后再用release。
如果你也用这种方式开发 SwiftUI App,Minder 的弹簧动画详解讲的就是这条流水线产出的 App 之一。如果是编程 agent 在替你驱动 CI 循环,先读读如何阻止编程 agent 修改你的测试和在一台 PC 上跑 20 个 agent 时出了什么问题,因为一个早上 6 点在修签名的 agent,会很乐意转而去“修”检查本身。
完整的流水线(每个坑都写了注释的 workflow、Fastfile、signing.py、App Store Connect 脚本,以及一个手把手带你走完流程的 agent skill)打包成了 Ship iOS from Windows kit。如果你更希望有人直接帮你把 App 做出来,这里是我们的固定价格。
你觉得 Apple 的哪一个步骤仍然非得有 Mac 不可?告诉我们,我们去查查 API 能不能做到。
2026 年 10 月 16 日首发于 tensorust.com,同时发布在 X(@TensorustAI)和 dev.to。相关链接:我们帮你在自己的仓库里搭好这条流水线:$299、固定价格的 iPhone App 开发、我们的 iPhone App、Ship iOS from Windows kit。
接着读
- 开发 · 2026 年 10 月 19 日 · 阅读约 15 分钟Apple 的 4.3 spam 检查是一个 30 秒的批处理任务,22 封拒信就是证据
- 开发 · 2026 年 10 月 18 日 · 阅读约 29 分钟在 Windows 上做 iOS 开发:完整指南(21 款 App,没有 Mac)
- 开发 · 2026 年 10 月 18 日 · 阅读约 13 分钟App Store 审核要多久:64 次真实审核结果,中位数 73 小时