KXBuild CI 实操,Windows/Linux 服务器接入 Jenkins 打包 iOS IPA

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

相关推荐
Lhan.zzZ1 小时前
用 Qt 构建线程安全的通讯静态库:从线程亲和性到完整工程
c++·vscode·qt·tcp/ip·udp
Gauss松鼠会1 小时前
GaussDB 整体架构解析
运维·服务器·网络·数据库·sql·架构·gaussdb
狗凯之家源码网1 小时前
iOS C2 V2 与 Python FastAPI 全链路远控系统深度评测
python·ios·fastapi
Gauss松鼠会1 小时前
GaussDB 中 gsql 与 DBeaver(JDBC)客户端工具查询时间不一致:原因、排查与处理
运维·服务器·数据库·sql·gaussdb·经验总结
Halo10132 小时前
VSCode设置护眼背景色
ide·vscode·编辑器
看浪的路人2 小时前
第6讲:Streaming 优化——告别“转圈圈“的用户体验
ios·iphone·xcode
谙弆悕博士2 小时前
debian快速换源
linux·运维·windows·debian·下载·换源·网络问题
.道阻且长.2 小时前
Linux基本开发工具:编译器gcc/g++
linux·运维·服务器·职场和发展
HideF11 小时前
Linux
linux·服务器·网络