Automating build and TestFlight upload for simple iOS apps Aug 13 2019 Latest Update: May 22 2020
Sometimes we only want to do the simplest of tasks of build and upload to TestFlight without having to spend much time doing lots of configuration. Maybe we are only testing a minimum viable product (MVP) and want to make it accessible to beta users. No matter the case, we also would like to understand the process behind the magic behind the graphic interface deployment. In this post, I'll show the basic process of building and uploading an app to the Apps Store Connect(especially for TestFlight) using the command-line. And also, how can we automate it with a simple script and the tools already provided by Xcode.
We are not going into much detail of what are targets, build configurations, build settings, or Schemes. I'll assume some familiarity with those terms. We are going to learn how to build our application and how to push it to TestFlight using the command-line.
If you've published code to App Store Connect before, you might be familiar with the build version error. The error appears when we try to upload a version of our app with a non-unique version and build pair. To avoid this error, we have to change the build version, so let's start with that.
Table of Contents
Incrementing the build version
Our apps version is represented by the pair: version and build numbers. Version represents the marketing-version, what your users see on the App Store. The build number helps you to keeps track of the releases you've made for that version. The sequence of builds for a version is also known as the "release train" for the version. We need to identify our app's release uniquely, this means the pair version
and build
should be unique. To keep the releases unique, we are going to increment the build of our app.
To increment the version and build of our app, we can do it through Xcode in the Project settings Tab.

