Skip to main content
Tutorials

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

  1. 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.
  2. Click Team Keys, then Generate API Key.
  3. Enter a name that shows what it’s for, such as github-actions-upload.
  4. Under Access, choose a role. For uploading builds only, choose App Manager.
  5. 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.

  1. private_keys in the current folder
  2. private_keys in the home folder
  3. .private_keys in the home folder
  4. .appstoreconnect/private_keys in 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_VERSION from the tag only works for tag triggers. On a manual run (workflow_dispatch), GITHUB_REF_NAME is 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.