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.xcconfigholds the few settings the test build changes, and you apply it with--xcconfigat 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 nameAPI_BASE_URL. Turn on Show one row per configuration (set Debug and Release separately), enterhttps://test-api.example.comfor Debug andhttps://api.example.comfor Release, then press Ctrl+S to save. - Command line. Run these two commands in the project folder.
-climits 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 ashttps:/$()/.$()expands to nothing, so the result is stillhttps://. INFOPLIST_KEY_CFBundleDisplayNameonly works in projects that generate Info.plist. If the project uses its own Info.plist, change the value ofCFBundleDisplayNameto$(APP_DISPLAY_NAME)in the Property List Editor, then setAPP_DISPLAY_NAMEboth in the project and in the xcconfig.- Create
AppIcon-Testin 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
- On the Identifiers page, register two App IDs,
com.example.myappandcom.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. - On the Profiles page, create an Ad Hoc provisioning profile. Choose
com.example.myapp.testas the App ID, Apple Distribution as the certificate, select all test devices, and name it MyApp AdHoc. - Create another profile of type App Store, choose
com.example.myappas 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.
--derivedDataDirgives 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_VERSIONgives 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-Dtakes 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.