跳到主要内容
教程目录

iOS 自动打包:Jenkins、GitLab CI 在 Windows、Linux 上自动打包 iOS

最后更新

iOS 自动打包可以放在现有的 Windows 或 Linux 服务器上:装好 KXBuild 后接进 Jenkins 或 GitLab CI,证书和密码放进 CI 的密钥管理,流水线里一条 kxbuild build -c Release --export app-store 出签好名的 ipa,构建号取 CI 的递增编号,再用 iTMSTransporter 自动上传 TestFlight。原有的 xcodebuild 脚本照常可用。

iOS 自动打包的构建机怎么准备?

在构建机上按安装页装好 KXBuild 并登录账号,然后新开一个终端运行体检,全部通过即可。Linux 用安装页的 install.sh 一条命令安装。Windows 服务器选「直接在 Windows 安装」,这样 Jenkins 和 GitLab Runner 启动的 PowerShell 能直接调用 kxbuild。

1kxbuild doctor

安装脚本会把安装目录写进当前用户的环境变量。Jenkins 节点和 GitLab Runner 要用同一个用户运行,否则流水线里找不到 kxbuild。以服务方式运行的,在服务属性里把登录身份改成这个用户;做不到的,在节点或 Runner 的环境变量里加上 KXBUILD_HOME 和它下面的 bin 目录。流水线第一步运行一次 kxbuild version,能打印版本号就说明路径没问题。

再给构建机设置一个环境变量 KXBUILD_NO_UPDATE_CHECK=1,关掉新版本提示和后台检查。升级放在维护时间手动执行 kxbuild doctor,不要写进每次构建,免得两次构建用了不同版本的工具链。目录和环境变量的完整说明见目录与环境变量。

Windows 构建机把 Jenkins 工作区或 Runner 的 builds 目录放在短路径上,例如 D:\ci,React Native 这类依赖层级很深的工程超过 260 字符会报错。

证书和描述文件怎么交给流水线?

签名要三样东西,发布证书 .p12、它的密码、App Store 描述文件。证书和密码不要提交进仓库,放进 CI 系统的密钥管理里,构建时临时写进一个证书目录,用构建设置 CODE_SIGN_KEYCHAIN 指向它,构建结束后删除。描述文件本身不含私钥,也可以直接提交在仓库里。

名称 Jenkins 凭据类型 GitLab 变量类型 内容
IOS_P12 Secret file File Apple Distribution 证书 .p12
IOS_P12_PASSWORD Secret text Variable,勾选 Masked 和 Protected .p12 的密码
IOS_PROFILE Secret file File App Store 类型的 .mobileprovision

临时证书目录里的结构和证书与签名一致,.p12 和描述文件放在一起,password.properties 每行写一条「文件名=密码」,文件名必须和 .p12 完全一致。

专用构建机只给一个团队用的话,也可以把证书一次性放进构建机的 KXBUILD_HOME\keychain,所有工程共用,流水线里就不用处理证书。代价是任何能在这台机器上跑流水线的人都能拿到私钥。

生成 .p12 和描述文件的方法见证书与签名。

流水线里用什么命令打包?

流水线里的出包命令用 kxbuild build,参数含义见 kxbuild 命令参考。

1kxbuild build ./MyApp.xcworkspace -s MyApp -c Release --export app-store --clean --derivedDataDir ./out --skip-package-updates --ipa ./out/MyApp.ipa -D CODE_SIGN_KEYCHAIN="D:/ci/myapp/ci-keychain"
  • --derivedDataDir ./out 把构建输出固定在工作区里,--clean 每次从干净目录开始。
  • --skip-package-updates 让 Swift Package 严格按 Package.resolved 的版本解析、不访问远程仓库,Package.resolved 要提交进仓库。
  • --ipa 指定 ipa 的位置,旁边会同时生成 MyApp.AppStoreInfo.plist,上传 App Store Connect 时用得到。
  • --export app-store 用证书目录里的 Apple Distribution 证书和 App Store 描述文件签名,不管工程 Release 配置里设的是哪种证书,得到的 ipa 可以直接上传 App Store Connect。

