iOS 持续集成通常要专门准备一台 Mac 做构建机。用 KXBuild 可以把现有的 Windows 或 Linux 服务器接进 Jenkins、GitLab CI,每次提交或打 tag 自动出签好名的 ipa。本文从准备构建机讲到上传 TestFlight,需要准备 Apple 开发者账号、Apple Distribution 证书和 App Store 描述文件,以及一台能跑 Jenkins 节点或 GitLab Runner 的机器。
准备构建机 {#prepare-agent}
在构建机上按安装页装好 KXBuild 并登录账号,然后新开一个终端运行体检,全部通过即可。Linux 用安装页的 install.sh 一条命令安装。Windows 服务器选「直接在 Windows 安装」,这样 Jenkins 和 GitLab Runner 启动的 PowerShell 能直接调用 kxbuild。
bash
kxbuild doctor

安装脚本会把安装目录写进当前用户 的环境变量。Jenkins 节点和 GitLab Runner 要用同一个用户运行,否则流水线里找不到 kxbuild。以服务方式运行的,在服务属性里把登录身份改成这个用户;做不到的,在节点或 Runner 的环境变量里加上 KXBUILD_HOME 和它下面的 bin 目录。流水线第一步运行一次 kxbuild version,能打印版本号就说明路径没问题。
再给构建机设置一个环境变量 KXBUILD_NO_UPDATE_CHECK=1,关掉新版本提示和后台检查。升级放在维护时间手动执行 kxbuild update,不要写进每次构建,免得两次构建用了不同版本的工具链。目录和环境变量的完整说明见目录与环境变量。
Windows 构建机把 Jenkins 工作区或 Runner 的 builds 目录放在短路径上,例如 D:\ci,React Native 这类依赖层级很深的工程超过 260 字符会报错。
证书和描述文件交给流水线 {#signing-secrets}
签名要三样东西,发布证书 .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 和描述文件的方法见证书与签名。
构建命令 {#build-command}
流水线里的出包命令用 kxbuild build,参数含义见 kxbuild 命令参考。
powershell
kxbuild build ./MyApp.xcworkspace -s MyApp -c Release --clean --derivedDataDir ./out --skip-package-updates --ipa ./out/MyApp.ipa -D CODE_SIGN_KEYCHAIN="D:/ci/myapp/ci-keychain" -D CODE_SIGN_IDENTITY="Apple Distribution"
--derivedDataDir ./out把构建输出固定在工作区里,--clean每次从干净目录开始。--skip-package-updates让 Swift Package 严格按 Package.resolved 的版本解析、不访问远程仓库,Package.resolved 要提交进仓库。--ipa指定 ipa 的位置,旁边会同时生成 MyApp.AppStoreInfo.plist,上传 App Store Connect 时用得到。-D CODE_SIGN_IDENTITY="Apple Distribution"在工程的 Release 配置没有设发布证书时临时覆盖,工程里已经设好的可以去掉。
命令成功退出码为 0,失败为非 0,Jenkins 和 GitLab 据此判断构建是否失败。想在脚本里解析结果可以加 --json,每行输出一条 JSON 事件;排查构建失败时去掉它,错误详情只在普通输出里。
用 CocoaPods 的工程,构建前先在工程目录运行 pod install,构建 .xcworkspace,见 CocoaPods。Flutter、React Native、Expo、Kotlin Multiplatform 工程在构建前先运行一次带工程目录的体检,它会检查框架环境,并准备好 iOS 端的工程,之后再按对应技术栈页的方法安装依赖、构建 ios 目录下的工程。
bash
kxbuild doctor ./my_flutter_app
已有的 xcodebuild 脚本 {#xcodebuild-scripts}
构建机上的 KXBuild 自带和 macOS 同名的 xcodebuild 和 xcrun,原来在 Mac 上跑的构建脚本、框架自带的构建脚本调用它们时不用改。支持 build、clean、archive 等动作和 -workspace、-scheme、-configuration、-sdk、-derivedDataPath 以及 KEY=VALUE 覆盖等常用参数,完整列表见 xcodebuild / xcrun 兼容。
bash
xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Release -sdk iphoneos -derivedDataPath ./out
需要改的只有导出 ipa 这一步,-exportArchive 不支持。原来「archive 再 exportArchive」的两步,换成一条 kxbuild build -c Release 直接得到签好名的 ipa。
构建号自动递增 {#build-number}
App Store Connect 要求同一版本下每次上传的构建号(CFBundleVersion)比上一次大。流水线里用 -D 覆盖 CURRENT_PROJECT_VERSION,取 CI 自带的递增编号,不用改工程文件,详见临时覆盖构建设置。
powershell
kxbuild build ./MyApp.xcodeproj -c Release -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 示例 {#jenkins}
下面两个 Jenkinsfile 分别用于 Windows 节点和 Linux 节点,流程相同。先在 Jenkins 的凭据管理里按上面的表创建三条凭据,ID 分别为 ios-p12、ios-p12-password、ios-profile。
Windows 节点 {#jenkins-windows}
groovy
pipeline {
agent { label 'ios-windows' }
environment {
KXBUILD_NO_UPDATE_CHECK = '1'
}
stages {
stage('Build') {
steps {
withCredentials([
file(credentialsId: 'ios-p12', variable: 'IOS_P12'),
string(credentialsId: 'ios-p12-password', variable: 'IOS_P12_PASSWORD'),
file(credentialsId: 'ios-profile', variable: 'IOS_PROFILE')
]) {
powershell '''
kxbuild version
$kc = Join-Path $env:WORKSPACE 'ci-keychain'
New-Item -ItemType Directory -Force $kc | Out-Null
Copy-Item $env:IOS_P12 "$kc/distribution.p12"
Copy-Item $env:IOS_PROFILE "$kc/MyApp_AppStore.mobileprovision"
"distribution.p12=$env:IOS_P12_PASSWORD" | Set-Content -Encoding ascii "$kc/password.properties"
kxbuild build ./MyApp.xcworkspace -s MyApp -c Release --clean --derivedDataDir ./out --skip-package-updates --ipa ./out/MyApp.ipa -D CODE_SIGN_KEYCHAIN="$kc" -D CODE_SIGN_IDENTITY="Apple Distribution" -D CURRENT_PROJECT_VERSION=$env:BUILD_NUMBER
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
'''
}
}
}
}
post {
success {
archiveArtifacts artifacts: 'out/*.ipa, out/*.AppStoreInfo.plist', fingerprint: true
}
always {
powershell 'Remove-Item -Recurse -Force ci-keychain -ErrorAction SilentlyContinue'
}
}
}
脚本最后检查 $LASTEXITCODE,保证 kxbuild 失败时这一步也标为失败。
Linux 节点 {#jenkins-linux}
groovy
pipeline {
agent { label 'ios-linux' }
environment {
KXBUILD_NO_UPDATE_CHECK = '1'
}
stages {
stage('Build') {
steps {
withCredentials([
file(credentialsId: 'ios-p12', variable: 'IOS_P12'),
string(credentialsId: 'ios-p12-password', variable: 'IOS_P12_PASSWORD'),
file(credentialsId: 'ios-profile', variable: 'IOS_PROFILE')
]) {
sh '''
set -e
kxbuild version
KC="$WORKSPACE/ci-keychain"
mkdir -p "$KC"
cp "$IOS_P12" "$KC/distribution.p12"
cp "$IOS_PROFILE" "$KC/MyApp_AppStore.mobileprovision"
printf 'distribution.p12=%s\\n' "$IOS_P12_PASSWORD" > "$KC/password.properties"
kxbuild build ./MyApp.xcworkspace -s MyApp -c Release --clean --derivedDataDir ./out --skip-package-updates --ipa ./out/MyApp.ipa -D CODE_SIGN_KEYCHAIN="$KC" -D CODE_SIGN_IDENTITY="Apple Distribution" -D CURRENT_PROJECT_VERSION="$BUILD_NUMBER"
'''
}
}
}
}
post {
success {
archiveArtifacts artifacts: 'out/*.ipa, out/*.AppStoreInfo.plist', fingerprint: true
}
always {
sh 'rm -rf ci-keychain'
}
}
}
脚本用 ''' 单引号字符串,密码由 shell 展开,不经过 Groovy 插值,Jenkins 不会因此提示泄露风险。printf 里的 \\n 在 Groovy 字符串中写成两个反斜杠,传给 shell 的是 \n。
GitLab CI 示例 {#gitlab-ci}
在 GitLab 项目的 Settings → CI/CD → Variables 按上面的表添加三个变量。File 类型的变量在作业里展开为一个临时文件的路径。Runner 用 shell 执行器,注册时打上 ios-linux 或 ios-windows 标签。
yaml
stages:
- build
variables:
KXBUILD_NO_UPDATE_CHECK: "1"
build-ios:
stage: build
tags: [ios-linux]
rules:
- if: $CI_COMMIT_TAG
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- kxbuild version
- export KC="$CI_PROJECT_DIR/ci-keychain"
- mkdir -p "$KC"
- cp "$IOS_P12" "$KC/distribution.p12"
- cp "$IOS_PROFILE" "$KC/MyApp_AppStore.mobileprovision"
- printf 'distribution.p12=%s\n' "$IOS_P12_PASSWORD" > "$KC/password.properties"
- kxbuild build ./MyApp.xcworkspace -s MyApp -c Release --clean --derivedDataDir ./out --skip-package-updates --ipa ./out/MyApp.ipa -D CODE_SIGN_KEYCHAIN="$KC" -D CODE_SIGN_IDENTITY="Apple Distribution" -D CURRENT_PROJECT_VERSION="$CI_PIPELINE_IID"
after_script:
- rm -rf "$CI_PROJECT_DIR/ci-keychain"
artifacts:
paths:
- out/*.ipa
- out/*.AppStoreInfo.plist
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 产物 {#archive}
上面两个示例都把 out/*.ipa 和 out/*.AppStoreInfo.plist 作为构建产物保存,在 Jenkins 的构建页或 GitLab 的作业页可以直接下载。ipa 的结构和 Xcode 导出的一样,可以直接上传。
给测试人员直接安装的包,把描述文件换成 Ad Hoc 类型,其余步骤不变,见发布与上架中的「不上架,直接分发」。一次流水线同时出 App Store 包和 Ad Hoc 包时,两份描述文件都放进临时证书目录,分两次构建,用 --ipa 放到不同文件名。
上传到 TestFlight {#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,下载 Auth_Key_.p8。.p8 只能下载一次,和 .p12 一样存进 CI 的 Secret file,上传前复制到工作目录的 private_keys 子目录。
bash
mkdir -p private_keys
cp "$ASC_KEY_FILE" "private_keys/Auth_Key_${ASC_KEY_ID}.p8"
iTMSTransporter -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-actions}
GitHub 托管的 Windows 和 Linux runner 每次都是全新环境,工具链需要重新下载。用 GitHub Actions 时建议在自己的构建机上注册自托管 runner,按本文第一节准备好,workflow 里的步骤和上面 GitLab 示例的 script 相同,证书和密码放在仓库的 Secrets 里。更多教程可以查看官网:https://www.kxbuild.net/zh