After we've incremented our build number, we can archive and upload our app to the App Store Connect and TestFlight for our beta testers.
Another consideration to keep in mind is if we have App extensions or WatchKit apps, they have to match the same version and build than the app target.
This means that every time we want to upload our app, we need to change the bundle number on all of those targets. The good news is that when there is repetition, we can automate.
Automating the build version increase
If you want to know the process that I followed to create the script for incrementing the bundle number on all targets, keep reading. You can also skip to the next section (or download my script xcibversion).
To figure out how can we automate this process first, we need to check what happens when we changed the build number for each target.
Everyone should be using a version control system to track their code, I'll assume git
for simplicity. Anyhow, we can use git status
after changing the build number and get a list of the files that were changed.
1
2
3
4
5
$ git status
# You'll get a list with a few Info.plist files, something like this:
modified: myApp/supportFiles/Info.plist
modified: myAppFramework/Info.plist
modified: myAppTodayExtension/Info.plist
Ok so we can see that the changes occur only in the Info.plist
files. Let's see the changes, using git diff
:
1
2
3
4
5
$ git diff
# you'll see the differences that include something like:
<key>CFBundleVersion</key>
- <string>14</string>
+ <string>15</string>
So the Property list saves a key CFBUndleVersion
and the value is a string
containing our version numbers. Perfect! macOS includes the utility plutil
to work with plist
files.
We can extract information from a Property list using plutil
like this:
1
2
3
4
5
6
7
$ plutil -extract CFBundleVersion xml1 -o - ./YOUR_PATH_TO/Info.plist
# and we get the result
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<string>15</string>
</plist>
The previous command returns an xml
representation. We can use xmllint
to extract the string from the xml:
1
2
3
4
$ plutil -extract CFBundleVersion xml1 -o - ./YOUR_PATH_TO/Info.plist | xmllint --xpath "//string/text()" -
# don't forget the dash at the end of the xmllint command
# and we will get the bundle number 15 in my case
15
We can also replace a value in the plist
using plutil
.
1
2
# this will replace the previous bundle number with 16
$ plutil -replace CFBundleVersion -string "16" ./YOUR_PATH_TO/Info.plist
We now have all the parts that we need to create a bash script that will read the current version of our plist
and updated it. You can put them together in your preferred scripting language, I'll show you my implementation using bash script:
Update: thanks mrcarriere for the improvements to the code.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
#!/usr/bin/env bash
USAGE="Usage:\n${0##*/} --path=PATH [--interactive|-i] [--build=SPECIFIC_BUILD] [--dry-run]"
arguments=""
for i in "$@"
do
case $i in
--help)
printf "${USAGE}\n"
exit
;;
-i|--interactive)
INTERACTIVE=1
shift # past argument with no value
;;
--build=*)
SPECIFIC_BUILD="${i#*=}"
shift # past argument=value
;;
--path=*)
UWORKDIR="${i#*=}"
WORKDIR=$(printf %q "${UWORKDIR}")
shift # past argument=value
;;
--debug)
DEBUG=1
shift # past argument with no value
;;
--dry-run)
DRY=1
shift # past argument with no value
;;
*)
# unknown option
;;
esac
done
if [ -z ${WORKDIR} ] ; then
printf "ERROR: Missing path.\n\n${USAGE}\n"
exit 1
fi
if [ ! -z "${DEBUG}" ] ; then
echo "INTERACTIVE = ${INTERACTIVE}"
echo "SPECIFIC_BUILD = ${SPECIFIC_BUILD}"
fi
function _execute(){
if [ ! -z "${DRY}" ] ; then
echo "DRY COMMAND: ${@}"
else
eval "$@"
fi
}
find $WORKDIR -name "Info.plist" -print0 |
while IFS= read -r -d '' file; do
value=`plutil -extract CFBundleVersion xml1 -o - "$file"`
build=`echo ${value} | xmllint --xpath "//string/text()" -`
re='^[0-9]+$'
if [[ $build =~ $re ]] ; then
if [ ! -z "${SPECIFIC_BUILD}" ] ; then
new_build=$SPECIFIC_BUILD
else
new_build=$((build + 1))
fi
echo "> current: ${build} new: ${new_build}"
if [ ! -z "${INTERACTIVE}" ] ; then
while true; do
read -p "Update File: ${file} - from build:${build} to: ${new_build}? [y|n|stop] " answer
case ${answer:0:1} in
Y|y ) _execute "plutil -replace CFBundleVersion -string \"${new_build}\" \"${file}\"" ; break;;
N|n ) break;;
S|s ) exit;;
* ) echo "Please answer yes or no.";;
esac
done
else
_execute "plutil -replace CFBundleVersion -string \"${new_build}\" \"${file}\""
echo "Updated File: ${file} - from build:${build} to: ${new_build}"
fi
fi
done
With that script, we can automate the build number increments. Usage of the script is the following:
1
2
Usage:
xcibversion --path=PATH [--interactive|-i] [--build=SPECIFIC_BUILD] [--dry-run]
You can check the GitHub repo here https://github.com/rderik/xcibversion
The next step is to build the app and upload it to TestFlight.
Building your app and uploading it to TestFligth from the command line
To upload your app into TestFlight, you need your app in the ipa
package format. To obtain the ipa
, we first need to archive it and then build the ipa
from that archive.
To build the archive from the command-line, we use the xcodebuild
tool. In the example command, I used my project's myAppScheme
scheme and the Release
build configuration to create the archive. You'll have to replace these values for the ones that match your project.
If you want to check your project's configuration from the command-line, use the following command:
1
$ xcodebuild -project PATH_TO/yourProject.xcodeproj -list
The xcodebuild
tool has many useful options, you should definitely have a look at its' man page. Let's now build the archive.
Building the Archive
To build the archive, we'll use the following command. You can change the path to where you want the xcarchive
to be saved, using with the argument -archivePath
.
1
$ xcodebuild -project intoaccount/IntoAccount.xcodeproj -scheme IntoAccount -sdk iphoneos -configuration Release archive -archivePath $PWD/build/IntoAccount.xcarchive
That will generate the archive in the specified path. Now we can create the ipa
and upload it to App Store Connect.
Generating the ipa
for App Store Connect
To generate the ipa
, we will need to specify an exportOption.plist
file, this file is where you define all the parameters for your ipa
. For example, we would need to specify the distribution method. In our case, we want to distribute the ipa
through the AppStore, so we are going to set the method
to app-store
. From Xcode 10, we can automatically upload the ipa
directly to App Store Connect using the destination
key and setting it to upload
. If you want to see the full list of parameters that exportOptions
supports, run:
1
$ xcodebuild --help
Or visit my notes on exportOptions.
This is an example of the most basic exportOption.plist
:
1
2
3
4
5
6
7
8
9
10
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store</string>
<key>destination</key>
<string>upload</string>
</dict>
</plist>
Pushing to the Appstore
Finally, to upload to App Store Connect, you'll use the following command. Change the values to match your setup.
1
2
$ xcodebuild -exportArchive -archivePath $PWD/build/myApp.xcarchive -exportOptionsPlist exportOptions.plist -exportPath $PWD/build
That will upload it to App Store Connect. Now you can follow the standard procedure for setting beta test in TestFlight.
Final Thoughts
We covered the basic process of building and uploading an app to App Store Connect from the command-line.
You can add our script, or the commands we used, to any build server (i.e. Jenkins). Or you can create a bash script like the following and run it every time you want to upload a new build to App Store Connect:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/usr/bin/env bash
# SET YOUR VARIABESPROJECT_PATH=/PATH_TO_YOUR_PROJECT/
BUILD_PATH=/PATH_TO_WHERE_YOU_WANT_YOUR_BUILD_TO_BE_SAVED/
APP_NAME=YOUR_APP_NAMESCHEME=YOUR_SCHEMEEXPORT_OPTIONS_PLIST=PATH_TO_YOUR_EXPORT_OPTIONS_PLIST
# Increase build number (Assuming you added the xcibversion to your execution path)
xcibversion --path=$PROJECT_PATH
#Builds the xcarchive
xcodebuild -project $PWD/$APP_NAME.xcodeproj -scheme $SCHEME -sdk iphoneos -configuration Release archive -archivePath $BUILD_PATH/IntoAccount.xcarchive
# Builds the ipa and uploads it to the appstore
xcodebuild -exportArchive -archivePath $PWD/build/myApp.xcarchive -exportOptionsPlist $EXPORT_OPTIONS_PLIST -exportPath $BUILD_PATH
That small script is enough for many small iOS projects. Of course, you might outgrow these simple scripts, and you'll end up using something like fastlane to do more complex automation for your deployments.
Also, it is good to know what is going on behinds the scene, I like magic when I understand it. If I just use a tool and don't understand the process behind it, it just makes everything obscure and hard to debug.
There are a lot of ways and tools to accomplish what we just did in this post, I just showed you the path I think is the most straight forward. But have a look at the related topics section for other tools and approaches to building a simple setup that works for you.
I hope this helps, let me know if you find it useful or if you have any questions.
Related topics/notes of interest:
- A nice post on Xcode Version using agvtool
- A good article on how to use
plutil
to edit Property list files - Show available SDKS:
1
$ xcodebuild -showsdks
- Apple's documentation on Version Numbers and Build Numbers
Using
Application Loader
to upload and validateipa
, Apple's documentationWhen we use
atool
, we can run it usingxcrun
1
$ xcrun atool ….
When using
atool
we might need to create an app-specific password, check the documentation to create oneyou can use
atool
to check your notarization process status, and get the password from keychain:
1
2
# I named the password `atool_account` in keychain bot it can be any other name
$ xcrun altool --notarization-history 0 -u[User] -p "@keychain:altool_account"