手边只有一台 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 年核实)
ExportOptions.plist的method用app-store-connect(旧的app-store已废弃)destination设成upload时,-exportArchive导出即上传,不必再单独调xcrun altool(altool 也已进入废弃流程)-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 也能把大半错误挡在提交前(实测有效):
- 缩进里不能有 TAB(YAML 明令禁止);
- 把每个
run: |块剥掉公共缩进拆成.sh,用bash -n做语法检查(${{ }}先替换成占位符); - heredoc 内容真跑一遍,再用 XML 解析器验证产出的 plist 合法;
- 检查 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 也能上架」的现实解法。
发表回复
要发表评论,您必须先登录。