跳到主要内容
教程目录

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」里申请访问权限,同意条款后才能生成密钥。

生成团队密钥

  1. 用账户持有人或管理角色登录 App Store Connect,进入「用户和访问」,点「集成」,页面默认打开 App Store Connect API。
  2. 点「团队密钥」,再点「生成 API 密钥」。
  3. 名称填一个能看出用途的名字,例如 github-actions-upload。
  4. 「访问」选择角色,只用来上传构建的选 App 管理。
  5. 点「生成」,新密钥出现在列表里。

记下三样东西。

  • 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,依次在下面几个目录里查找,文件名必须原样保留。

  1. 当前目录下的 private_keys
  2. 用户主目录下的 private_keys
  3. 用户主目录下的 .private_keys
  4. 用户主目录下的 .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},每次重跑第二段会加一。