iOS 自动递增构建号并自动上传 ipa:App Store Connect API 密钥
最后更新
iOS 自动递增构建号的做法是用 CI 的运行序号生成构建号,构建时用 -D CURRENT_PROJECT_VERSION=… 覆盖,不改工程文件。自动上传 ipa 用 App Store Connect 的团队 API 密钥加 Apple 的命令行版 Transporter(iTMSTransporter),它有 Windows 和 Linux 版,用 -apiKey 和 -apiIssuer 认证,不需要输入账号密码和验证码。本文接着 GitHub Actions 用自托管 Linux runner 构建 iOS App 的 workflow,补上这两部分。
开始前你需要准备好这几样东西。
- App Store Connect 里已经建好这个 App 的记录,Bundle ID 和工程一致。
- 一个能在 CI 里出 ipa 的流水线,GitHub Actions 见 183 篇教程,Jenkins 和 GitLab CI 见用 Jenkins、GitLab CI 自动打包 iOS。
- 账户持有人或管理角色的 App Store Connect 账号,用来生成 API 密钥。
版本号和构建号有什么规则?
iOS App 有两个号,Info.plist 里各对应一个键,工程里各对应一个构建设置。
| 名称 | Info.plist 键 | 构建设置 | 作用 |
|---|---|---|---|
| 版本号 | CFBundleShortVersionString | MARKETING_VERSION | 商店里显示的 1.0、1.2.0 |
| 构建号 | CFBundleVersion | CURRENT_PROJECT_VERSION | 区分同一版本的每次上传 |
App Store Connect 要求同一个版本号下,每次上传的构建号都比上一次大。构建号重复或变小,上传时直接被拒。被审核拒绝后重传、TestFlight 构建过期后重传,构建号也要加。
构建号的格式由 Apple 规定,是一到三个用点分隔的整数,例如 45、45.2、20261019.1530,只能包含数字和点,见 Apple 的 CFBundleVersion 文档。比较大小时逐段比较整数,46 比 45.2 大。
让 Info.plist 读构建设置
用 -D 覆盖构建号的前提是 Info.plist 里写的是变量,不是写死的数字。Xcode 13 之后新建的工程默认如此。老工程用属性列表编辑器打开 Info.plist,把两个键的值改成下面这样,保存后提交。
1<key>CFBundleShortVersionString</key>
2<string>$(MARKETING_VERSION)</string>
3<key>CFBundleVersion</key>
4<string>$(CURRENT_PROJECT_VERSION)</string>
工程里有 App 扩展(小组件、通知扩展等)的,扩展的 Info.plist 也要这样写。扩展和主 App 的版本号、构建号要保持一致,否则上传时会收到 Apple 的提示。
CI 里怎么自动递增构建号?
用运行序号
最简单的做法是直接用 CI 自带的递增编号,每次运行自动加一。
| CI | 变量 | 说明 |
|---|---|---|
| GitHub Actions | GITHUB_RUN_NUMBER |
这个 workflow 的运行序号,重新运行(Re-run)时不变 |
| GitHub Actions | GITHUB_RUN_ATTEMPT |
同一次运行的第几次尝试,从 1 开始 |
| Jenkins | BUILD_NUMBER |
这个任务的构建序号 |
| GitLab CI | CI_PIPELINE_IID |
项目内递增的流水线编号 |
构建时用 -D 覆盖,写法见临时覆盖构建设置。
1kxbuild build ./MyApp.xcodeproj -c Release --export app-store -D MARKETING_VERSION=1.2.0 -D CURRENT_PROJECT_VERSION="$GITHUB_RUN_NUMBER"
GitHub Actions 里点 Re-run 重新运行时,GITHUB_RUN_NUMBER 不变。上一次尝试已经上传成功的话,重跑会因为构建号重复而失败。把尝试次数拼成第二段,可以避开这个问题。
1BUILD_NUMBER="${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}"
接着已有的构建号
App Store Connect 里已经手动传到第 120 号构建,而 workflow 刚建好、运行序号从 1 开始的,加一个固定偏移量,保证新的构建号一定更大。
1BUILD_NUMBER=$((GITHUB_RUN_NUMBER + 1000))
用时间
多个流水线、多台机器都会往同一个 App 上传的,运行序号可能撞车,用 UTC 时间更稳妥。写成「年月日.时分」两段,每段都是整数。
1BUILD_NUMBER="$(date -u +%Y%m%d.%H%M)"
Windows 构建机上的 PowerShell 写法如下。
1$BuildNumber = (Get-Date).ToUniversalTime().ToString('yyyyMMdd.HHmm')
同一分钟内跑两次会重复,这种情况少见,遇到时重跑一次即可。
版本号从 tag 取
用 v1.2.0 这样的 tag 触发打包的,版本号可以直接从 tag 名取,GitHub Actions 里去掉开头的 v。
1kxbuild build ./MyApp.xcodeproj -c Release --export app-store -D MARKETING_VERSION="${GITHUB_REF_NAME#v}" -D CURRENT_PROJECT_VERSION="$GITHUB_RUN_NUMBER"
不用 tag 的,把版本号留在工程里,发新版本前在工程设置编辑器的 General 页手动改,构建时只覆盖构建号。
App Store Connect API 密钥怎么创建?
API 密钥让脚本代表你的团队调用 App Store Connect,不需要 Apple 账号密码,也不需要两步验证码,适合在流水线里用。
第一次使用先申请访问权限
团队第一次使用 App Store Connect API 时,要由账户持有人在「用户和访问 → 集成 → App Store Connect API」里申请访问权限,同意条款后才能生成密钥。
生成团队密钥
- 用账户持有人或管理角色登录 App Store Connect,进入「用户和访问」,点「集成」,页面默认打开 App Store Connect API。
- 点「团队密钥」,再点「生成 API 密钥」。
- 名称填一个能看出用途的名字,例如
github-actions-upload。 - 「访问」选择角色,只用来上传构建的选 App 管理。
- 点「生成」,新密钥出现在列表里。
记下三样东西。
- Issuer ID,显示在密钥列表上方,整个团队共用一个。
- 密钥 ID(Key ID),列表里每个密钥一行。
- 私钥文件,点「下载 API 密钥」得到
AuthKey_<密钥 ID>.p8。
私钥只能下载一次,Apple 不保留副本,下载链接用过就消失。文件丢了只能撤销这个密钥再生成一个新的。
生成后名称和访问角色也不能修改,要改同样是撤销重建。团队密钥对账号下所有 App 都有效,不能限定到某一个 App,所以要像证书私钥一样保管。完整说明见 Apple 的 App Store Connect API 帮助页和创建 API 密钥文档。
密钥泄露或员工离职时,在同一页点「撤销」,撤销后立即失效且不能恢复。
哪些工具能自动上传 ipa?
| 方式 | 运行在 | 认证 | 适合 |
|---|---|---|---|
| Appuploader | Windows | 登录开发者账号 | 手动上传 |
| Transporter App | macOS | 登录 Apple 账号 | 手动上传,把 ipa 拖进去 |
| 命令行版 Transporter(iTMSTransporter) | macOS、Windows、Linux | API 密钥,或账号加 App 专用密码 | 流水线自动上传 |
手动上传的做法见发布与上架。流水线里用命令行版 Transporter。
截至 2026 年 10 月,Apple 的 Transporter 用户指南列出的支持系统是 macOS 10.11 及以上、64 位 Windows 11 及以上和 64 位 Red Hat Enterprise Linux,Windows 版是自解压安装程序,Linux 版是一个 shell 脚本,下载地址在指南的 Install Transporter 部分。
指南列出的 Linux 只有 Red Hat Enterprise Linux。Ubuntu 等其他发行版不在 Apple 的支持列表里,能否正常运行要你自己验证。最稳妥的做法是把 Transporter 装在一台固定的构建机上,Windows 11 或 RHEL 都可以,注册成自托管 runner 专门负责上传。
用 API 密钥上传
放好私钥
Transporter 按 -apiKey 给出的密钥 ID 找 AuthKey_<密钥 ID>.p8,依次在下面几个目录里查找,文件名必须原样保留。
- 当前目录下的
private_keys - 用户主目录下的
private_keys - 用户主目录下的
.private_keys - 用户主目录下的
.appstoreconnect/private_keys
流水线里用第一个位置,在工作目录里临时建 private_keys,上传完删除。
上传命令
1iTMSTransporter -m upload -assetFile out/MyApp.ipa -assetDescription out/MyApp.AppStoreInfo.plist -apiKey "$ASC_KEY_ID" -apiIssuer "$ASC_ISSUER_ID"
| 参数 | 作用 |
|---|---|
-m upload |
上传模式 |
-assetFile |
要上传的 ipa。上传 App 要用 -assetFile,不能用 -f |
-assetDescription |
ipa 的资源描述 plist,在 Windows 和 Linux 上上传 App 时必填 |
-apiKey |
密钥 ID |
-apiIssuer |
Issuer ID |
-assetDescription 要的 AppStoreInfo.plist,KXBuild 用 --export app-store 构建时已经生成在 ipa 旁边,用 --ipa ./out/MyApp.ipa 时就是 ./out/MyApp.AppStoreInfo.plist,见构建与配置的产物位置一节。ipa 和这个 plist 要来自同一次构建,换了 ipa 就要换配套的 plist。
上传成功后 Apple 要处理几分钟到几十分钟,处理完构建出现在 App Store Connect 的 TestFlight 页。之后的出口合规、内部和外部测试见 TestFlight 内测分发教程。
接进 GitHub Actions
添加 Secrets
在仓库的 Settings → Secrets and variables → Actions 里再加三个 Secret。.p8 本身是文本文件,用记事本打开,把全部内容(包括 BEGIN 和 END 两行)粘进去即可,不用转 base64。
| Secret 名称 | 内容 |
|---|---|
| ASC_KEY_ID | 密钥 ID |
| ASC_ISSUER_ID | Issuer ID |
| ASC_KEY_P8 | AuthKey_<密钥 ID>.p8 的全部文本 |
完整 workflow
下面的 workflow 分两个 job。build 在装好 KXBuild 的自托管 Linux runner 上出 ipa,和 183 篇教程相同,只是构建号换成了带尝试次数的写法;upload 在装好 Transporter 的自托管 runner 上运行,下载 build 产出的 artifact 后上传。装 Transporter 的那台 runner 注册时加一个 transporter 标签,并把 Transporter 的 bin 目录加进 runner 用户的 PATH。
1name: iOS release
2
3on:
4 push:
5 tags: ["v*"]
6 workflow_dispatch:
7
8jobs:
9 build:
10 runs-on: [self-hosted, linux]
11 timeout-minutes: 60
12 env:
13 KXBUILD_NO_UPDATE_CHECK: "1"
14 steps:
15 - uses: actions/checkout@v4
16
17 - name: Add KXBuild to PATH
18 run: |
19 echo "KXBUILD_HOME=$HOME/kxbuild" >> "$GITHUB_ENV"
20 echo "$HOME/kxbuild/bin" >> "$GITHUB_PATH"
21
22 - name: Prepare signing
23 env:
24 IOS_P12_BASE64: ${{ secrets.IOS_P12_BASE64 }}
25 IOS_P12_PASSWORD: ${{ secrets.IOS_P12_PASSWORD }}
26 IOS_PROFILE_BASE64: ${{ secrets.IOS_PROFILE_BASE64 }}
27 run: |
28 KC="$RUNNER_TEMP/ci-keychain"
29 mkdir -p "$KC"
30 printf '%s' "$IOS_P12_BASE64" | base64 -d > "$KC/distribution.p12"
31 printf '%s' "$IOS_PROFILE_BASE64" | base64 -d > "$KC/MyApp_AppStore.mobileprovision"
32 printf 'distribution.p12=%s\n' "$IOS_P12_PASSWORD" > "$KC/password.properties"
33 echo "KC=$KC" >> "$GITHUB_ENV"
34
35 - name: Build ipa
36 run: |
37 kxbuild version
38 kxbuild build ./MyApp.xcworkspace -s MyApp -c Release --export app-store --clean \
39 --derivedDataDir ./out --skip-package-updates --ipa ./out/MyApp.ipa \
40 -D CODE_SIGN_KEYCHAIN="$KC" \
41 -D MARKETING_VERSION="${GITHUB_REF_NAME#v}" \
42 -D CURRENT_PROJECT_VERSION="${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}"
43
44 - uses: actions/upload-artifact@v4
45 with:
46 name: ios-ipa
47 path: |
48 out/MyApp.ipa
49 out/MyApp.AppStoreInfo.plist
50 retention-days: 30
51 if-no-files-found: error
52
53 - name: Clean up signing files
54 if: always()
55 run: rm -rf "$RUNNER_TEMP/ci-keychain"
56
57 upload:
58 needs: build
59 runs-on: [self-hosted, linux, transporter]
60 steps:
61 - uses: actions/download-artifact@v4
62 with:
63 name: ios-ipa
64 path: out
65
66 - name: Upload to App Store Connect
67 env:
68 ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
69 ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
70 ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
71 run: |
72 mkdir -p private_keys
73 printf '%s\n' "$ASC_KEY_P8" > "private_keys/AuthKey_${ASC_KEY_ID}.p8"
74 iTMSTransporter -m upload \
75 -assetFile out/MyApp.ipa \
76 -assetDescription out/MyApp.AppStoreInfo.plist \
77 -apiKey "$ASC_KEY_ID" \
78 -apiIssuer "$ASC_ISSUER_ID"
79
80 - name: Remove private key
81 if: always()
82 run: rm -rf private_keys
几点说明。
MARKETING_VERSION从 tag 取,只适合 tag 触发。手动触发(workflow_dispatch)时GITHUB_REF_NAME是分支名,删掉这一行,版本号用工程里的。- 自托管 runner 的工作目录会保留到下一次运行,最后一步
if: always()保证私钥文件不会留在机器上。 - 构建机本身就是装了 KXBuild 和 Transporter 的自托管 runner 的,两个 job 可以合成一个,把上传 step 直接接在 Build ipa 后面,省掉 artifact 的上传和下载。
Windows 自托管 runner 的上传 step
Transporter 装在 Windows 11 构建机上的,upload job 的 runs-on 改成 [self-hosted, windows, transporter],上传 step 换成 PowerShell 写法。
1 - name: Upload to App Store Connect
2 shell: pwsh
3 env:
4 ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
5 ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
6 ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
7 run: |
8 New-Item -ItemType Directory -Force private_keys | Out-Null
9 Set-Content -Encoding ascii "private_keys/AuthKey_$env:ASC_KEY_ID.p8" $env:ASC_KEY_P8
10 iTMSTransporter -m upload -assetFile out/MyApp.ipa -assetDescription out/MyApp.AppStoreInfo.plist -apiKey $env:ASC_KEY_ID -apiIssuer $env:ASC_ISSUER_ID
11 if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
12
13 - name: Remove private key
14 if: always()
15 shell: pwsh
16 run: Remove-Item -Recurse -Force private_keys -ErrorAction SilentlyContinue
最后检查 $LASTEXITCODE,保证上传失败时这一步也标为失败。Jenkins 和 GitLab CI 的写法相同,私钥用 Secret file 或 File 类型变量传入,复制到 private_keys 目录,见 139 篇教程的上传 TestFlight 一节。
上传报错怎么处理?
| 现象 | 原因 | 处理 |
|---|---|---|
| 上传被拒,提示构建号已存在或必须更大 | 构建号重复,常见于 Re-run 或多条流水线共用一个 App | 用「运行序号.尝试次数」或时间作构建号,必要时加偏移量 |
| 上传后版本号、构建号还是旧的 | Info.plist 里写死了数字,-D 不生效 |
把 CFBundleVersion 改成 $(CURRENT_PROJECT_VERSION),扩展的 Info.plist 也要改 |
| 认证失败 | 密钥 ID 或 Issuer ID 填错,或者密钥已被撤销 | 到「用户和访问 → 集成」核对,撤销的密钥要重新生成 |
| 找不到私钥 | .p8 文件名不是 AuthKey_<密钥 ID>.p8,或不在 Transporter 查找的目录里 |
文件名和 -apiKey 的值保持一致,放进当前目录的 private_keys |
| 提示缺少资源描述 | Windows、Linux 上没有传 -assetDescription |
加上构建时生成的 AppStoreInfo.plist |
| 权限不足 | 密钥的访问角色太低 | 新建一个 App 管理角色的团队密钥 |
iTMSTransporter: command not found |
Transporter 的 bin 目录不在 runner 用户的 PATH 里 | 在 runner 的环境变量里加上,或在命令里写完整路径 |
自动上传 ipa 之前,Apple 还会检查签名、描述文件和图标,这类报错的处理见发布与上架的上传前检查,签名相关的错误对照 iOS 签名报错与 ipa 安装失败。
常见问题
iOS 构建号可以用时间戳吗?
可以,但要符合 CFBundleVersion 的格式,一到三个用点分隔的整数。推荐写成「年月日.时分」,例如 20261019.1530,不要用带横线或冒号的日期字符串。
Windows 上能用 App Store Connect API 密钥上传 ipa 吗?
能。Apple 的命令行版 Transporter 支持 64 位 Windows 11 及以上,用 -apiKey 和 -apiIssuer 认证,Windows 上上传 App 还要用 -assetDescription 指定 AppStoreInfo.plist。只想手动上传的,用 Appuploader 更简单。
App Store Connect API 的 .p8 私钥丢了怎么办?
私钥只能下载一次,Apple 不保留副本,无法找回。在「用户和访问 → 集成」里撤销这个密钥,再生成一个新的,并更新 CI 里的密钥 ID 和私钥。
API 密钥应该选什么角色?
只用来上传构建的,选 App 管理即可。团队密钥对账号下所有 App 都有效,角色越高能做的事越多,不要为了省事选管理角色。
在 GitHub Actions 里重新运行为什么提示构建号重复?
Re-run 时 GITHUB_RUN_NUMBER 不变,上一次尝试已经上传过同一个构建号。把构建号写成 ${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT},每次重跑第二段会加一。