One of the challenges of any development project (iOS or otherwise) is maintaining a single code-base, yet still being able to create a build that can be configured to point to a development, system test, user acceptance test, and production environment.

Because an iOS app is packaged into a non-modifiable bundle, everything that is required to configure the app must be done at build time. This post describes a technique that I have recently been using for a big corporate’s iPhone app.

Note 1: I recently edited this article (25th March 2014) to clarify how provisioning profiles and app bundle identifiers need to be managed. Apologies for any confusion.

Note 2: I also edited it on 25th November 2014 to modify the build script to only use the commit hash for non-production servers. This is because Apple needs numeric-only version numbers when submitting to the app store. I probably should update the BDF_ENVIRONMENT_LAST_COMMIT variable to be called BDF_ENVIRONMENT_VERSION_NUMBER to be more accurate.

Challenges of Multi-Environment Deployments

Some of the challenges that we faced during our development cycle are listed below:

Obviously there are a bunch of application parameters that vary between environments (eg. hostname/port of the back-end system, do we allow self-signed SSL certificates, logging levels, plus a million and one other settings).

There are also a bunch of compiler flags (stripping symbols, optimising code, provisioning profile, signing certificates, etc) that need to vary by environment.

Our testers needed to be able to have different builds on their phones (eg. one build might point to System Test and one might point to UAT).

Testers need to easily be able to distinguish which app points to which environment.

Testers need to easily be able to determine how recent their build is

Push notifications need to be able to sent to both production and development devices.

The app should contain identifying information about what specific codebase version was used to compile it.

Apps need to be able to deployed to something like TestFlight with different testers having access to different environment builds.

The build process must be able to be run on both a developer’s workstation, and via a continuous integration build server.

Developers need to easily and reliably switch between environments in their Xcode environments.

Switching between environments on a developer’s machine must not cause Xcode/git to think that there have been changes that need to be committed.

Non-production settings must not be shipped in the production build

The environment-specific settings must be under source control.

So, that’s quite a wish list!

Solution Overview

We’ll go through the gory details below, but just so that you know where we’re heading the general gist of the solution is as follows:

Create a environment_XXX.plist file per environment that contains all of the settings that vary by environment.

The build process will copy the appropriate environment plist file so that the app can simply look for a file called environment.plist so the build process will copy over the appropriate plist.

Use Xcode’s Info.plist preprocessing capability to dynamically populate the Info.plist based on the settings in the environment.plist file.

Use Xcode’s Configuration and Scheme settings to switch between environments.

The build process will create bundles that have unique bundle identifiers. Non-prod will be suffixed with the environment name. For example, .DEV and .SYS

Each environment will have its own icon

The app name contains the environment name and the build day of the month. For example, DEV 10 means it is a development build and was built on the 10th of the month

Detailed Steps

Understanding Configurations and Schemes

Before we proceed, though, it is important to understand Xcode’s Configuration and Scheme concepts as this technique relies quite heavily on them.

A Configuration defines the set of compiler settings that will to be used. By default, Xcode creates two configurations for you (Debug and Release) when you create a new iOS project. Most of the settings are the same but there are a bunch of settings that vary between a Debug build and a Release build. For example:

A Scheme, however, encapsulates all the bits describing the broader build process for an application. For example:

Which Target is going to be built

Which Configuration should be used to build it (it allows you to select a different configuration for running vs profiling vs archiving)

Which Tests get run (if you are using the new Xcode5 testing harness)

By default, when you create a new project, Xcode will create a single Scheme that uses the Debug configuration for running/debugging/testing and the Release configuration for Profiling/Archiving.

What we are going to is create one scheme and one configuration for each environment that we intend to support.

Creating a new Project

So, let’s begin… in the example below, we will create an app that has settings for DEV, SYS and PROD. Feel free to adjust for as many environments as you have.

Configurations

Create a new iOS project in Xcode5 and open the Info tab for the project. You should be able to see the two default configurations (Debug and Release) that Xcode has created for you.

Double-click the Debug label, and rename it to DEV. Click on + icon to add a new configuration, duplicate the DEV configuration and call it SYS. Double-click the Release label and call it PROD. Your screen should now look like:

Click on the Editor/Add Build Setting/Add User-Defined Setting menu and add a variable called BDF_ENVIRONMENT (pick any name you like… this just what I am calling it for this article). Note that sometimes Xcode forgets what you have selected and doesn’t enable the menu items properly – if this happens, select something else (say, a target) and the re-select the project – sigh!

Click on the disclosure triangle next to BDF_ENVIRONMENT and for each configuration, enter the appropriate value. Note that you should be performing this step against the Project, not the Target so that these settings will be picked up by all targets (otherwise, you would have to do this step again for any other targets you add).