命令成功退出码为 0,失败为非 0,Jenkins 和 GitLab 据此判断构建是否失败。想在脚本里解析结果可以加 --json,每行输出一条 JSON 事件;排查构建失败时去掉它,错误详情只在普通输出里。

用 CocoaPods 的工程,构建前先在工程目录运行 pod install,构建 .xcworkspace,见 CocoaPods。

Flutter、React Native、Expo、Kotlin Multiplatform 工程在构建前先运行一次带工程目录的体检,它会检查框架环境,并准备好 iOS 端的工程,之后再按对应技术栈页的方法安装依赖、构建 ios 目录下的工程。

1kxbuild doctor ./my_flutter_app

已有的 xcodebuild 脚本

构建机上的 KXBuild 自带和 macOS 同名的 xcodebuild 和 xcrun,原来在 Mac 上跑的构建脚本、框架自带的构建脚本调用它们时不用改。

支持 build、clean、archive 等动作和 -workspace、-scheme、-configuration、-sdk、-derivedDataPath 以及 KEY=VALUE 覆盖等常用参数,完整列表见 xcodebuild / xcrun 兼容。

1xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Release -sdk iphoneos -derivedDataPath ./out

导出 ipa 的 -exportArchive 也照常可用,按 ExportOptions.plist 的 method 用证书目录里的证书和描述文件签名。也可以把「archive 再 exportArchive」的两步换成一条 kxbuild build -c Release --export app-store,直接得到签好名的 ipa。

构建号怎么自动递增?

App Store Connect 要求同一版本下每次上传的构建号(CFBundleVersion)比上一次大。流水线里用 -D 覆盖 CURRENT_PROJECT_VERSION,取 CI 自带的递增编号,不用改工程文件,详见临时覆盖构建设置。

1kxbuild build ./MyApp.xcodeproj -c Release --export app-store -D MARKETING_VERSION=1.2.0 -D CURRENT_PROJECT_VERSION=45

Jenkins 里用 BUILD_NUMBER,GitLab 里用 CI_PIPELINE_IID(项目内递增的流水线编号)。版本号 MARKETING_VERSION 可以从 git tag 取,也可以留在工程里手动改。

这种写法要求 Info.plist 的 CFBundleVersion 写成 $(CURRENT_PROJECT_VERSION)、CFBundleShortVersionString 写成 $(MARKETING_VERSION),Xcode 13 之后新建的工程默认如此。老工程里写死数字的,用属性列表编辑器改一次并提交。

Jenkins 怎么配置 iOS 自动打包?

下面两个 Jenkinsfile 分别用于 Windows 节点和 Linux 节点,流程相同。先在 Jenkins 的凭据管理里按上面的表创建三条凭据,ID 分别为 ios-p12、ios-p12-password、ios-profile。

Windows 节点

 1pipeline {
 2  agent { label 'ios-windows' }
 3  environment {
 4    KXBUILD_NO_UPDATE_CHECK = '1'
 5  }
 6  stages {
 7    stage('Build') {
 8      steps {
 9        withCredentials([
10          file(credentialsId: 'ios-p12', variable: 'IOS_P12'),
11          string(credentialsId: 'ios-p12-password', variable: 'IOS_P12_PASSWORD'),
12          file(credentialsId: 'ios-profile', variable: 'IOS_PROFILE')
13        ]) {
14          powershell '''
15            kxbuild version
16            $kc = Join-Path $env:WORKSPACE 'ci-keychain'
17            New-Item -ItemType Directory -Force $kc | Out-Null
18            Copy-Item $env:IOS_P12 "$kc/distribution.p12"
19            Copy-Item $env:IOS_PROFILE "$kc/MyApp_AppStore.mobileprovision"
20            "distribution.p12=$env:IOS_P12_PASSWORD" | Set-Content -Encoding ascii "$kc/password.properties"
21            kxbuild build ./MyApp.xcworkspace -s MyApp -c Release --export app-store --clean --derivedDataDir ./out --skip-package-updates --ipa ./out/MyApp.ipa -D CODE_SIGN_KEYCHAIN="$kc" -D CURRENT_PROJECT_VERSION=$env:BUILD_NUMBER
22            if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
23          '''
24        }
25      }
26    }
27  }
28  post {
29    success {
30      archiveArtifacts artifacts: 'out/*.ipa, out/*.AppStoreInfo.plist', fingerprint: true
31    }
32    always {
33      powershell 'Remove-Item -Recurse -Force ci-keychain -ErrorAction SilentlyContinue'
34    }
35  }
36}

