A Complete Guide to iOS App Auto Deployment with CI-CD

Build, Test, and Deploy your apps to AppStore Connect without using Fastlane
Jun 2 2022 · 7 min read draft


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 —

  1. Automate build
  2. Automate tests
  3. Automate deployment

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.

Basic Setup

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.

Automate Build

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.

  - build
  - test
  - deploy
  stage: build
    - pod install --repo-update
    - xcodebuild clean build -workspace "ProjectName.xcworkspace" -scheme "ProjectName" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED="NO" | xcpretty --c
    - runner-tag-name
    - 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 —

  1. Pod installation: If you have added external pods to your project then first we will need to install them on CI. In case of no pods, skip this.
  2. Build command: After that, we build our project on CI using the command-line xcodebuild tool provided by Xcode. It comes bundled with Xcode, so no installation is required.
  3. We have provided CODE_SIGNING_REQUIRED and CODE_SIGNING_ALLOWED arguments to xcodebuild and the value is set to No as build does not require signing.
  4. -scheme argument is used to specify the scheme we want to build.
  5. 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.

Automate Tests

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.

  stage: test
    - pod install --repo-update
    - xcodebuild test -workspace "ProjectName.xcworkspace" -scheme "ProjectName" -sdk "iphonesimulator" -destination "platform=iOS Simulator,name=iPhone 11 Pro" | xcpretty --color
    - runner-tag-name
    - 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.

Automate Deployment

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.

  stage: deploy
    - 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

      - dSYMs
    - main
    - 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.

a. Export and Archive

  1. The initial two commands install the certificate and provision profile from CI variables. Don’t worry about 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.
  2. The next command is pod install, skip it if you don’t have pods.
  3. ARCHIVE_PATH variable is used to hold the file path where we want to store build files.
  4. 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.
  5. xcodebuild — clean archievecommand archives the project.
  6. -workspace argument takes the name of the xcworkspace file of the project.
  7. -sdk iphoneos argument is used to specify the OS we want to create an archive for.
  8. -archivePath $ARCHIVE_PATH argument is used to specify the file path where we want to store the archive.
  9. 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.
  10. CODE_SIGN_STYLE=Manual indicates that we want to handle signing manually.
  11. CODE_SIGN_IDENTITY="${DIST_CODE_SIGN_IDENTITY}" argument is used to specify signing identity. We will look at this env variable shortly.
  12. xcodebuild -exportArchive exports archive as an .ipa file.
  13. -archivePath $ARCHIVE_PATH is used to specify the path of the stored archive.
  14. -exportOptionsPlist ExportOptionsAppStore.plist argument is used to specify a few export-related configs. We will explore it in the next section.
  15. -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.

b. Handle Signing

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.

  1. APPLE_DISTRIBUTION_CERTIFICATE_KEY= Base64 value of distribution certificate file.
  2. APPLE_DISTRIBUTION_CERTIFICATE_PASSWORD = Certificate password set at the time of exporting.
  3. BUILD_KEY_CHAIN = login.keychain(Your Default keychain name). This is where the certificate will be installed.
  4. BUILD_KEY_CHAIN_PASSWORD = Add your keychain access password
  5. DISTRIBUTION_PROVISION_KEY = Base64 value of distribution provision profile file.
  6. DISTRIBUTION_PROVISION_UUID = Get UUID from the provision profile.
  7. 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


# Recreate the certificate from the secure environment variable

# 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

# 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


# Recreate the certificate from the secure environment variable

# 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">

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.

c. Push IPA 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 id
  • ITC_USER_PASSWORD = App-specific password

That’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.

amisha-i image
Amisha Italiya
iOS developer @canopas | Sharing knowledge of iOS development

amisha-i image
Amisha Italiya
iOS developer @canopas | Sharing knowledge of iOS development

Whether you need...

  • *
    High-performing mobile apps
  • *
    Bulletproof cloud solutions
  • *
    Custom solutions for your business.
Bring us your toughest challenge and we'll show you the path to a sleek solution.
Talk To Our Experts
Subscribe Here!
Follow us on
2024 Canopas Software LLP. All rights reserved.