Add another User-Defined Setting called BDF_BUNDLE_ID and set your bundle identifiers for each environment (again, do it at the Project level, not the Target level):

I discuss why we are adding this variable a little later in the article.

Schemes

Click on the Scheme pull down at the top left of Xcode, and choose the Manage Schemes menu item.

Delete the existing scheme, and then click on the + button and add a new scheme called DEV pointing to your target. Do the same for SYS and PROD.

Edit the DEV scheme and make sure that each of the 5 actions (Run/Test/Profile/Analyze/Archive) are using the DEV configuration. Repeat the same steps for the SYS and PROD schemes (obviously using the SYS and PROD configurations respectively). Below is an example of what the DEV scheme should look like:

Once you close your Scheme editor, if you click on the scheme pull down it should look something like the following:

You can probably guess how you might switch between building for each environments?

Checkpoint

So, at this point, we have created three schemes and three configurations that describe the build process for each of our three environments. Each configuration has some compiler-specific settings, but the main one that we care most about at the moment is the BDF_ENVIRONMENT variable. When we switch schemes, the value of this compiler variable will be set to DEV, SYS or PROD depending on the chosen scheme. Most of the steps below rely on this variable being set correctly.

Application Settings

So, now that we have a way of distinguishing which environment we are building for, let’s add some settings that vary by environment.

Environment-specific Files

Add a new plist called environment_DEV.plist but do not add it to your target. If you add it to your target, it will be packaged into your app’s bundle and all of your non-production info will be shipped in your production app – which you definitely do not want. I normally create a directory called Configuration and put them in there, but for the sake of this article, I am just going to put them into the same folder as the other project files.

Edit this plist file so that it contains something like (spacing added for clarity):

Make two copies of this file called environment_SYS.plist and environment_PROD.plist (again taking care that they are not added to your target), and modify to contain SYS and PROD values respectively.

Creating Asset Catalog

In order to pick a new image depending on the environment, you will need to open your Asset Catalog and set it up so that there are three App Icon icon sets (you can remove the original AppIcon icon set):

AppIcon_DEV

AppIcon_SYS

AppIcon_PROD

Obviously, these contain the icons that you want to use for each environment. In the example below, I am using a blue icon for production, green for system test and red for development.

Now, switch back into the Build Settings tab for your target, and search for the Asset Catalog App Icon Set Name setting. Set the appropriate values for each configuration.

Getting Ready to Switch Environment Files

So, we now have the raw per-environment configuration files ready to go. Because you didn’t add them to the target, though, they aren’t going to be packaged into the app’s bundle.

So that the app can agnostically just pull in a specific configuration plist called environment.plist, the build process needs to replace it with the appropriate environment_XXX.plist file. We will discuss the exact mechanics of this step a bit later, however, we first need to tell Xcode that there is going to be an environment.plist file that needs to be included in the bundle.

Add an empty property list to your project called environment.plist, but this time make sure you add it to your target.

In theory, we could just leave it like this, however once environment.plist gets checked into git, then every time you do a build that switches to a different environment this file will be replaced and git will flag your workspace as being dirty and so you’ll either be forced to either a) commit that change, b) discard it, or c) just ignore it. All of those are unpalatable, so what we are going to do is tell git not to commit the environment.plist file. Essentially, we are going to treat environment.plist as a derived file (from a version control perspective), but still make sure it gets included in the final app bundle by adding it to the target.

So, either create a .gitignore file that contains a single line with environment.plist, or modify your existing one to add it. Now, because Xcode helpfully adds any new files to your git index when they are created, you will need to remove it from your current git index. To be honest, I have got no idea how to get Xcode to do this. I personally find Xcode’s git implementation underwhelming so I use SourceTree or the command line to manage my git integration.

Checkpoint

Now we have a couple of configuration files that contain the environment-specific settings, and we know the name of the environment file that the app will read from (environment.plist).

Applying Configuration Settings

Modifying the App’s Info.plist

We need to modify the app’s Info.plist file to pull in the environment-specific settings. Fortunately, Xcode provides a neat Info.plist pre-processing feature that allows us to easily do this. Set the following in the build settings for the target:

Xcode will read the file specified in the INFOPLIST_PREFIX_HEADER setting, and any #defines that are declared in there are then available to be substituted dynamically into the Info.plist.

The next section will describe how the environment_preprocess.h gets populated. However, before you go onto the next step, you need to add environment_preprocess.h to your .gitignore file (note that you don’t need to create an empty file this time though because you actually don’t want it included in your target’s bundle). Your .gitignore file should now contain (at least):

