Email me

译自 2026 年 10 月 24 日的英文原文。数字和来源与原文一致。 阅读英文原文 →

开发 · · 阅读约 10 分钟 · Matt Busel

归档于 iOS 与 Swift App 开发 指南

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

在 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 上架。下面讲它怎么运作,以及在哪里出过问题。

流水线动态示意图:Windows PC 执行 gh workflow run,GitHub macOS runner 选择 Xcode 26 并在 52 秒内完成编译,1 秒导入签名证书,然后 fastlane 用 2 分 31 秒完成构建和上传,总计 4 分 22 秒。
2026 年 10 月 8 日,Chain 1.2 如何从 Windows 上的一条命令进入 App Store Connect。各步骤耗时取自 GitHub Actions 运行日志。

开始之前你需要什么

  • 一台装有 git、Python 和 GitHub CLI 的 Windows PC。编辑器随意。有个编程 agent 会帮上大忙,反正你也没法在 Xcode 里点来点去。
  • 一个付费的 Apple 开发者账号。
  • 一个 GitHub 仓库。公开仓库意味着免费的 macOS 分钟数。私有仓库也行,只是要花钱(数字见下文)。

有两件事只能由人来做,各做一次:

  1. 创建 App Store Connect API key(用户和访问,集成)。你会拿到一个 .p8 文件、一个 key ID 和一个 issuer ID。它们只放进 GitHub secrets,别的地方都不放。
  2. 在 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 一整个早上都耗在这里。

2026 年 9 月 10 日 Glyphstorm 在 GitHub Actions 上的 App Store workflow 运行截图:第 10 次运行在上午 5:49 变绿,第 11 次和第 13 到 19 次失败,第 12 次被取消,第 20 次在美东夏令时上午 6:38 变绿。
2026 年 9 月 10 日美东时间上午 5:49 到 6:38,Glyphstorm 的 App Store 运行记录(github.com/Mattbusel/glyphstorm,10 月 16 日截取)。

下面是 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。

这一切都就位之后,发布就变得很无聊,而这正是目标:

Chain 1.2 的 GitHub Actions 任务截图:选择 Xcode 26 用时 4 秒,安装 xcodegen 和 fastlane 7 秒,选择模拟器 25 秒,编译 52 秒,写入签名密钥 0 秒,导入签名证书 1 秒,构建、上传并提交 2 分 31 秒。任务在 4 分 9 秒内成功完成。
Chain 1.2,2026 年 10 月 8 日:任务耗时 4 分 9 秒,算上排队 4 分 22 秒。这一步叫 Build, upload and submit;这次是 dry_run,所以上传后就停了。

没有 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 上的一个小脚本再给每张图加上标题。

Chain 的四张 App Store 截图:Five seconds a day、Watch your year fill in、Missed a day? Save the chain 和 Know your best days,每张都展示了深色界面和青柠绿色的习惯方块。
Chain 十张 App Store 截图中的四张:原图由 CI 模拟器截取,标题由 Windows 上的脚本添加。

这里说一个个人观点:最终发布的截图会提交到仓库里,release lane 从不重新截图。Apple 看到的图片,就是真人看过的那几张,而签名出问题时的每一次重试也能少花十分钟。

139 次运行教会了我们什么

从 GitHub Actions API 拉取全部运行记录,能得到一条清晰的学习曲线。21 个仓库共有 139 次 App Store workflow 运行:81 次成功,43 次失败,15 次取消。成功运行从排队到结束的中位耗时大约 8 分钟。

按上线顺序排列的每款 App 失败的 App Store workflow 运行次数柱状图: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,之后 Clockout、Odometer、Curve、Pricebook、Pawprint、Chores、Climbbook、Cutoff、Parked 和 Race Pace 全部为 0。
每款 App 历史上失败的 App Store workflow 运行次数,按首次运行顺序排列(2026 年 9 月 10 日至 10 月 8 日)。来源:GitHub Actions API,2026 年 10 月 16 日拉取。

头两款 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 也能把录屏做了。

今天就在你的项目里这么做

  1. 把 .xcodeproj 从 git 里删掉,为 XcodeGen 写一个 project.yml。在 CI 里生成项目。
  2. 在你自己的机器上通过 API 创建证书,保存为旧格式的 .p12,放进 GitHub secret。如果 CI 已经创建过一些,把这些孤儿证书列出来并吊销。
  3. 在 CI 里:临时钥匙串、导入、set-key-partition-list。漏掉最后一步,必定卡死。
  4. 用 App Store 描述文件手动签名。别让自动签名去找一台你根本没有的设备。
  5. 构建号 = 最新 TestFlight 构建号 + 1,作为构建设置传入。
  6. 永远不要写死 Xcode 路径或模拟器名称。在运行时挑选最新的、真实可用的那个。
  7. 加一个 -shot 启动参数,让 CI 替你截 App Store 截图。
  8. 在商品页看起来没问题之前,一直用 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。

接着读

全部文章 →

更多中文内容

简体中文: Tensorust →

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