没有 Mac 也能上架 iOS:GitHub Actions 全自动构建 + 签名 + TestFlight 送审实录

作者:

在

手边只有一台 Windows,却要交付一个能装进 iPhone、最终上 App Store 的客户端。
本文是这次把「写字台」iOS 客户端从零做到 TestFlight 的完整记录,命令与 workflow 都可以直接抄。
文中不含任何密钥、Team ID、Issuer ID 等敏感值——这些只存在于仓库 Secrets 里。

一、先说结论

flutter build ios / xcodebuild 只能在 macOS 上跑,Linux runner 产不出 iOS 产物。
所以没有 Mac 就只有一条路:

GitHub Actions 的 macos-latest runner(同类还有 Codemagic、Xcode Cloud,但 GitHub 与代码仓库同源,最小摩擦)。

整个链路拆成两级,刻意分开:

级别 workflow 触发 产出
① 不签名构建 ios.yml push / PR / 手动 Runner-unsigned.ipa(artifact,证明链路通)
② 签名 + 发布 ios-release.yml 手动 / tag v* 直接上传 TestFlight

分开的原因很实际:macOS runner 计费是 Linux 的 10 倍,日常提交不该烧这个钱。

二、动手前的三个决策(先问再写代码)

问题 为什么关键
客户端开源吗? 决定仓库可见性。若后端仓库已是 public,闭源客户端根本放不进去,必须独立建库
只做 iOS 还是双端? 一套 Flutter 出 iOS + Android,独立仓库更合适
有 Apple Developer 账号吗? 没有就只能止步于「未签名构建」;有($99/年)才能接签名与 TestFlight

必须先纠正的一个误解

新建仓库不会多拿 Actions 额度。 GitHub Free 的额度是账号级共享的:

仓库可见性 Actions 额度 macOS runner 倍率
public 无限免费 免费
private 2000 分钟/月(全部私有仓库共享) ×10

私有仓库那 2000 分钟,换算到 macOS 上实际只有约 200 分钟。一次冷缓存构建 15~25 分钟,
一个月只够跑十几次——开发期完全不够用。能公开就公开。

三、生成骨架,然后把标识改对

flutter create 是必须的(它生成 ios/、android/ 原生工程),不能手写:

flutter create --platforms=ios,android \
  --org cn.example --project-name my_app \
  --description "..." \
  --no-pub .
  • --platforms=ios,android:不要 web/desktop,少一堆噪音目录
  • --no-pub:生成阶段别卡在网络上
  • --project-name 决定 Dart 包名(只能小写 + 下划线),和仓库名是两回事:
    仓库用连字符(my-app),Dart 包名用下划线(my_app)

生成完必须显式改标识,一处一处来:

位置 改什么
ios/Runner.xcodeproj/project.pbxproj PRODUCT_BUNDLE_IDENTIFIER(3 处主 target + 3 处 RunnerTests,整串替换可一次覆盖)
ios/Runner/Info.plist CFBundleDisplayName(中文名)、CFBundleName(建议 ASCII)
android/app/build.gradle namespace 与 applicationId
android/app/src/main/AndroidManifest.xml android:label
Kotlin 包目录 目录搬家 + package 声明

⚠️ Bundle ID 近乎不可逆:一旦在 App Store Connect 建了 App 记录就不建议再改。
所以生成骨架后、建 App 记录前,确认一次。
⚠️ 另外注意:flutter create 给 iOS 生成的是驼峰 id(下划线在 iOS 侧不合法)。

四、第一阶段:不签名构建(零 Apple 配置就能绿)

第一里程碑只做 --no-codesign,把「环境 + 编译」的问题先隔离出来:

name: iOS Build

on:
  push:
    branches: [main]
  pull_request:
  workflow_dispatch:

concurrency:
  group: ios-build-${{ github.ref }}
  cancel-in-progress: true

env:
  FLUTTER_VERSION: '3.47.6'   # 钉住,别用 latest

jobs:
  build-ios:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: ${{ env.FLUTTER_VERSION }}
          channel: stable
          cache: true
      - name: 环境信息(排错用)
        run: |
          flutter --version
          xcodebuild -version
          pod --version || true
      - run: flutter pub get
      - run: flutter analyze
      - run: flutter test
      - name: 构建 iOS(不签名)
        run: flutter build ios --release --no-codesign
      - name: 核对产物信息
        run: |
          set -euo pipefail
          APP=build/ios/iphoneos/Runner.app
          test -d "$APP" || { echo "::error::没有找到 $APP"; exit 1; }
          du -sh "$APP"
          for k in CFBundleIdentifier CFBundleName CFBundleShortVersionString MinimumOSVersion; do
            printf '%-30s = ' "$k"
            /usr/libexec/PlistBuddy -c "Print :$k" "$APP/Info.plist" 2>/dev/null || echo "(无)"
          done
      - name: 打包成未签名 ipa
        run: |
          set -euo pipefail
          rm -rf Payload Runner-unsigned.ipa
          mkdir -p Payload
          cp -R build/ios/iphoneos/Runner.app Payload/
          zip -qry Runner-unsigned.ipa Payload
          ls -lh Runner-unsigned.ipa
      - uses: actions/upload-artifact@v4
        with:
          name: ios-unsigned-ipa
          path: Runner-unsigned.ipa
          if-no-files-found: error

