A repository of bitesize articles, tips & tricks
(in both English and French) curated by Mirego’s team.

Managing Xcode versions on Jenkins nodes

We use bare metal Mac mini to run our Jenkins worker nodes. For years we've been managing the Xcode version installed on these nodes by relying on the App Store to run the update when a new version of Xcode is available. However, letting the App Store manage which version is installed is like Heisenberg's uncertainty principle. You can know where Xcode will be, but you cannot know which version it will be. With the App Store, one never knows when the App Store update will kick-in. We have ten nodes making it very messy when a new major version of Xcode becomes available. Some node update overnight, and others don't.

Managing Xcode can be scripted thanks to the Xcode-install gem (a.k.a. xcversion).

There are many ways to distribute the gem to every node, but my preferred method is to use bundler. I use git to distribute on every node configuration project with a Gemfile in it.

source 'https://rubygems.org'

gem 'fastlane'
gem 'xcode-install'

This way, bundle install will place the required gem regardless of the ruby version installed on the node and not require system privileges if we're using the system ruby. Then, to run xcversion , all that's needed to do is use bundle exec xcversion.

To automate everything, I've created an xcode-update.sh script that will wrap all the calls to xcversion to manage the installed version. My script uses a parser to figure out the latest versions from Apple automatically. It's a bit shaky still, so I won't include it in this post. However, below I'm placing a sample script that does most of the work without figuring the last version automatically.

#!/usr/bin/env bash

# Make sure the gem is installed
bundle install

# xcversion require an AppleID and Password
if [ -z "${FASTLANE_USER}" ]; then
    echo "FASTLANE_USER is required"
    exit 1
fi

if [ -z "${FASTLANE_PASSWORD}" ]; then
    echo "FASTLNAME_PASSWORD is required"
    exit 1
fi

# Alias the command
xcversion_cmd="bundle exec xcversion"

# Install the required versions
# (this assumes no existing Xcode version on the node)
${xcversion_cmd} install 12.0.1
${xcversion_cmd} install 11.7
${xcversion_cmd} install 12.2 berta 3

# Make sure `xcode-select` points to the right version by default
${xcversion_cmd} select 12.0.1

# Make symlink so builds can refer to the target Xcode with just the major number.
ln -sfv /Applications/Xcode-11.7.app /Applications/Xcode-11.app
ln -sfv /Applications/Xcode-12.0.1.app /Applications/Xcode-12.app

# Ensure there's a /Applications/Xcode.app so build that assume the default location don't fail
ln -sfv /Applications/Xcode-12.0.1.app /Applications/Xcode.app

# Provide a predictable location for the beta version of Xcode.
ln -sfv /Applications/Xcode-12.2.beta.3.app /Applications/Xcode-Beta.app

Finally, to get this going. Creating a Jenkinfile (thank you Jenkins DSL) to automate the entire thing. It maps credentials and uses the elastic-axis plugin to execute on all nodes with a specific label (xcversion in this example)

matrixJob("maintenance/xcode_update") {
  description('Keep Beta, Current and Previous version of Xcode up to date')
  logRotator(1)
  concurrentBuild()
  axes {
    elasticAxis {
      name('Nodes participating in xcode version management')
      labelString('xcversion')
      ignoreOffline(true)
    }
  }
  triggers {
    cron('@weekly')
  }
  wrappers {
    colorizeOutput()
    credentialsBinding {
      usernamePassword('FASTLANE_USER', 'FASTLANE_PASSWORD', 'xcversion-appleid-jenkins')
      usernamePassword('LOCAL_NODE_USERNAME', 'LOCAL_NODE_PASSWORD', 'xcversion-nodes-admin-user')
    }
  }
  steps {
    shell('''
cd ${JENKINS_CONFIG}/xcversion
echo "${LOCAL_NODE_PASSWORD}" | sudo -S -E ./xcode-update.sh
''')
  }
}