Automating build and TestFlight upload for simple iOS apps Aug 13 2019

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.

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.

Update version and build number in Xcode

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:

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
#!/usr/bin/env bash

USAGE="Usage:\n${0##*/} [--interactive|-i] [--version=SPECIFIC_VERSION] [--path=PATH][--dry-run]"
arguments=""
WORKDIR="."
for i in "$@"
do
  case $i in
    --help)
      echo "${USAGE}"
      ;;
    -i|--interactive)
      INTERACTIVE=1
      shift # past argument with no value
      ;;
    --version=*)
      SPECIFIC_VERSION="${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 "${DEBUG}" ] ; then
  echo "INTERACTIVE = ${INTERACTIVE}"
  echo "SPECIFIC_VERSION = ${SPECIFIC_VERSION}"
fi

function _execute(){
if [ ! -z "${DRY}" ] ; then
  echo "DRY COMMAND: ${@}"
else
  eval "$@"
fi
}

for file in `find ${WORKDIR} -name "Info.plist"` ; do
  value=`plutil -extract CFBundleVersion xml1 -o - $file`
  version=`echo ${value} | xmllint --xpath "//string/text()" -`
  re='^[0-9]+$'
  if [[ $version =~ $re ]] ; then
    if [ ! -z "${SPECIFIC_VERSION}" ] ; then
      new_version=$SPECIFIC_VERSION
    else
      new_version=$((version + 1))
    fi
    echo "version: ${version} New: ${new_version}"
    if [ ! -z "${INTERACTIVE}" ] ; then
      while true; do
        read -p "Update File: ${file} - fromm version:${version} to: ${new_version}?[y|n|stop] " answer
        case ${answer:0:1} in
          Y|y ) _execute "plutil -replace CFBundleVersion -string "${new_version}" ${file}" ; break;;
          N|n ) break;;
          S|s ) exit;;
          * ) echo "Please answer yes or no.";;
        esac
      done
    else
      _execute "plutil -replace CFBundleVersion -string "${new_version}" ${file}"
      echo "Updated File: ${file} - fromm version:${version} to: ${new_version}" 
    fi
  fi
done

With that script, we can automate the build number increments. Usage of the script is the following:

1
2
Usage:
xcibversion [--interactive|-i] [--build=SPECIFIC_BUILD] [--path=PATH][--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:

1
$ xcodebuild -showsdks
1
$ xcrun atool ….
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"

** There is no comment system yet, but you can send me a message on twitter @rderik or send me an email: derik[at]rderik[dot]com.