Auto-Increment iOS Build Numbers and Upload With an API Key
Last updated
To auto-increment iOS build numbers, generate them from the CI run number and override them at build time with -D CURRENT_PROJECT_VERSION=…, without changing the project file. To upload the .ipa automatically, use a team API key from App Store Connect with Apple’s command-line Transporter (iTMSTransporter). It has Windows and Linux versions and authenticates with -apiKey and -apiIssuer, so nobody types a password or verification code. This tutorial extends the workflow from Build an iOS app and .ipa with GitHub Actions on a self-hosted Linux runner with both parts.
Before you start, have these ready.
- An app record in App Store Connect with the same bundle ID as the project.
- A pipeline that produces an .ipa in CI. For GitHub Actions, see the self-hosted runner tutorial above. For Jenkins and GitLab CI, see Build iOS apps with Jenkins and GitLab CI.
- An App Store Connect account with the Account Holder or Admin role, to generate the API key.
What are the rules for version and build numbers?
An iOS app has two numbers, each with its own Info.plist key and build setting.
| Name | Info.plist key | Build setting | Purpose |
|---|---|---|---|
| Version number | CFBundleShortVersionString | MARKETING_VERSION | The 1.0 or 1.2.0 shown on the App Store |
| Build number | CFBundleVersion | CURRENT_PROJECT_VERSION | Tells apart each upload of the same version |
App Store Connect requires every upload for a given version number to have a higher build number than the previous one. A repeated or lower build number is rejected at upload. You also need a new build number when you upload again after a rejection by App Review or after a TestFlight build expires.
Apple defines the format as one to three integers separated by periods, for example 45, 45.2 or 20261019.1530, containing only digits and periods. See Apple’s CFBundleVersion reference. Comparison goes component by component as integers, so 46 is higher than 45.2.
Make Info.plist read the build settings
Overriding the build number with -D only works when Info.plist contains a variable, not a hard-coded number. Projects created in Xcode 13 or later do this by default. For older projects, open Info.plist in the Property List Editor, change the two values as shown below, save and commit.
1<key>CFBundleShortVersionString</key>
2<string>$(MARKETING_VERSION)</string>
3<key>CFBundleVersion</key>
4<string>$(CURRENT_PROJECT_VERSION)</string>
If the project has app extensions (widgets, notification extensions and so on), write their Info.plist files the same way. Extensions must have the same version and build number as the main app, or Apple warns you at upload.
How do you auto-increment build numbers in CI?
Use the run number
The simplest approach is the counter your CI already provides, which goes up by one with every run.
| CI | Variable | Description |
|---|---|---|
| GitHub Actions | GITHUB_RUN_NUMBER |
The run number of this workflow. Stays the same on Re-run |
| GitHub Actions | GITHUB_RUN_ATTEMPT |
The attempt number within a run, starting at 1 |
| Jenkins | BUILD_NUMBER |
The build number of this job |
| GitLab CI | CI_PIPELINE_IID |
The pipeline number, incremented per project |
Override it at build time with -D, as described in overriding build settings.
1kxbuild build ./MyApp.xcodeproj -c Release --export app-store -D MARKETING_VERSION=1.2.0 -D CURRENT_PROJECT_VERSION="$GITHUB_RUN_NUMBER"
When you click Re-run in GitHub Actions, GITHUB_RUN_NUMBER doesn’t change. If the previous attempt already uploaded, the rerun fails with a duplicate build number. Append the attempt number as a second component to avoid this.
1BUILD_NUMBER="${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}"
Continue from existing build numbers
If you’ve already uploaded up to build 120 by hand and the new workflow’s run number starts at 1, add a fixed offset so new build numbers are always higher.
1BUILD_NUMBER=$((GITHUB_RUN_NUMBER + 1000))
Use the time
When several pipelines or machines upload to the same app, run numbers can collide, and UTC time is safer. Write it as two components, date and hour-minute, each an integer.
1BUILD_NUMBER="$(date -u +%Y%m%d.%H%M)"
On a Windows build machine, the PowerShell version is this.
1$BuildNumber = (Get-Date).ToUniversalTime().ToString('yyyyMMdd.HHmm')
Two runs in the same minute produce the same number. That’s rare, and running again fixes it.
Take the version number from the tag
If tags such as v1.2.0 trigger packaging, take the version number from the tag name. In GitHub Actions, strip the leading v.
1kxbuild build ./MyApp.xcodeproj -c Release --export app-store -D MARKETING_VERSION="${GITHUB_REF_NAME#v}" -D CURRENT_PROJECT_VERSION="$GITHUB_RUN_NUMBER"
If you don’t use tags, keep the version number in the project, change it by hand on the General page of the Project Settings Editor before each release, and override only the build number at build time.
How do you create an App Store Connect API key?
An API key lets scripts call App Store Connect on behalf of your team with no Apple Account password and no two-factor code, which is what a pipeline needs.
Request access the first time
The first time a team uses the App Store Connect API, the Account Holder must request access in Users and Access > Integrations > App Store Connect API and accept the terms before any keys can be generated.
Generate a team key
- Sign in to App Store Connect with the Account Holder or Admin role, go to Users and Access and click Integrations. The page opens on App Store Connect API.
- Click Team Keys, then Generate API Key.
- Enter a name that shows what it’s for, such as
github-actions-upload. - Under Access, choose a role. For uploading builds only, choose App Manager.
- Click Generate. The new key appears in the list.
Write down three things.
- Issuer ID, shown above the key list. The whole team shares one.
- Key ID, one row per key in the list.
- Private key file. Click Download API Key to get
AuthKey_<Key ID>.p8.
You can download the private key only once. Apple keeps no copy, and the download link disappears after use. If you lose the file, revoke the key and generate a new one.
The name and access role can’t be changed after generation either, so changing them also means revoking and recreating. A team key works for every app in the account and can’t be limited to one app, so protect it as you would a certificate’s private key. See Apple’s App Store Connect API help page and Creating API keys for App Store Connect API.
If a key leaks or someone leaves the team, click Revoke on the same page. A revoked key stops working immediately and can’t be restored.
Which tools can upload an .ipa automatically?
| Method | Runs on | Authentication | Good for |
|---|---|---|---|
| Transporter app | macOS | Sign in with an Apple Account | Manual upload, drag the .ipa in |
| Appuploader | Windows | Sign in with a developer account | Manual upload |
| Command-line Transporter (iTMSTransporter) | macOS, Windows, Linux | API key, or an account with an app-specific password | Automatic upload in a pipeline |
For manual uploads, see Release and App Store submission. In a pipeline, use command-line Transporter.
As of October 2026, Apple’s Transporter User Guide lists macOS 10.11 or later, 64-bit Windows 11 or later and 64-bit Red Hat Enterprise Linux as supported systems. The Windows version is a self-extracting installer and the Linux version is a shell script. Download links are in the Install Transporter section of the guide.
The guide lists only Red Hat Enterprise Linux for Linux. Ubuntu and other distributions aren’t on Apple’s supported list, so verify for yourself that it works there. The safest setup is to install Transporter on one dedicated build machine, Windows 11 or RHEL, and register it as a self-hosted runner that only handles uploads.
Upload with the API key
Place the private key
Transporter looks for AuthKey_<Key ID>.p8 based on the key ID passed to -apiKey, in these folders in order. Keep the file name exactly as downloaded.
private_keysin the current folderprivate_keysin the home folder.private_keysin the home folder.appstoreconnect/private_keysin the home folder
In a pipeline, use the first location. Create private_keys in the working folder for the upload and delete it afterwards.
Upload command
1iTMSTransporter -m upload -assetFile out/MyApp.ipa -assetDescription out/MyApp.AppStoreInfo.plist -apiKey "$ASC_KEY_ID" -apiIssuer "$ASC_ISSUER_ID"
| Option | What it does |
|---|---|
-m upload |
Upload mode |
-assetFile |
The .ipa to upload. Apps must use -assetFile, not -f |
-assetDescription |
The asset description plist for the .ipa, required when uploading apps on Windows and Linux |
-apiKey |
Key ID |
-apiIssuer |
Issuer ID |
KXBuild already writes the AppStoreInfo.plist that -assetDescription needs next to the .ipa when it builds with --export app-store. With --ipa ./out/MyApp.ipa it’s ./out/MyApp.AppStoreInfo.plist. See the build products section of Build and configuration. The .ipa and the plist must come from the same build. If you change the .ipa, use its matching plist.
After a successful upload Apple processes the build for a few minutes to tens of minutes, and then it appears on the TestFlight tab in App Store Connect. For export compliance and internal and external testing after that, see TestFlight beta testing.
Add it to GitHub Actions
Add secrets
Add three more secrets in Settings > Secrets and variables > Actions in the repository. The .p8 file is already text. Open it in a text editor and paste all of it, including the BEGIN and END lines. No base64 needed.
| Secret name | Content |
|---|---|
| ASC_KEY_ID | Key ID |
| ASC_ISSUER_ID | Issuer ID |
| ASC_KEY_P8 | The full text of AuthKey_.p8 |
The complete workflow
The workflow below has two jobs. build produces the .ipa on the self-hosted Linux runner with KXBuild, the same as in the self-hosted runner tutorial, except that the build number now includes the attempt number. upload runs on a self-hosted runner with Transporter installed, downloads the artifact from build and uploads it. Give the Transporter runner a transporter label when you register it, and add Transporter’s bin folder to the runner user’s PATH.
1name: iOS release
2
3on:
4 push:
5 tags: ["v*"]
6 workflow_dispatch:
7
8jobs:
9 build:
10 runs-on: [self-hosted, linux]
11 timeout-minutes: 60
12 env:
13 KXBUILD_NO_UPDATE_CHECK: "1"
14 steps:
15 - uses: actions/checkout@v4
16
17 - name: Add KXBuild to PATH
18 run: |
19 echo "KXBUILD_HOME=$HOME/kxbuild" >> "$GITHUB_ENV"
20 echo "$HOME/kxbuild/bin" >> "$GITHUB_PATH"
21
22 - name: Prepare signing
23 env:
24 IOS_P12_BASE64: ${{ secrets.IOS_P12_BASE64 }}
25 IOS_P12_PASSWORD: ${{ secrets.IOS_P12_PASSWORD }}
26 IOS_PROFILE_BASE64: ${{ secrets.IOS_PROFILE_BASE64 }}
27 run: |
28 KC="$RUNNER_TEMP/ci-keychain"
29 mkdir -p "$KC"
30 printf '%s' "$IOS_P12_BASE64" | base64 -d > "$KC/distribution.p12"
31 printf '%s' "$IOS_PROFILE_BASE64" | base64 -d > "$KC/MyApp_AppStore.mobileprovision"
32 printf 'distribution.p12=%s\n' "$IOS_P12_PASSWORD" > "$KC/password.properties"
33 echo "KC=$KC" >> "$GITHUB_ENV"
34
35 - name: Build ipa
36 run: |
37 kxbuild version
38 kxbuild build ./MyApp.xcworkspace -s MyApp -c Release --export app-store --clean \
39 --derivedDataDir ./out --skip-package-updates --ipa ./out/MyApp.ipa \
40 -D CODE_SIGN_KEYCHAIN="$KC" \
41 -D MARKETING_VERSION="${GITHUB_REF_NAME#v}" \
42 -D CURRENT_PROJECT_VERSION="${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}"
43
44 - uses: actions/upload-artifact@v4
45 with:
46 name: ios-ipa
47 path: |
48 out/MyApp.ipa
49 out/MyApp.AppStoreInfo.plist
50 retention-days: 30
51 if-no-files-found: error
52
53 - name: Clean up signing files
54 if: always()
55 run: rm -rf "$RUNNER_TEMP/ci-keychain"
56
57 upload:
58 needs: build
59 runs-on: [self-hosted, linux, transporter]
60 steps:
61 - uses: actions/download-artifact@v4
62 with:
63 name: ios-ipa
64 path: out
65
66 - name: Upload to App Store Connect
67 env:
68 ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
69 ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
70 ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
71 run: |
72 mkdir -p private_keys
73 printf '%s\n' "$ASC_KEY_P8" > "private_keys/AuthKey_${ASC_KEY_ID}.p8"
74 iTMSTransporter -m upload \
75 -assetFile out/MyApp.ipa \
76 -assetDescription out/MyApp.AppStoreInfo.plist \
77 -apiKey "$ASC_KEY_ID" \
78 -apiIssuer "$ASC_ISSUER_ID"
79
80 - name: Remove private key
81 if: always()
82 run: rm -rf private_keys
A few details.
- Taking
MARKETING_VERSIONfrom the tag only works for tag triggers. On a manual run (workflow_dispatch),GITHUB_REF_NAMEis the branch name, so remove that line and use the version in the project. - A self-hosted runner keeps its working folder until the next run.
if: always()on the last step makes sure the private key file never stays on the machine. - If one self-hosted runner has both KXBuild and Transporter, you can merge the two jobs. Put the upload step right after Build ipa and skip uploading and downloading the artifact.
Upload step on a Windows self-hosted runner
If Transporter is installed on a Windows 11 build machine, change runs-on for the upload job to [self-hosted, windows, transporter] and use the PowerShell version of the upload step.
1 - name: Upload to App Store Connect
2 shell: pwsh
3 env:
4 ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
5 ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
6 ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
7 run: |
8 New-Item -ItemType Directory -Force private_keys | Out-Null
9 Set-Content -Encoding ascii "private_keys/AuthKey_$env:ASC_KEY_ID.p8" $env:ASC_KEY_P8
10 iTMSTransporter -m upload -assetFile out/MyApp.ipa -assetDescription out/MyApp.AppStoreInfo.plist -apiKey $env:ASC_KEY_ID -apiIssuer $env:ASC_ISSUER_ID
11 if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
12
13 - name: Remove private key
14 if: always()
15 shell: pwsh
16 run: Remove-Item -Recurse -Force private_keys -ErrorAction SilentlyContinue
The final $LASTEXITCODE check makes the step fail when the upload fails. Jenkins and GitLab CI work the same way. Pass the private key as a Secret file or a File-type variable and copy it into the private_keys folder, as shown in the TestFlight upload section of the Jenkins and GitLab CI tutorial.
How do you fix upload errors?
| Symptom | Cause | Fix |
|---|---|---|
| Upload rejected because the build number exists or must be higher | Duplicate build number, often from Re-run or several pipelines sharing one app | Use run number.attempt or the time as the build number, and add an offset if needed |
| The uploaded version or build number is still the old one | Info.plist has a hard-coded number, so -D has no effect |
Change CFBundleVersion to $(CURRENT_PROJECT_VERSION), including in extension Info.plist files |
| Authentication fails | Wrong Key ID or Issuer ID, or the key was revoked | Check in Users and Access > Integrations, and generate a new key if it was revoked |
| Private key not found | The .p8 file isn’t named AuthKey_<Key ID>.p8, or isn’t in a folder Transporter searches |
Match the file name to the -apiKey value and put it in private_keys in the current folder |
| Missing asset description | -assetDescription wasn’t passed on Windows or Linux |
Add the AppStoreInfo.plist generated by the build |
| Insufficient permissions | The key’s access role is too low | Create a new team key with the App Manager role |
iTMSTransporter: command not found |
Transporter’s bin folder isn’t on the runner user’s PATH | Add it to the runner’s environment, or use the full path in the command |
Before accepting an upload made with your API key, Apple also checks the signature, provisioning profile and icons. For those errors, see the pre-upload checks in Release and App Store submission, and for signing errors see 16 common iOS code signing and .ipa install errors.
FAQ
Can I use a timestamp as an iOS build number?
Yes, as long as it follows the CFBundleVersion format of one to three integers separated by periods. Write it as date.hourminute, for example 20261019.1530, and don’t use date strings with dashes or colons.
Can I upload an .ipa from Windows with an App Store Connect API key?
Yes. Apple’s command-line Transporter supports 64-bit Windows 11 or later and authenticates with -apiKey and -apiIssuer. On Windows, uploading an app also needs -assetDescription pointing at the AppStoreInfo.plist. For manual uploads only, Transporter on a Mac or Appuploader on Windows is simpler.
What if I lose the .p8 private key for the App Store Connect API?
You can download the private key only once and Apple keeps no copy, so it can’t be recovered. Revoke the key in Users and Access > Integrations, generate a new one, and update the Key ID and private key in your CI.
Which role should the API key have?
For uploading builds only, App Manager is enough. A team key works for every app in the account, and a higher role can do more, so don’t pick Admin just for convenience.
Why does a Re-run in GitHub Actions report a duplicate build number?
GITHUB_RUN_NUMBER stays the same on Re-run, and the previous attempt already uploaded that build number. Use ${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT} as the build number, so the second component goes up with each rerun.