As developers, we build all the time. However, much of our time is also spent on preparing builds, running tests, and pushing them to Stores. Secretly we all wish the process to be easier.
In this article, we are going to see how can we build, test, and deploy our iOS app on AppStore automatically without using Fastlane. The build will be pushed to Testflight after merging PR in the master branch of the git repository.
To make navigation easier, I have divided the article into 3 separate sections. We will explore scripts to —
Today, we will set up CI in such a way that every commit will run tests and build process but only master commits will push builds to TestFlight.
I have used GitLab CI for example but you can take script commands and fit them into any other CI like Jenkins and Github Actions easily.
Note — I have assumed you all have the basic idea about app deployment on ITC (AppStore Connect) and you already have created your app to the AppStore console.
To start running our pipeline on Gitlab CI, some basic setup is required. You can refer to this documentation for guidance.
We will also need a Code Signing Certificate and Provision Profile from the Apple developer console. Refer to this blog for more details about generating code-signing certificates.
You will need certificates and profiles only for deployment though, we will cover more details there.
Let’s get started with the automation of the build workflow.
To automate the build process, we will add a build stage in our Gitlab CI pipeline. In Gitlab, a stage can have multiple jobs. For example, consider a scenario where you have a build
stage that requires running both unit and integration tests. All jobs of the same stage run in parallel.
We will have 3 stages: build, test and deploy. These all will run linearly.
Let’s start by adding the script of the build
stage. As you might have seen in the basic GitLab CI setup, We will need to create a .gitlab-ci.yml
file to add our build script.
stages:
- build
- test
- deploy
build_project:
stage: build
script:
- pod install --repo-update
- xcodebuild clean build -workspace "ProjectName.xcworkspace" -scheme "ProjectName" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED="NO" | xcpretty --c
tags:
- runner-tag-name
except:
- main
The script contains 2 main sections — stages
and build_project
. We already discussed stages, so let’s focus on the latter.
build_project
is the name of the job we have defined for building the project. You can change it to anything as per your needs. The job has script
section which will be executed when this job runs.
The script section contains —
xcodebuild
tool provided by Xcode. It comes bundled with Xcode, so no installation is required.CODE_SIGNING_REQUIRED
and CODE_SIGNING_ALLOWED
arguments to xcodebuild
and the value is set to No
as build does not require signing.-scheme
argument is used to specify the scheme we want to build.xcpretty --c
pipe is used to make logs readable.Furthermore, we have added except
section to run this job on every commit of branches except main
. As we will be running the deployment job on the master branch, we expect deployment to fail as well in case of build errors. However, feel free to run this on the master branch as well according to your use cases.
If you have added any type of test like the Unit Test or UI Test in your project, we will need to add a job for running those tests on every pushed change.
We will use the test stage for running our tests. Here’s a script that we need to add to .gitlab-ci.yml
file for automation.
unit_test:
stage: test
script:
- pod install --repo-update
- xcodebuild test -workspace "ProjectName.xcworkspace" -scheme "ProjectName" -sdk "iphonesimulator" -destination "platform=iOS Simulator,name=iPhone 11 Pro" | xcpretty --color
tags:
- runner-tag-name
except:
- tags
Everything is almost the same as build
script but here we pass build
argument to xcodebuild
to run unit and UI tests.
Apart from that, we have provided -sdk "iphonesimulator" -destination "platform=iOS Simulator,name=iPhone 11 Pro"
argument to run tests on iPhone 11 Pro device. That should be changed according to the needs.
Now to the final stage and that is deployment.
This stage will have more configuration compared to test
and build
stage as we need to install the certificate and provision profile on CI.
We will explore the installation of the certificates and profile later. Let’s first see what our script looks like.
deploy_testflight:
stage: deploy
script:
- chmod +x install_certificate.sh && ./install_certificate.sh
- chmod +x install_profile.sh && ./install_profile.sh
- pod install --repo-update
- ARCHIVE_PATH="$HOME/Library/Developer/Xcode/Archives/ProjectName/${CI_COMMIT_SHA}/${CI_JOB_ID}.xcarchive"
- EXPORT_PATH="$HOME/Library/Developer/Xcode/Archives/ProjectName/${CI_COMMIT_SHA}/${CI_JOB_ID}/"
- xcodebuild -workspace ProjectName.xcworkspace -scheme "ProjectName" clean archive -sdk iphoneos -archivePath $ARCHIVE_PATH PROVISIONING_PROFILE_SPECIFIER="${DISTRIBUTION_PROVISION_UUID}" CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY="${CODE_SIGN_IDENTITY}" | xcpretty --c
- xcodebuild -exportArchive -archivePath $ARCHIVE_PATH -exportOptionsPlist ExportOptionsAppStore.plist -exportPath $EXPORT_PATH PROVISIONING_PROFILE_SPECIFIER="${DISTRIBUTION_PROVISION_UUID}" CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY="${CODE_SIGN_IDENTITY}"
- echo "Collecting artifacts.."
- cp -R "${ARCHIVE_PATH}/dSYMs" .
- IPA="${EXPORT_PATH}ProjectName.ipa"
- echo $IPA
- echo "Uploading app to iTC..."
- xcrun altool --upload-app -t ios -f $IPA -u $ITC_USER_NAME -p $ITC_USER_PASSWORD
artifacts:
paths:
- dSYMs
only:
- main
tags:
- runner-tag-name
Now, this looks a lot longer than the build script. Let’s see what’s in script
section step by step. I have divided the script into 3 subsections. We will explore the archive and export phase first.
install_certificate.sh
and install_profile.sh
for now, we will understand it in the next section. They are used just for managing signing builds.ARCHIVE_PATH
variable is used to hold the file path where we want to store build files.EXPORT_PATH
variable is used to hold the directory path where we want to store the exported archive. This will be sent to AppStore Connect.xcodebuild — clean archieve
command archives the project.-workspace
argument takes the name of the xcworkspace
file of the project.-sdk iphoneos
argument is used to specify the OS we want to create an archive for.-archivePath $ARCHIVE_PATH
argument is used to specify the file path where we want to store the archive.PROVISIONING_PROFILE_SPECIFIER="${DIST_PROVISION_UUID}"
argument is used to specify the provisioning profile. Here, we have used an environment variable that we will explore shortly in the next section.CODE_SIGN_STYLE=Manual
indicates that we want to handle signing manually.CODE_SIGN_IDENTITY="${DIST_CODE_SIGN_IDENTITY}"
argument is used to specify signing identity. We will look at this env variable shortly.xcodebuild -exportArchive
exports archive as an .ipa
file.-archivePath $ARCHIVE_PATH
is used to specify the path of the stored archive.-exportOptionsPlist ExportOptionsAppStore.plist
argument is used to specify a few export-related configs. We will explore it in the next section.-exportPath $EXPORT_PATH
argument is used to provide the path where we want to store .ipa
file.That’s it. A long list of steps but pretty straightforward. By now, we will have an .ipa
file that we can push to AppStore connect.
Now let’s understand the signing configuration that is used in archive
and export
steps.
As described in the Basic Setup section earlier, you should have a certificate and provision profile of the distribution mode. (Please make sure you don’t use the development certificate and profile, otherwise, you will waste a lot of your valuable time).
Now you need to export the certificate as .p12
file, follow this guide for more details. Note: Remember the password you have entered while exporting the certificate.
To pass our certificates to CI, we will use Gitlab CI Environment variables as certificates should be stored inside the Git repository. As environment variables are string values, we will need to convert the certificate and profile to base64 string using this command.
base64 -i FILE_PATH -o outputfilename
Now let’s add them as Environment variables.
Go to Project Setting => CI-CD => Variables and then add the following variables.
APPLE_DISTRIBUTION_CERTIFICATE_KEY
= Base64 value of distribution certificate file.APPLE_DISTRIBUTION_CERTIFICATE_PASSWORD
= Certificate password set at the time of exporting.BUILD_KEY_CHAIN
= login.keychain
(Your Default keychain name). This is where the certificate will be installed.BUILD_KEY_CHAIN_PASSWORD
= Add your keychain access passwordDISTRIBUTION_PROVISION_KEY
= Base64 value of distribution provision profile file.DISTRIBUTION_PROVISION_UUID
= Get UUID from the provision profile.CODE_SIGN_IDENTITY
= Get it from provision profile - CERTIFICATES section => Name.We are ready with the variables, now let’s create small scripts to install it.
Here’s the script install_certificate.sh
that is used to install the distribution certificate.
#!/usr/bin/env sh
CERTIFICATE_P12=certificate.p12
# Recreate the certificate from the secure environment variable
echo $APPLE_DISTRIBUTION_CERTIFICATE_KEY | base64 --decode > $CERTIFICATE_P12
# Unlock the keychain
security unlock-keychain -p $BUILD_KEY_CHAIN_PASSWORD $BUILD_KEY_CHAIN
# Set infinite time-out of the keychain
security set-keychain-settings $BUILD_KEY_CHAIN
# Import certificates to disk from Environment variable
security import $CERTIFICATE_P12 -k $BUILD_KEY_CHAIN -P $APPLE_DISTRIBUTION_CERTIFICATE_PASSWORD -T /usr/bin/codesign;
# Allows Xcode to access certificate from keychain
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $BUILD_KEY_CHAIN_PASSWORD $BUILD_KEY_CHAIN
# remove certs
rm -fr *.p12
Here’s the script install_profile.sh
that is used to install the provisioning profile.
#!/usr/bin/env sh
PROFILE_FILE=${DISTRIBUTION_PROVISION_UUID}.mobileprovision
# Recreate the certificate from the secure environment variable
echo $DISTRIBUTION_PROVISION_KEY | base64 --decode > $PROFILE_FILE
# copy where Xcode can find it
cp ${PROFILE_FILE} "$HOME/Library/MobileDevice/Provisioning Profiles/${DISTRIBUTION_PROVISION_UUID}.mobileprovision"
# clean
rm -fr *.mobileprovision
We need to add both files to the root directory of our project, so they can be used by CI.
Additionally, we need one more .plist
file which was used while exporting the build. Here’s that .exportOptionsAppStore
plist file.
<?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>destination</key>
<string>export</string>
<key>method</key>
<string>app-store</string>
<key>provisioningProfiles</key>
<dict>
<key>your.app.bundle.identifier</key>
<string>xxxx-xxxx-xx-xxx-xxxx</string>
</dict>
<key>signingCertificate</key>
<string>provision-certifiacte-sha-id</string>
<key>signingStyle</key>
<string>manual</string>
<key>stripSwiftSymbols</key>
<true/>
<key>teamID</key>
<string>project.team.identifier</string>
<key>uploadBitcode</key>
<true/>
<key>uploadSymbols</key>
<true/>
</dict>
</plist>
All the fields are self-explanatory, feel free to change them according to your needs.
We are done with archive, export, and signing. Now let’s understand how we can push it to AppStore connect.
Here’s the command we used in the deployment script to push IPA.
- xcrun altool --upload-app -t ios -f $IPA -u $ITC_USER_NAME -p $ITC_USER_PASSWORD
Here xcrun atool
along with the --upload-app
argument is used. -f
argument is a file path where we provide a $IPA
variable we created earlier.
-u
and -p
arguments are used for authentication for AppStore connect and there we need to pass Appstore email and app-specific password. You can refer to this guide for generating an app-specific password.
Once you have a password, set the following GitLab CI environment variables.
ITC_USER_NAME
= Your Appstore email idITC_USER_PASSWORD
= App-specific passwordThat’s it for atool
configuration.
One more thing, we are also storingdSYMs
generated during the archive as artifacts to make sure they are available if we ever need them.
We are only going to run this job only when we merge a particular branch to the master branch so we restrict our job to not run for other jobs except the main(our master branch name).
Tadda… That’s it !!!
You will get your latest app update on Testflight after successfully completing the deployment job.
Hope you will have a basic idea about the whole CI-CD process. Basically, we make all the processes automatic so that we can get our Testflight build whenever there’s any update in the master branch.
Additionally, your app’s test users will also get regular updates, so that they can also test your latest app.
Whether you need...