脚本最后检查 $LASTEXITCODE,保证 kxbuild 失败时这一步也标为失败。

Linux 节点

 1pipeline {
 2  agent { label 'ios-linux' }
 3  environment {
 4    KXBUILD_NO_UPDATE_CHECK = '1'
 5  }
 6  stages {
 7    stage('Build') {
 8      steps {
 9        withCredentials([
10          file(credentialsId: 'ios-p12', variable: 'IOS_P12'),
11          string(credentialsId: 'ios-p12-password', variable: 'IOS_P12_PASSWORD'),
12          file(credentialsId: 'ios-profile', variable: 'IOS_PROFILE')
13        ]) {
14          sh '''
15            set -e
16            kxbuild version
17            KC="$WORKSPACE/ci-keychain"
18            mkdir -p "$KC"
19            cp "$IOS_P12" "$KC/distribution.p12"
20            cp "$IOS_PROFILE" "$KC/MyApp_AppStore.mobileprovision"
21            printf 'distribution.p12=%s\\n' "$IOS_P12_PASSWORD" > "$KC/password.properties"
22            kxbuild build ./MyApp.xcworkspace -s MyApp -c Release --export app-store --clean --derivedDataDir ./out --skip-package-updates --ipa ./out/MyApp.ipa -D CODE_SIGN_KEYCHAIN="$KC" -D CURRENT_PROJECT_VERSION="$BUILD_NUMBER"
23          '''
24        }
25      }
26    }
27  }
28  post {
29    success {
30      archiveArtifacts artifacts: 'out/*.ipa, out/*.AppStoreInfo.plist', fingerprint: true
31    }
32    always {
33      sh 'rm -rf ci-keychain'
34    }
35  }
36}