几个要点:

  • ipa 本质就是个目录结构:Payload/Runner.app 打包改名 .ipa。未签名的包不能装机,
    它的价值是证明「环境 + 编译 + 打包」这条链路是通的。
  • if-no-files-found: error:产物没生成必须让这一步红,否则会「绿着但什么都没产出」。
  • flutter analyze / flutter test 放在构建前面:编译要几分钟,静态问题早失败早省时间。
  • concurrency + cancel-in-progress:连续推送时取消旧构建,省 macOS 分钟。

绿灯不等于对了

退出码 0 只说明进程没崩。产物里的标识必须读日志核对,比如:

CFBundleIdentifier = cn.xiezitai.app

看到这一行,才能说「bundle id 改对了」。

五、第二阶段:签名 + 直传 TestFlight

关键认知:不需要 Mac,也不需要在本机导出证书。走 App Store Connect API Key,
让 Xcode 自己向苹果申请证书与描述文件,用完即弃。

前提(不满足会卡住)

前提 说明
App Store Connect 里已建好 App 记录 只有注册了 bundle id 不够,必须有 App 记录,否则 TestFlight 没有落点
已同意最新版协议 协议、税务和银行业务里若有黄色横幅未点 → 上传被拒
Apple ID 是 Account Holder 或 Admin 只有这两个角色能建 API Key

四个 Secret(值只放仓库里)

Secret 从哪来
APP_STORE_CONNECT_ISSUER_ID App Store Connect → 用户和访问 → 集成 → App Store Connect API,页面顶部的 UUID
APP_STORE_CONNECT_KEY_ID 同页,新建 Key 那行的 Key ID
APP_STORE_CONNECT_API_KEY 该 Key 的 .p8 文件全文(含 BEGIN/END 两行),只能下载一次
APPLE_TEAM_ID developer.apple.com/account → Membership details

⚠️ Team ID ≠ Issuer ID:一个在 Apple Developer,一个在 App Store Connect,填反了报认证失败。
⚠️ 必须建 Team Key:苹果明确限制 Individual Key 无法访问 Provisioning 接口,自动签名会直接失败。
角色选择:要自动签证书 → Admin;只上传构建 → App Manager 也够。

三个硬事实(苹果改过多次,2026 年核实)

  1. ExportOptions.plist 的 method 用 app-store-connect(旧的 app-store 已废弃)
  2. destination 设成 upload 时,-exportArchive 导出即上传,不必再单独调 xcrun altool(altool 也已进入废弃流程)
  3. -allowProvisioningUpdates 要在 archive 和 -exportArchive 两条命令上都带,并同时给
    -authenticationKeyPath / -authenticationKeyID / -authenticationKeyIssuerID
# ① 先跑一遍不签名构建:目的让 Flutter 生成 Generated.xcconfig 并执行 pod install,
#    否则后面的 xcodebuild archive 用 workspace 归档会找不到 Pods
flutter build ios --release --no-codesign --build-number=${{ github.run_number }}

# ② 归档
xcodebuild archive \
  -workspace ios/Runner.xcworkspace -scheme Runner \
  -configuration Release -destination 'generic/platform=iOS' \
  -archivePath "$RUNNER_TEMP/Runner.xcarchive" \
  -allowProvisioningUpdates \
  -authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8" \
  -authenticationKeyID "$ASC_KEY_ID" -authenticationKeyIssuerID "$ASC_ISSUER_ID" \
  DEVELOPMENT_TEAM="$TEAM_ID" CODE_SIGN_STYLE=Automatic

# ③ 导出 + 直接上传(method=app-store-connect, destination=upload)
xcodebuild -exportArchive \
  -archivePath "$RUNNER_TEMP/Runner.xcarchive" \
  -exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist" \
  -exportPath "$RUNNER_TEMP/export" \
  -allowProvisioningUpdates \
  -authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8" \
  -authenticationKeyID "$ASC_KEY_ID" -authenticationKeyIssuerID "$ASC_ISSUER_ID"

