用 Jenkins、GitLab CI 在 Windows 或 Linux 上自动打包 iOS
iOS 持续集成通常要专门准备一台 Mac 做构建机。用 KXBuild 可以把现有的 Windows 或 Linux 服务器接进 Jenkins、GitLab CI,每次提交或打 tag 自动出签好名的 ipa。本文从准备构建机讲到上传 TestFlight,需要准备 Apple 开发者账号、Apple Distribution 证书和 App Store 描述文件,以及一台能跑 Jenkins 节点或 GitLab Runner 的机器。
准备构建机
在构建机上按安装页装好 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 update,不要写进每次构建,免得两次构建用了不同版本的工具链。目录和环境变量的完整说明见目录与环境变量。
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 --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 目录下的工程。
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 不支持。原来「archive 再 exportArchive」的两步,换成一条 kxbuild build -c Release 直接得到签好名的 ipa。
构建号自动递增
App Store Connect 要求同一版本下每次上传的构建号(CFBundleVersion)比上一次大。流水线里用 -D 覆盖 CURRENT_PROJECT_VERSION,取 CI 自带的递增编号,不用改工程文件,详见临时覆盖构建设置。
1kxbuild 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 示例
下面两个 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 --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
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 --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"
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 示例
在 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 --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"
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 的结构和 Xcode 导出的一样,可以直接上传。
给测试人员直接安装的包,把描述文件换成 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 里。