脚本用 ''' 单引号字符串,密码由 shell 展开,不经过 Groovy 插值,Jenkins 不会因此提示泄露风险。printf 里的 \\n 在 Groovy 字符串中写成两个反斜杠,传给 shell 的是 \n。

GitLab CI 怎么配置 iOS 自动打包?

在 GitLab 项目的 Settings → CI/CD → Variables 按上面的表添加三个变量。File 类型的变量在作业里展开为一个临时文件的路径。Runner 用 shell 执行器,注册时打上 ios-linux 或 ios-windows 标签。

 1stages:
 2  - build
 3
 4variables:
 5  KXBUILD_NO_UPDATE_CHECK: "1"
 6
 7build-ios:
 8  stage: build
 9  tags: [ios-linux]
10  rules:
11    - if: $CI_COMMIT_TAG
12    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
13  script:
14    - kxbuild version
15    - export KC="$CI_PROJECT_DIR/ci-keychain"
16    - mkdir -p "$KC"
17    - cp "$IOS_P12" "$KC/distribution.p12"
18    - cp "$IOS_PROFILE" "$KC/MyApp_AppStore.mobileprovision"
19    - printf 'distribution.p12=%s\n' "$IOS_P12_PASSWORD" > "$KC/password.properties"
20    - kxbuild build ./MyApp.xcworkspace -s MyApp -c Release --export app-store --clean --derivedDataDir ./out --skip-package-updates --ipa ./out/MyApp.ipa -D CODE_SIGN_KEYCHAIN="$KC" -D CURRENT_PROJECT_VERSION="$CI_PIPELINE_IID"
21  after_script:
22    - rm -rf "$CI_PROJECT_DIR/ci-keychain"
23  artifacts:
24    paths:
25      - out/*.ipa
26      - out/*.AppStoreInfo.plist
27    expire_in: 30 days

Windows 上的 Runner 执行器 shell 设为 powershell 或 pwsh,script 部分换成 PowerShell 写法,和上面 Jenkins Windows 示例中 powershell 块里的命令相同,变量用 $env:IOS_P12 这种形式读取,构建号用 $env:CI_PIPELINE_IID。

GitLab 的 Masked 变量要求值至少 8 个字符,.p12 密码太短时设不上 Masked,建议导出 .p12 时就用足够长的密码。勾选 Protected 的变量只在受保护的分支和 tag 上可用,用 tag 触发打包时要在 Settings → Repository 中把对应的 tag 设为受保护。

ipa 产物怎么归档?

上面两个示例都把 out/*.ipa 和 out/*.AppStoreInfo.plist 作为构建产物保存,在 Jenkins 的构建页或 GitLab 的作业页可以直接下载。ipa 签好了名,可以直接上传。

给测试人员直接安装的包,把 --export app-store 换成 --export ad-hoc,临时证书目录里放 Ad Hoc 描述文件,见发布与上架中的「不上架,直接分发」。一次流水线同时出 App Store 包和 Ad Hoc 包时,两份描述文件都放进临时证书目录,分两次构建,用 --ipa 放到不同文件名。

怎么自动上传到 TestFlight?

手动上传用 Appuploader 或 Transporter,见发布与上架。要在流水线里自动上传,可以用 Apple 的命令行版 Transporter(iTMSTransporter),它有 Windows 和 Linux 版,在 App Store Connect 帮助页的 Transporter 部分下载,装在构建机上。

认证用 App Store Connect API 密钥,不需要账号密码和两步验证。在 App Store Connect 的「用户和访问 → 集成 → App Store Connect API」生成一个团队密钥,角色选 App 管理,记下 Key ID 和 Issuer ID,下载 AuthKey_.p8。.p8 只能下载一次,和 .p12 一样存进 CI 的 Secret file,上传前复制到工作目录的 private_keys 子目录。

1mkdir -p private_keys
2cp "$ASC_KEY_FILE" "private_keys/AuthKey_${ASC_KEY_ID}.p8"
3iTMSTransporter -m upload -assetFile out/MyApp.ipa -assetDescription out/MyApp.AppStoreInfo.plist -apiKey "$ASC_KEY_ID" -apiIssuer "$ASC_ISSUER_ID"

-assetDescription 用的就是构建时 ipa 旁边生成的 AppStoreInfo.plist。App Store Connect 里要先建好这个 App 的记录,Bundle ID 和包中一致。上传成功后 Apple 需要处理几分钟到几十分钟,构建才会出现在 TestFlight 页。

GitHub Actions 怎么用?

GitHub 托管的 Windows 和 Linux runner 每次都是全新环境,工具链需要重新下载。用 GitHub Actions 时建议在自己的构建机上注册自托管 runner,按本文第一节准备好,workflow 里的步骤和上面 GitLab 示例的 script 相同,证书和密码放在仓库的 Secrets 里。无论用哪种 CI,iOS 自动打包的核心都是同一条 kxbuild build 命令加临时证书目录。

常见问题

原来的 xcodebuild 构建脚本要改吗?

不用改。build、clean、archive、-exportArchive 和 -workspace、-scheme 等常用参数照常可用,证书和描述文件放进证书目录即可;想简化,可以把「archive 再 exportArchive」换成一条 kxbuild build -c Release --export app-store,完整参数见 xcodebuild / xcrun 兼容。

流水线里提示找不到 kxbuild 命令怎么办?

让 Jenkins 节点或 GitLab Runner 用安装 KXBuild 的那个用户运行。安装脚本只把路径写进当前用户的环境变量,做不到时在节点或 Runner 的环境变量里加上 KXBUILD_HOME 和它下面的 bin 目录。

自动上传 TestFlight 需要账号密码和两步验证吗?

不需要。用 App Store Connect API 密钥认证,记下 Key ID、Issuer ID 并保存 .p8 文件,iTMSTransporter 上传时传入即可。.p8 只能下载一次,要存进 CI 的 Secret file。

GitLab 的 .p12 密码变量为什么勾不上 Masked?

密码少于 8 个字符。GitLab 的 Masked 变量要求值至少 8 个字符,重新导出 .p12 时设一个足够长的密码。