ExportOptions.plist 在 CI 里用 heredoc 现生成(免得把 teamID 写死在仓库里):

<key>method</key><string>app-store-connect</string>
<key>destination</key><string>upload</string>
<key>teamID</key><string>${TEAM_ID}</string>
<key>signingStyle</key><string>automatic</string>
<key>uploadSymbols</key><true/>
<key>manageAppVersionAndBuildNumber</key><false/>

⚠️ YAML 的 run: | 块标量会剥掉公共缩进,heredoc 终止符必须顶格,否则会把后面所有内容吃掉。

构建号必须唯一

App Store Connect 拒绝重复的 CFBundleVersion。用
--build-number=${{ github.run_number }} 最省心(run number 每个 workflow 各自自增,天然唯一),
配合 Info.plist 里的 CFBundleVersion = $(FLUTTER_BUILD_NUMBER) 生效。

.p8 一定要自检

手工把 .p8 粘进 Secret,最常见的错误是漏了 BEGIN/END 两行或前后带别的文本,
结果报一个看不懂的认证失败。上传前加一步自检,把错误提前成一句人话:

head -1 "$KEY" | grep -q 'BEGIN PRIVATE KEY' || { echo "::error::.p8 首行不完整"; exit 1; }
tail -1 "$KEY" | grep -q 'END PRIVATE KEY'   || { echo "::error::.p8 末行不完整"; exit 1; }

key 放到 ~/.appstoreconnect/private_keys/AuthKey_<KEY_ID>.p8(xcodebuild 认这个路径),
最后加一步 if: always() 的清理把它删掉。

不要在 push 到 main 时跑发布

ios-release.yml 只挂 workflow_dispatch + tag v*。发新版本 = 打一个 tag:

git tag -a v1.1.2 -m "v1.1.2: 仅 iPhone 支持"
git push origin v1.1.2

⚠️ 只推 main 不打 tag,TestFlight 永远看不到新版本——这是最容易踩的坑:
代码推上去了、构建也是绿的,但发布流程根本没被触发。

六、踩过的坑(都是真金白银换的)

坑 1:新版 Flutter 会就地迁移你的 iOS 工程

构建日志里会出现:

Updating minimum iOS deployment target to 15.0.
Upgrading project.pbxproj / AppFrameworkInfo.plist / Runner.xcscheme
Finished migration to UIScene lifecycle.

这是 Flutter 3.47 在当场升级旧模板(部署目标抬到 15.0、切 UIScene 生命周期)。
它只作用于 CI 的临时工作区,不提交回仓库,所以每次构建都重做一遍。

  • 别当成报错;
  • 但要把仓库里的 IPHONEOS_DEPLOYMENT_TARGET、AppFrameworkInfo.plist 的
    MinimumOSVersion 对齐到 Flutter 的下限
    ,否则仓库值与「实际构建的东西」长期不一致。

坑 2:本地 Flutter 与 CI 版本不一致,会「本地能跑 CI 挂」

开发机上装的是定制分支(本例是鸿蒙分支),上游 stable 又是另一个版本,
它生成的 iOS/Android 模板与 CI 不是一套。

处理:CI 里钉死版本(flutter-version: '3.47.6'),README 写明本地该用哪个,
别用 channel: stable 漂移;版本号用官方 release 清单核对,别猜。

坑 3:macOS runner 排队,别误判成卡死

macos-latest 机器池比 Linux 紧张得多,run 会先 status: queued 一段时间(实测十几分钟还没起)。

  • 排队期间 job 的 steps 数组是空的,只按 step 状态轮询会「一行都不输出」,看着像脚本挂了;
    要把 job 级 status 也纳入判断。
  • 排队超过 ~30 分钟才值得怀疑;单纯慢不用重跑(重跑要重新排队)。
  • 两条 macOS 任务同时排队时,超过 15 分钟可能被自动取消,隔几分钟重试即可。

坑 4:读 job 日志 401 —— 302 到别的主机还带着 Authorization

GET /repos/{owner}/{repo}/actions/jobs/{job_id}/logs 会 302 到预签名存储地址,
urllib/requests 默认把 Authorization 头一起带过去,存储端不认 → 401。

解法:重定向跨主机时剥掉 Authorization。

class NoAuthRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        new = super().redirect_request(req, fp, code, msg, headers, newurl)
        if new is not None and urllib.parse.urlsplit(newurl).netloc != urllib.parse.urlsplit(req.full_url).netloc:
            new.headers.pop('Authorization', None)
        return new

op = urllib.request.build_opener(NoAuthRedirect)

坑 5:Windows 本机侧的杂音