environment.plist
environment_preprocess.h

Copying the Appropriate Environment File

Now we need to perform the required steps to perform the environment-switching magic ™. To do this, we’re going to add a new Target that gets run before the app’s target that runs a small shell script to shuffle things around.

Perform the following steps:

Add a brand new aggregate Target to your project called something like Environment Switch Script.

Open the Build Phases tab for your app’s target and add Environment Switch Script as a target dependency

Open the Build Phases tab for your Environment Switch Script target, and add a Run Script phase. Xcode4 used to have a nice easy + button to do this, but Xcode5 seems to have hidden it in the Editor/Add Build Phase/Add Run Script Build Phase menu.

Add the following script to your Environment Switch Script target:

dir=${PROJECT_DIR}/${PROJECT_NAME}echo"Starting environment configuration for project: "$dir# environment variable from value passed in to xcodebuild.# if not specified, we default to DEVenv=${BDF_ENVIRONMENT}if[-z"$env"]thenenv="DEV"fiecho"Using $env environment"# copy the environment-specific filecp$dir/environment_$env.plist $dir/environment.plist
# Date and time that we are running this buildbuildDate=`date"+%F %H:%M:%S"`todaysDay=`date"+%d"`# app settingsappName=`/usr/libexec/PlistBuddy -c"Print :appName""$dir/environment.plist"`version=`/usr/libexec/PlistBuddy -c"Print :CFBundleShortVersionString""${PROJECT_DIR}/${PROJECT_NAME}/Info.plist"`if["$env"!= "PROD"]then# need to dynamically append today's day for non-prodappName="$appName$todaysDay"# Last hash from the current branchversion=`git log --pretty=format:"%h" -1`fi# Build the preprocess filecd${PROJECT_DIR}/${PROJECT_NAME}preprocessFile="environment_preprocess.h"echo"Creating header file"echo-e"//-----------------------------------------">$preprocessFileecho-e"// Auto generated file">>$preprocessFileecho-e"// Created $buildDate">>$preprocessFileecho-e"//-----------------------------------------">>$preprocessFileecho-e"">>$preprocessFileecho-e"#define BDF_ENVIRONMENT $env">>$preprocessFileecho-e"#define BDF_ENVIRONMENT_LAST_COMMIT $version">>$preprocessFileecho-e"#define BDF_ENVIRONMENT_APP_NAME $appName">>$preprocessFile# dump out file to build logcat$preprocessFile# Force the system to process the plist fileecho"Touching plist at: "${PROJECT_DIR}/${PROJECT_NAME}/${PROJECT_NAME}-Info.plist
touch${PROJECT_DIR}/${PROJECT_NAME}/${PROJECT_NAME}-Info.plist
# doneecho"Done."

This will copy over the appropriate environment settings at build time, and create the environment_preprocess.h file that will be used to substitute into the Info.plist.

NB: You may be wondering why we didn’t just add a Run Script directly to your app’s target? The reason is that the Info.plist preprocess header file (environment_preprocess.h) must be present before the main app build process begins. By adding a dependent build target, you can be sure the environment_preprocess.h file has definitely been created before your app’s target starts to get built.

The very last step in this section is to modify your Info.plist file to reference the variables that will be created in the preprocess header file. Set the following values:

Bundle Display Name = BDF_ENVIRONMENT_APP_NAME

Bundle Version = BDF_ENVIRONMENT_LAST_COMMIT

Bundle Identifier = ${BDF_BUNDLE_ID}

You might be wondering why the bundle identifier is using the ${...} notation instead of just using the variable name directly like the others? The reason is to do with the way Xcode verifies that you have the correct provisioning profile available.

Although, the Info.plist pre-processing occurs very early in the build cycle, Xcode also performs a few dependency checks before the pre-processing has occurred – one of which is that there is a provisioning profile that matches the bundle identifier. If we rely on the pre-processing substitution, Xcode will be looking for a provisioning profile that matches an app called BDF_BUNDLE_ID, which will obviously cause an error. To get around this, we earlier created a User-Defined Build Setting called BDF_BUNDLE_ID – which is available and able to be substituted prior to the Info.plist pre-processing step.

A little annoying, but a necessary step.

Checkpoint

At this point, you should be able to build and run your app and at least the icons and app name will be set correctly.

Reading the Environmental Settings in the App

In the environment_XXX.plist example I gave above, I only listed values that are needed to support the build process. Feel free to add your own variables. Generally speaking, you can now access the environment variables by just fetching them from the environment.plist class, however, I prefer to wrap it in a class that looks something like:

Provisioning, Code Signing and Push Notifications

