Skip to main content
Tutorials

iOS Ad Hoc and App Store builds from one project with xcconfig

Testers need a build that talks to the test environment, the App Store needs one that talks to production, and ideally both can sit side by side on one phone for comparison. This tutorial uses a SwiftUI project called MyApp. You separate the two environments with xcconfig, prepare an Ad Hoc and an App Store provisioning profile, and finally produce both .ipa files with one PowerShell script.

The commands here run on Windows, and the project already builds with KXBuild and installs on a device. If you’re not there yet, start with your first SwiftUI app.

How the two builds differ

Item Test build Production build
Bundle ID com.example.myapp.test com.example.myapp
Home screen name MyApp Test MyApp
Icon AppIcon-Test AppIcon
API URL https://test-api.example.com https://api.example.com
Provisioning profile Ad Hoc, with the test devices’ UDIDs App Store
How to install USB cable or a distribution service TestFlight, App Store

Because the bundle IDs differ, the phone treats them as two apps and both can be installed at once. The different name and icon let testers tell at a glance which one they’re holding.

Choosing an approach

The usual approach in Xcode is to duplicate Release into a new Staging build configuration. If your project already has such a configuration, pass -c Staging when you build. If it doesn’t, use the approach below, which leaves the project structure unchanged.

  • Put the defaults in the project settings. Add the API URL to the project as a custom build setting, API_BASE_URL, pointing to the test environment in Debug and to production in Release. Everyday debugging in VS Code and production builds both use it.
  • Layer an xcconfig on the test build. Config\AdHoc.xcconfig holds the few settings the test build changes, and you apply it with --xcconfig at build time. An xcconfig is plain text committed with the code, so the result is the same whoever builds.
  • Use -D for one-off changes. For example, if this test build needs to point at a developer’s own server, add -D API_BASE_URL=... when building instead of editing a file.

One rule to remember. Settings in the --xcconfig file have the highest priority, higher than -D, just like xcodebuild’s -xcconfig. So don’t set the same key both in the xcconfig and with -D. Keep any key you want to override temporarily out of the xcconfig.

Add an API URL setting

Use either of the following.

  • VS Code extension. Run KXBuild: Project Settings… from the Command Palette, select the MyApp target in the left column, right-click a value cell on the Build Settings page and choose Add User-Defined Setting, and enter the name API_BASE_URL. Turn on Show one row per configuration (set Debug and Release separately), enter https://test-api.example.com for Debug and https://api.example.com for Release, then press Ctrl+S to save.
  • Command line. Run these two commands in the project folder. -c limits each change to one configuration.
1kxbuild project add-setting -k API_BASE_URL -v https://test-api.example.com -t MyApp -c Debug -P .\MyApp.xcodeproj
2kxbuild project add-setting -k API_BASE_URL -v https://api.example.com -t MyApp -c Release -P .\MyApp.xcodeproj

For the editor see Project Settings Editor, and for command options see the kxbuild command reference.

Read it in code

Build settings exist only at build time. To read one at runtime, the app needs it written into Info.plist.

If the project already has an Info.plist file, open it in the Property List Editor and add a String row named API_BASE_URL with the value $(API_BASE_URL).

Projects created in Xcode 13 or later usually have no Info.plist file, since it’s generated from build settings. For these, create Config\Info.plist containing only your custom keys. At build time it’s merged with the generated content.

1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5	<key>API_BASE_URL</key>
6	<string>$(API_BASE_URL)</string>
7</dict>
8</plist>

Then point the target’s Info.plist file at it. Enter Config/Info.plist in Info.plist File on the General page of the Project Settings Editor, or run this command.

1kxbuild project add-setting -k INFOPLIST_FILE -v Config/Info.plist -t MyApp -P .\MyApp.xcodeproj

Read it in code like this. STAGING is the compilation condition the next section adds to the test build. Wrap code that only belongs in the test build (such as a debug entry that shows the current environment) in it, and it won’t be compiled into the production build.

 1import Foundation
 2
 3enum AppEnv {
 4    static let apiBaseURL = Bundle.main.object(forInfoDictionaryKey: "API_BASE_URL") as? String ?? ""
 5
 6    #if STAGING
 7    static let isTestBuild = true
 8    #else
 9    static let isTestBuild = false
10    #endif
11}

