Automating app releases for multiple platforms with Xcode Cloud

RevenueCat logo
Relax, you can roll back your mobile release

No one is immune from shipping critical bugs to production, but Runway helps you limit the amount of havoc that can cause.

I recently decided to spend some time automating the release process for NowPlaying and make shipping new versions of the app as simple as possible.

I decided to use Xcode Cloud as our CI/CD solution due to its tight integration with Xcode and App Store Connect and the fact that, as we have an Apple Developer Membership, we would get a tier of 25 compute hours per month at no extra cost!

At the moment, NowPlaying has two targets that we ship to the App Store regularly - one for iOS and one for tvOS. I wanted to create a solution that would allow us to specify which platform we want to release and have the workflow take care of the rest.

Defining the workflow

While I am taking an iterative approach to the automation and there are things I still want to improve, the workflow I settled on works in the following way:

  1. We manually create a tag in the repository that specifies the version number and platform we want to ship: iOS/<version> or visionOS/<version>.
  2. Once the tag is pushed, a workflow for the specific platform is triggered.
  3. Before the app is built, the workflow sets the MARKETING_VERSION property of all relevant targets for the specified platform to the version number in the tag.
  4. Changes are committed and pushed to the main branch of the repository.
  5. The app is built and distributed to TestFlight using Xcode Cloud.

The iOS Workflow

I started setting up the iOS workflow and all of its steps and information, which I will go through in this section.

In the general information section, I specified which repository and project to use, as well as checked the ‘Restrict Editing’ box.

The latter is required for workflows that upload builds for external testing:

Then, I added a GitHub token with push permissions to the environment variables section and marked it as a secret. This token will be used later on in the workflow to push the version update changes to the repository:

The workflow has a single start condition - when a tag is created. I specified that the workflow should only run when the tag name is prefixed with iOS/:

The workflow only has a single action that archives the app for iOS and prepares it for external testing and distribution to the App Store:

Last but not least, the workflow needs to upload the build to TestFlight and add it to any relevant groups of beta testers:

The visionOS Workflow

Both the iOS and visionOS workflows are very similar, so I will only highlight the main differences between the two you need to be aware of.

First, instead of specifying the iOS/ prefix for the tag, I specified visionOS/:

And in the Archive action, I changed the scheme and platform to the visionOS target:

Setting up Fastlane

Now that the Xcode Cloud workflow was ready, I needed to create a script that would set the MARKETING_VERSION property of the relevant targets to the version number in the tag.

I decided to use fastlane for this as it is a tool I am familiar with and it provides a lot of actions that make it easy to automate numerous tasks.

To install fastlane, I decided to use Ruby’s Bundler so that I could manage all dependencies and easily install them on CI:

bundle init

The command above created a Gemfile at the root of the repository that I then edited to include the fastlane and xcodeproj gems and support loading fastlane plugins:

source ""

gem "fastlane"
gem "xcodeproj"

plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)

Now, after running the bundle install command, I had fastlane available locally, so I could then initialize the fastlane project by running the bundle exec fastlane init command.

I also installed a plugin to help me check the git status for specific files to then decide on whether to commit changes or not:

bundle exec fastlane add_plugin fastlane-plugin-git_status

Writing the script

The first thing I did after initializing the fastlane project was to create a new lane in the FastFile with some logic to read the value of the tag, set the MARKETING_VERSION property of the relevant targets and commit the changes to the repository:

fastlane_require 'xcodeproj'

# 1
def set_all_xcodeproj_version_numbers(version_number, platform)
  project ='../NowPlaying.xcodeproj')
  targets = project.targets
  if platform == "ios"
    targets = { |target| != "NowPlayingTests" && != "NowPlaying visionOS" }
    targets = { |target| == "NowPlaying visionOS" } if platform == "visionOS"

  targets.each do |target|
    target.build_configurations.each do |config|
      config.build_settings["MARKETING_VERSION"] = version_number

desc "Description of what the lane does"
lane :bump_version do
  # 2
  split_git_ref = ENV["CI_TAG"].split("/", -1)
  version_number = split_git_ref.last
  platform = split_git_ref.first
  # 3
  set_all_xcodeproj_version_numbers(version_number, platform.downcase)
  # 4
  if git_status(path: "NowPlaying.xcodeproj/project.pbxproj").empty?
    puts "🚀 Nothing to commit, pushing the same version again!"
    sh("git fetch origin main:main")
    sh("git checkout main")

    git_commit(path: "NowPlaying.xcodeproj/project.pbxproj", message: "[🚀 release #{platform}] Updating version to: #{version_number}")

    # Push with personal access token to enable permissions in Xcode Cloud
    sh("git push https://polpielladev:#{ENV["GITHUB_TOKEN"]}")

Let’s go through the lane I created step by step:

  1. I created a method called set_all_xcodeproj_version_numbers that takes the version number and the platform as arguments. This method opens the NowPlaying.xcodeproj file and sets the MARKETING_VERSION property of all relevant targets to the version number. The platform argument is used to determine which targets to update.
  2. I then created a lane called bump_version that reads the value of the tag using Xcode Cloud’s CI_TAG environment variable and uses it to retrieve the version and platform.
  3. The new lane then calls set_all_xcodeproj_version_numbers method with the version number and platform as arguments.
  4. Finally the lane checks if the NowPlaying.xcodeproj/project.pbxproj file has been modified and, if so, commits the changes to the repository and pushes them to the main branch using the GitHub token I added to the environment variables in the Xcode Cloud workflow. If the file has not been modified, the lane prints a message to the console and exits.

Once the lane was ready, I added a new file called in a new ci_scripts directory at the root of the repository. This file is executed by Xcode Cloud straight after the repository is cloned and before the actions start.

cd ..

echo 'export GEM_HOME=$HOME/gems' >>~/.bash_profile
echo 'export PATH=$HOME/gems/bin:$PATH' >>~/.bash_profile
export GEM_HOME=$HOME/gems
export PATH="$GEM_HOME/bin:$PATH"

gem install bundler --install-dir $GEM_HOME

bundle install

bundle exec fastlane bump_version

The script installs all dependencies using Bundler and then calls the bump_version lane I created in the FastFile.

If you’d like to learn more about all available ci scripts and how to use them in Xcode Cloud, check out my article on the topic.

Future improvements

There are a couple of things I would like the workflow to do in the future:

  • Automatically update the release notes based on a local file in the repository and handle translations automatically, as I do in this article on my blog.
  • Create a new release in GitHub with the release notes and the tag.

I will keep iterating on this automation and will update the article with any new improvements I make, so make sure to keep an eye on my social media to not miss any updates!