We all love provisioning and code signing, don’t we? The solution outlined in this post will end up creating several apps all with different bundle identifiers. For example:

com.bdf.myapp.DEV

com.bdf.myapp.SYS

com.bdf.myapp

This does imply that you are going to have to create a new provisioning profile for each one. However, the nice thing about choosing to use configurations and schemes, though, is that you can use the Build Settings for each of your configurations to set the appropriate provisioning profiles and signing certificates.

Compiling

Using Xcode

For developers, switching between environments is literally as simple as picking the correct scheme, building and running. Because of our .gitignore usage, switching between environments doesn’t cause git to report anything has changed. It is really, really easy.

Continuous Integration

When running a CI server, it is pretty simple to perform the builds also. The process above has been tested using Jenkins and TestFlight, and works well. The command that ends up getting run by Jenkins looks like:

Testing

As mentioned in the previous point, we were using TestFlight for getting our build to the testers. But distribution notwithstanding, a couple of key benefits I would mention:

Testers can easily tell which environment they are connecting to by just checking the colour of the icon and the app name.

They can easily tell how recent their build is because the app name contains the day of the build.

When a tester reports a problem, because the git commit hash is stored in the version number, the developers can easily use TestFlight to determine which specific commit is needed to recreate the same build.

Adding a New Environment

Over time, you will want to add new environments into your build process. To save you re-reading the whole article each time,the list of things you need to do are:

Add a new configuration. If you are clone an existing configuration be sure to pick the right one. I recently (accidentally) cloned the PROD configuration (which has all the optimization/variable stripping enabled) and then spent the next five hours trying to work out why the Xcode debugger was behaving weirdly.

Add a new scheme and set each phase to use the new configuration.

Modify the BDF_ENVIRONMENT project build setting to have the appropriate value.

Modify the BDF_BUNDLE_ID project build setting to have the appropriate value.

Add a new environment_XXX.plist file.

Create a new icon and add a new App Icon icon set in your Asset Catalog.

Modify your target’s Asset Catalog App Icon Set Name build setting to point to your new icon set.

Modify your target’s provision profile and code signing settings for the new configuration

Wrap-Up

So, it has been a long-winded tale, however I hope it has been worth it.
I have uploaded a sample project so you can see what a working example looks like.

Feel free to contact me on twitter or email if you have any feedback, questions or issues.

“Click on the Editor/Add Build Setting/Add User-Defined Setting menu and add a variable called BDF_ENVIRONMENT (pick any name you like… this just what I am calling it for this article). Note that sometimes Xcode forgets what you have selected and doesn’t enable the menu items properly – if this happens, select something else (say, a target) and the re-select the project – sigh!”

To enable menu item “Editor/Add Build Setting/Add User-Defined Setting”, you need to switch from “Info” tab to “Build Settings” on project, another way “Add User-Defined Setting” will be disable.

Hi, This is very awesome tutorial. I gone through the steps but i am receiving below error at build time. Please suggest me how can i resolve
build error:
:1:10: fatal error: ‘environment_preprocess.h’ file not found

but this file is generating and added in the project source folder
Thanks for your reply

Craig, very well written article and code. I was able to apply all in xcode8.0 downloading and comparing your project with mine. Thanks again for such a well written article.

I was hoping to get some advice in regards to an integration with React-Native.
This library https://github.com/luggit/react-native-config runs a script specified in build Phases. The script generates a.m file that has the Environment variables read from a common root file shared between ios and Android. Eventually Javascript is able to call a method in the.m file to read the environment.
After I have applied your instructions this is not possible anymore. I am not sure if the ruby script runs at all or the problem it somewhere else. I was hoping to be pointed in the right reaserch direction at least.

I must admit I haven’t used this technique to build a ReactNative project but I can’t think of a reason why it wouldn’t work.

I must admit, I’m not sure exactly what you mean by “this is not possible anymore”. Is the build process failing? Is it failing at runtime? Do you see any error messages? You mention a Ruby file, but I’m not sure what that ruby file does (the Build Phase script, maybe)?

This is the ruby file that runs on the build phase of the module:
https://github.com/luggit/react-native-config/blob/master/ios/ReactNativeConfig/BuildDotenvConfig.ruby
it reads the files .env form the react-native project root and dynamically adds GeneratedDotEnv.m with the environment variables converted into a dictionary used by #define DOT_ENV (at my best guess).
This library makes it possible to read from JavaScript this ENV variable. For example I am reading the API_URL which changes from dev to stage and production.
The build succeed, no error but the JavaScript is not able to get the variable anymore, which makes me think some part of the library build phase has not been triggered