Write two xcconfig files

Create a Config folder in the project folder with two xcconfig files. The test build’s file contains everything that differs from the production build.

1// Config/AdHoc.xcconfig
2PRODUCT_BUNDLE_IDENTIFIER = com.example.myapp.test
3INFOPLIST_KEY_CFBundleDisplayName = MyApp Test
4ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon-Test
5API_BASE_URL = https:/$()/test-api.example.com
6SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) STAGING
7CODE_SIGN_IDENTITY = Apple Distribution

The production build’s file only specifies the distribution certificate.

1// Config/AppStore.xcconfig
2CODE_SIGN_IDENTITY = Apple Distribution

A few things to watch.

  • In an xcconfig, // starts a comment, so write URLs as https:/$()/. $() expands to nothing, so the result is still https://.
  • INFOPLIST_KEY_CFBundleDisplayName only works in projects that generate Info.plist. If the project uses its own Info.plist, change the value of CFBundleDisplayName to $(APP_DISPLAY_NAME) in the Property List Editor, then set APP_DISPLAY_NAME both in the project and in the xcconfig.
  • Create AppIcon-Test in the asset catalog first. Open Assets.xcassets in the Asset Catalog Editor, right-click and choose New App Icon, name it AppIcon-Test, and add an icon with a “Test” badge. For making icons, see app icons, launch screen and localization.

The $(inherited) in SWIFT_ACTIVE_COMPILATION_CONDITIONS keeps the project’s existing compilation conditions and only appends STAGING.

Prepare two provisioning profiles

Both builds use the same Apple Distribution certificate, with one provisioning profile each. For getting certificates and profiles see Certificates and signing, and for the full steps see getting a certificate on Windows.

Register test devices

An Ad Hoc build installs only on phones registered in its provisioning profile. Connect a test device to your computer and run the command below. The first column is the UDID.

1kxdevice device list

In Certificates, Identifiers & Profiles on developer.apple.com, open the Devices page and click “+” to register each device. You can register at most 100 devices of each type per membership year, and removing a device only frees its slot in the next membership year, so don’t add phones you’ve only borrowed for a moment.

Register App IDs and create profiles

  1. On the Identifiers page, register two App IDs, com.example.myapp and com.example.myapp.test. If the app uses capabilities such as push notifications, App Groups or Sign in with Apple, enable them on both App IDs.
  2. On the Profiles page, create an Ad Hoc provisioning profile. Choose com.example.myapp.test as the App ID, Apple Distribution as the certificate, select all test devices, and name it MyApp AdHoc.
  3. Create another profile of type App Store, choose com.example.myapp as the App ID, and name it MyApp AppStore.

Download them and put them with the certificates in the keychain folder at the project root. The development certificate and profile you use for debugging can stay there, they don’t interfere.

1keychain\
2├─ development.p12
3├─ distribution.p12
4├─ MyApp_Dev.mobileprovision
5├─ MyApp_AdHoc.mobileprovision
6├─ MyApp_AppStore.mobileprovision
7└─ password.properties

How the profile is chosen at build time

KXBuild picks the provisioning profile from the keychain folder by bundle ID. The two builds have different bundle IDs, so each matches its own profile. CODE_SIGN_IDENTITY = Apple Distribution in the xcconfig makes it sign with the distribution certificate.

If the same bundle ID has both an Ad Hoc and an App Store profile, automatic selection can’t tell which to use. In that case add a line to the matching xcconfig to specify it by name. The name is the one you entered when creating the profile on the developer site, not the file name.

1PROVISIONING_PROFILE_SPECIFIER = MyApp AdHoc

Build both at once