坑 现象 处理
Git Bash 缺 coreutils mkdir/head/grep 全 command not found 用 Python 的 os.makedirs 等替代
后台命令拿不到 stdout 只留一句「completed」 命令里重定向到磁盘文件,再读文件
git push 无输出挂住 HTTPS + GCM 凭据助手 指定 wincred 助手,并设 GIT_TERMINAL_PROMPT=0、GCM_INTERACTIVE=never
flutter test 报 Invalid WebSocket upgrade request 本机代理拦了 flutter_tester 的 localhost WebSocket 清代理变量 + NO_PROXY=*;系统代理也会拦,此时别再折腾本地,验证交给 CI
CRLF/LF 混用 project.pbxproj 出现整文件 diff 加 .gitattributes:* text=auto eol=lf,工程文件显式 text eol=lf

七、没有 Mac,怎么在本地验证 workflow?

没有 Mac 也能把大半错误挡在提交前(实测有效):

  1. 缩进里不能有 TAB(YAML 明令禁止);
  2. 把每个 run: | 块剥掉公共缩进拆成 .sh,用 bash -n 做语法检查(${{ }} 先替换成占位符);
  3. heredoc 内容真跑一遍,再用 XML 解析器验证产出的 plist 合法;
  4. 检查 heredoc 终止符是否顶格。

但 macos-latest 上的 Xcode 版本、签名行为只能靠真跑一次。
所以顺序是:本地静态校验 → 推送 → 立刻 workflow_dispatch 跑一次看日志,不要「推上去就当完成」。

八、构建通了之后:提审还要补的东西

这部分和 CI 无关,但迟早要面对,一并记下:

事项 要点
出口合规 只用系统 TLS(HTTPS)→ 属豁免项;Info.plist 加 ITSAppUsesNonExemptEncryption = false,此后所有版本都不再弹加密问答
截图 若 App 声明支持 iPad,就必须交 13 寸 iPad 截图。不需要 iPad 支持就把 target 改成仅 iPhone(TARGETED_DEVICE_FAMILY = 1),要求随之消失
应用内删账号 有账号体系就必须提供 App 内注销入口(境外审核尤其看重)
UGC 合规 先审后发 + 举报 + 管理员删除,是社区类 App 的标配;Review Notes 里写清机制
隐私标签 逐项如实勾选,且都不勾「追踪」,最终显示「未收集用于追踪你的数据」最干净
演示账号 App 内无法注册时,必须在 Review Notes 给一个可用的测试账号

九、最终成型的两段式流水线

push / PR ──▶ ios.yml(macos-latest)
                ├─ flutter analyze / test
                ├─ flutter build ios --no-codesign
                └─ artifact: Runner-unsigned.ipa

tag v* / 手动 ──▶ ios-release.yml(macos-latest)
                ├─ 落 .p8 到 ~/.appstoreconnect/private_keys(并自检 BEGIN/END)
                ├─ flutter build ios --no-codesign --build-number=<run_number>
                ├─ xcodebuild archive -allowProvisioningUpdates
                ├─ 核对归档 Info.plist(identifier / version / minOS)
                └─ xcodebuild -exportArchive(method=app-store-connect,
                   destination=upload)→ 直达 TestFlight

实测一次 tag 发布:约 8 分钟跑完(含排队),日志里能看到
ARCHIVE SUCCEEDED / Upload succeeded / EXPORT SUCCEEDED,
之后 App Store Connect 处理几分钟,TestFlight 就能看到新构建。

十、交付前检查清单

  • 仓库可见性已按「是否开源」确认,并清楚 macOS runner 的 ×10 计费
  • Dart 包名(下划线)与仓库名(连字符)都对
  • bundle id 已显式改成目标值,且 pbxproj / Info.plist / build.gradle / Kotlin 包目录四处一致
  • CI 钉住 Flutter 版本,README 写明本地版本
  • 第一里程碑是 --no-codesign,产物 ipa 上传 artifact
  • 读过构建日志核对 CFBundleIdentifier / MinimumOSVersion,不只是看绿灯
  • 仓库里的 IPHONEOS_DEPLOYMENT_TARGET 已对齐 Flutter 下限
  • .gitattributes 已统一 LF
  • Secrets 齐了再动签名部分,不做未验证的 workflow
  • 发布走 tag 或手动,别挂 push
  • 记住:只推 main 不打 tag,新版本不会进 TestFlight

整条链路走通之后,这台 Windows 电脑就再没碰过任何证书文件——
archive、签名、上传全部在 GitHub 的一次性 macOS runner 上完成,密钥只在运行期存在于 runner 的内存与临时目录里,跑完即删。
对个人开发者来说,这就是「没有 Mac 也能上架」的现实解法。

评论

发表回复