Build iOS apps with Jenkins and GitLab CI on Windows or Linux
iOS continuous integration usually needs a dedicated Mac as the build machine. With KXBuild you can plug an existing Windows or Linux server into Jenkins or GitLab CI and get a signed ipa on every commit or tag. This tutorial goes from preparing the build machine to uploading to TestFlight. You need an Apple Developer account, an Apple Distribution certificate and an App Store provisioning profile, plus a machine that can run a Jenkins agent or GitLab Runner.
Prepare the build machine
Install KXBuild on the build machine as described on the install page and sign in to your account. Then open a new terminal and run doctor. When every check passes, you’re ready. On Linux, install with the install.sh one-liner from the install page. On a Windows server, choose “Install directly on Windows” so the PowerShell started by Jenkins or GitLab Runner can call kxbuild.
1kxbuild doctor
The installer adds the install folder to the current user’s environment variables. Run the Jenkins agent and GitLab Runner as that same user, or the pipeline won’t find kxbuild. If it runs as a service, set the service’s log on account to that user. If you can’t, add KXBUILD_HOME and its bin folder to the agent’s or Runner’s environment variables. Run kxbuild version as the first pipeline step. If it prints a version number, the path is fine.
Also set the environment variable KXBUILD_NO_UPDATE_CHECK=1 on the build machine to turn off update notices and background checks. Upgrade by running kxbuild update by hand during maintenance, not on every build, so two builds never use different toolchain versions. For all folders and environment variables, see Folders and environment variables.
On a Windows build machine, put the Jenkins workspace or the Runner’s builds folder on a short path such as D:\ci. Projects with deeply nested dependencies, such as React Native, fail when paths exceed 260 characters.
Give the pipeline your certificate and profile
Signing needs three things: the distribution certificate .p12, its password and the App Store provisioning profile. Don’t commit the certificate or password to the repository. Store them in your CI system’s secrets, write them to a temporary keychain folder during the build, point the CODE_SIGN_KEYCHAIN build setting at it, and delete it when the build ends. The provisioning profile contains no private key, so you can also commit it to the repository.
| Name | Jenkins credential type | GitLab variable type | Contents |
|---|---|---|---|
| IOS_P12 | Secret file | File | Apple Distribution certificate .p12 |
| IOS_P12_PASSWORD | Secret text | Variable, with Masked and Protected checked | The .p12 password |
| IOS_PROFILE | Secret file | File | App Store .mobileprovision |
The temporary keychain folder has the same layout as in Certificates and signing. The .p12 and profile go together, and password.properties has one filename=password per line. The file name must match the .p12 exactly.
If a dedicated build machine serves only one team, you can instead put the certificate once in the build machine’s KXBUILD_HOME\keychain, shared by all projects, so the pipeline doesn’t handle certificates at all. The trade-off is that anyone who can run a pipeline on that machine can get the private key.
To create the .p12 and provisioning profile, see Certificates and signing.
Build command
Use kxbuild build to produce the ipa in your pipeline. For what each option means, see kxbuild command reference.
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 ./outkeeps build output inside the workspace, and--cleanstarts from a clean folder every time.--skip-package-updatesresolves Swift packages strictly at the versions in Package.resolved without contacting remote repositories. Commit Package.resolved to the repository.--ipasets the ipa path. MyApp.AppStoreInfo.plist is written next to it, which you need when uploading to App Store Connect.-D CODE_SIGN_IDENTITY="Apple Distribution"overrides the certificate when the project’s Release configuration doesn’t set a distribution certificate. Drop it if the project already does.
The command exits with 0 on success and non-zero on failure, which Jenkins and GitLab use to decide whether the build failed. To parse results in a script, add --json to get one JSON event per line. Leave it off when investigating a failed build, because error details only appear in the normal output.
For CocoaPods projects, run pod install in the project folder before building, and build the .xcworkspace. See CocoaPods. For Flutter, React Native, Expo and Kotlin Multiplatform projects, run doctor once with the project folder before building. It checks the framework environment and prepares the iOS project. Then install dependencies and build the project in the ios folder as described on the page for that framework.
1kxbuild doctor ./my_flutter_app
Existing xcodebuild scripts
KXBuild on the build machine includes xcodebuild and xcrun with the same names as on macOS, so build scripts that ran on a Mac and build scripts that ship with frameworks can call them unchanged. Common actions such as build, clean and archive are supported, along with options such as -workspace, -scheme, -configuration, -sdk, -derivedDataPath and KEY=VALUE overrides. For the full list, see xcodebuild and xcrun compatibility.
1xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Release -sdk iphoneos -derivedDataPath ./out
The only step to change is exporting the ipa, since -exportArchive isn’t supported. Replace the two steps of archive then exportArchive with a single kxbuild build -c Release, which produces a signed ipa directly.
Increment the build number automatically
App Store Connect requires each upload of the same version to have a higher build number (CFBundleVersion) than the last. In the pipeline, override CURRENT_PROJECT_VERSION with -D using the CI system’s incrementing number, without changing the project file. See Override build settings for one build.
1kxbuild build ./MyApp.xcodeproj -c Release -D MARKETING_VERSION=1.2.0 -D CURRENT_PROJECT_VERSION=45
In Jenkins use BUILD_NUMBER, and in GitLab use CI_PIPELINE_IID (the pipeline number that increments within a project). The version MARKETING_VERSION can come from a git tag or stay in the project and be changed by hand.
This requires CFBundleVersion in Info.plist to be $(CURRENT_PROJECT_VERSION) and CFBundleShortVersionString to be $(MARKETING_VERSION), which is the default for projects created in Xcode 13 and later. If an older project has hard-coded numbers, change them once in the Property List Editor and commit.
Jenkins example
The two Jenkinsfiles below are for a Windows agent and a Linux agent, with the same flow. First create three credentials in Jenkins credentials management according to the table above, with IDs ios-p12, ios-p12-password and ios-profile.
Windows agent
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}
The script checks $LASTEXITCODE at the end so the step is marked failed when kxbuild fails.
Linux agent
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}
The script uses a ''' single-quoted string, so the shell expands the password rather than Groovy interpolation, and Jenkins doesn’t warn about a leak. The \\n in printf is written with two backslashes in the Groovy string, so the shell receives \n.
GitLab CI example
In your GitLab project, add the three variables from the table above under Settings > CI/CD > Variables. A File variable expands to the path of a temporary file in the job. Use the shell executor for the Runner, and tag it ios-linux or ios-windows when registering.
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
For a Runner on Windows, set the executor shell to powershell or pwsh and write the script section in PowerShell. The commands are the same as in the powershell block of the Jenkins Windows example. Read variables as $env:IOS_P12, and use $env:CI_PIPELINE_IID for the build number.
GitLab Masked variables need values of at least 8 characters, so a short .p12 password can’t be masked. Use a long enough password when you export the .p12. Protected variables are only available on protected branches and tags. If you build on tags, protect the matching tags under Settings > Repository.
Archive the ipa
Both examples keep out/*.ipa and out/*.AppStoreInfo.plist as build artifacts, which you can download from the Jenkins build page or the GitLab job page. The ipa has the same structure as one exported by Xcode and can be uploaded as is.
For builds that testers install directly, switch to an Ad Hoc profile and keep everything else the same. See “Distribute outside the App Store” in Release and App Store submission. To produce both an App Store build and an Ad Hoc build in one pipeline, put both profiles in the temporary keychain folder, build twice, and give each a different file name with --ipa.
Upload to TestFlight
To upload by hand, use Transporter or Appuploader. See Release and App Store submission. To upload automatically from the pipeline, use Apple’s command-line Transporter (iTMSTransporter). It has Windows and Linux versions, which you download from the Transporter section of the App Store Connect help, and install on the build machine.
Authenticate with an App Store Connect API key, with no account password or two-factor authentication. In App Store Connect, under Users and Access > Integrations > App Store Connect API, create a team key with the App Manager role. Note the Key ID and Issuer ID, and download AuthKey_.p8. The .p8 can only be downloaded once. Store it as a Secret file in your CI like the .p12, and before uploading copy it into a private_keys subfolder of the working directory.
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 takes the AppStoreInfo.plist generated next to the ipa at build time. Create the app’s record in App Store Connect first, with the same Bundle ID as the build. After a successful upload, Apple takes a few minutes to an hour or so to process it before the build appears on the TestFlight page.
GitHub Actions
GitHub-hosted Windows and Linux runners start from a fresh environment every time, so the toolchain has to be downloaded again. For GitHub Actions, register a self-hosted runner on your own build machine and prepare it as in the first section. The workflow steps are the same as the script in the GitLab example above, and the certificate and password go in the repository’s Secrets.