Save the script below as build-all.ps1 in the project folder and run it each time you ship a build, passing that build’s number.

 1param([int]$Build = 1)
 2
 3$proj = '.\MyApp.xcodeproj'
 4$packages = @(
 5  @{ Name = 'MyApp-Test'; Config = '.\Config\AdHoc.xcconfig' },
 6  @{ Name = 'MyApp';      Config = '.\Config\AppStore.xcconfig' }
 7)
 8
 9foreach ($p in $packages) {
10  kxbuild build $proj -c Release --xcconfig $p.Config -D CURRENT_PROJECT_VERSION=$Build --derivedDataDir ".\out\dd-$($p.Name)" --ipa ".\out\$($p.Name).ipa"
11  if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
12}
1powershell -ExecutionPolicy Bypass -File .\build-all.ps1 -Build 46

When it finishes, the out folder contains MyApp-Test.ipa and MyApp.ipa, each with an AppStoreInfo.plist next to it for uploading. A few notes.

  • Both builds use the Release configuration, so the test and production builds have the same optimizations and behavior, and any issue found in testing is an issue in the production build.
  • --derivedDataDir gives each build its own output folder. If they share one, icon files left by the first build get mixed into the second.
  • -D CURRENT_PROJECT_VERSION gives both builds the same build number, so tester feedback can be traced to a specific build. The build number isn’t in the xcconfig, so -D takes effect.
  • Don’t write non-ASCII comments (such as Chinese) in the script. Windows PowerShell 5.1 reads scripts without a BOM in the system code page, so those characters get garbled and cause syntax errors.

For --xcconfig, --ipa, --derivedDataDir and other options, see Build and configure. To produce both builds automatically in Jenkins or GitLab CI, put this loop in the pipeline. For handling certificates there, see Jenkins and GitLab CI builds.

Distribute the test build

Install over USB

Connect a test device to your computer and run the command below. If the production app is already on the phone, the test build is installed next to it as a separate app.

1kxdevice install .\out\MyApp-Test.ipa

With several phones connected, use --udid to choose one. Get UDIDs from kxdevice device list. For command usage see the kxdevice command reference.

Upload to a distribution service

For testers who aren’t nearby, upload MyApp-Test.ipa to a distribution service such as Diawi or Firebase App Distribution and send them the download link or QR code to open and install in Safari. Only phones registered in the provisioning profile can install it. For a new test device, register its UDID first, regenerate the Ad Hoc profile, replace the old file in keychain, and build again.

Send the production build through TestFlight

MyApp.ipa uses the App Store provisioning profile, so it can’t be installed directly over USB. That’s expected. Upload it to App Store Connect with Appuploader, then install it on a phone through TestFlight to verify it. See the upload and TestFlight sections of publishing to the App Store on Windows.

FAQ

The test build won’t install

Usually the phone’s UDID isn’t in the Ad Hoc profile. After registering the device on the developer site, edit the profile to include it, download and replace it, and build again. A build you’ve already made won’t pick up new devices on its own. For other install errors, see iOS signing and .ipa install errors.

Only one app remains after installing both

Both builds have the same bundle ID, so the second install replaced the first. Run kxdevice apps to see which bundle IDs are on the phone, then check that the test build was built with --xcconfig .\Config\AdHoc.xcconfig.

The test build doesn’t receive push notifications

First make sure push notifications are also enabled on the com.example.myapp.test App ID. Also, Ad Hoc builds use the APNs production environment just like the production build, so your server must send to the test build using the production address, with the topic set to com.example.myapp.test rather than the production bundle ID. For the full push setup, see push notifications.

App Groups or keychain sharing don’t work in the test build

These capabilities’ identifiers are registered on the App ID. The test build has a different App ID, so enable the same group under its App Groups too, or create a separate group for the test build. After changing capabilities, regenerate and download the provisioning profile, otherwise signing reports that the profile doesn’t include the capability.

A value passed with -D doesn’t take effect

The key is most likely already in the file given to --xcconfig, which has higher priority. Remove it from the xcconfig, set the default in the project instead, and override it with -D when needed.

Upload says the build number is a duplicate

App Store Connect requires each upload of the same version to have a higher build number than the last, so pass a larger -Build every time you run the script. This requires Info.plist’s CFBundleVersion to be $(CURRENT_PROJECT_VERSION). Projects created in Xcode 13 or later do this by default. For older projects, see the build number section of Jenkins and GitLab CI builds.