.st0{fill:#FFFFFF;}

How to Set up GitHub Actions for CI with Xcode 

 March 26, 2024

by Jon Reid

2 COMMENTS

CI tooling is an important part of building confidence in the changes developers make to the code. Here’s how I like to set up a CI build system for an Xcode project using GitHub Actions.

This article describes the tricks I’ve learned for reducing the pain of the long feedback cycle of getting something to work on a remote machine.

[This post is part of the TDD in a SwiftUI World series.]

Continuous Integration (CI) is a practice, not a tool. But CI tooling helps make CI easier. So what is CI? It’s when everyone on your team integrates their changes into the main branch, and does so often. How often? “Daily” is a good start but is the barest minimum. Check out Martin Fowler’s updated article on Continuous Integration.

Make Script to Run Tests

Coding anything that runs on a remote machine can be a pain to debug due to the long feedback cycle. You know what I mean: make a change, push it to the server, wait for the job to run, and check the logs.

So I like to make scripts that I can run locally. The script doesn’t do everything the server does, but it narrows the gap. After validating the script locally, I'll have the server call it. I still need to fall back on the long feedback cycle at some point, but local validation eliminates some things that can go wrong using a fast feedback cycle.

What Goes In the Script

So let’s make a script that tells the command-line version of Xcode to build and run tests. I create a bash script in my project root folder named run_tests.sh. (You can also see it on GitHub.)

#!/bin/bash
SCHEME='Mastermind'
DESTINATION='platform=iOS Simulator,OS=latest,name=iPhone 15'
xcodebuild test -scheme $SCHEME -sdk iphonesimulator -destination "$DESTINATION" CODE_SIGNING_ALLOWED='NO'

In this example, the tests I want to run are in a scheme named Mastermind, and I’m using the iPhone 15 simulator. I like to put these two things in explaining variables so that it’s easy to see what to modify when I copy this script to a new project.

During testing, there’s no point in spending any time doing code signing. I’m eager to reclaim every scrap of time to make the feedback even faster. Every tenth of a second adds up. So I add CODE_SIGNING_ALLOWED='NO' to override the project settings.

Making the Script Executable

There are two components to making a shell script executable. One component is that first line starting with #! which is a Unix directive saying which shell interpreter to use.

The other component is to mark the script as executable. From a terminal, enter

chmod +x run_tests.sh

Now we can run the script with ./run_tests.sh. The logs should show the build results, followed by the test results.

Again, by doing this all locally first, we reduce some of the pain of getting this part to work on a remote server.

Set Up a Workflow in GitHub Actions

Next, let’s set up a GitHub Actions workflow to run this script. Create a .github directory and a workflows directory inside it. GitHub Actions treats any YAML file we create in .github/workflows as a workflow. We are creating a build workflow, so I like to name the file build.yml.

Let’s walk through what I declare in this YAML file.

Workflow Name

First, we have to give it a name.

name: Build

This is the name GitHub will show in the Actions tab.

Triggers

Next, what triggers this build workflow? We declare that with the on directive.

on:
  workflow_dispatch:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

This sets up three triggers. The last two, “on push” and “on pull request” currently react to changes on the main branch only.

The first, workflow_dispatch, lets you run the workflow manually. It adds a “Run workflow” button like this:

Cancel In-Progress Workflows

The next one is optional, depending on your needs.

Most of my workflows are for personal projects where it’s only me coding, or me pairing with a buddy. We may push one change, then push another change in a short time. Rather than waste energy validating both commits, I’d rather cancel the first job.

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

This cancels any in-progress workflows for this workflow/branch combination.

Note that canceling workflows may or may not be the behavior you want on a team project. Allowing each build to finish will help you identify the commits if the build breaks. On the other hand, you may want to save some money if you are paying for compute time.

Set Desired Version of Xcode

Finally, let’s declare the build steps. Here, you need to consider which version of Xcode you want to use to build your code — and consequently, which version of macOS. For my project, I want to use Xcode 15.2. 

Go to the list of runner images and look at Available Images. We want to find the right “YAML Label” to use. The label macos-latest is convenient if you’re content with “use the default Xcode on the default macOS.” GitHub gradually promotes the “latest” label as they update to newer versions. But beware: it doesn’t mean the latest version of macOS. Look it up in the table.

At the time I write this, macos-latest is a synonym for macos-12. On the list of Available Images, click the Included Software link for macos-12 to see what this particular runner includes. There is a table for Xcode on macos-12 that shows the highest version of Xcode it includes is 14.2. So that’s not good enough for my Xcode 15 needs.

(Ignore the large and xlarge labels unless your organization is willing to pay more for better speed.)

Okay, so macos-13 should have Xcode 15.2, right? It does, but… let’s come back to this.

Let me explain the new settings :

jobs:
  build:
    name: Build and test
    runs-on: macos-13
    steps:
    - uses: actions/checkout@v4
    - name: Show current version of Xcode
      run: xcodebuild -version

A workflow defines jobs that run in parallel. In this case, we want a single job. What follows is an identifier for the job. In this example, I call the job build. Inside this identifier, we define a human-readable name. And runs-on is the YAML Label for the image we want, which is macos-13.

Now we define the sequence of steps for this job. The first step is an action to check out the repo. uses means it runs an action defined elsewhere —in this case, the checkout action. The @v4 specifies the version.

Steps are often run directives with a name. Here, let’s list all versions of Xcode available on this system. When working with a remote server, it’s helpful to do a lot of logging so you have more information when something goes wrong.

- name: List available Xcode versions
  run: ls /Applications | grep Xcode

Let’s follow this with another step that shows the current version of Xcode.

- name: Show current version of Xcode
  run: xcodebuild -version

Whenever this workflow runs, it will list what Xcode versions are available, and which version it’s set up to use. This is a good time to push these changes and see what they give us. The click into the build log, and click the disclosure indicator to see the details of these two steps.

We can see various versions of Xcode, and that the current version is Xcode 15.0.1. If that’s good enough for you, great. But I want Xcode 15.2.

We don’t need to use an external action to set the version of Xcode to use. Doing so would add an unnecessary dependency. Dependencies have benefits but they also have costs. Let’s avoid these costs by setting the version ourselves, the same we do on our local machines:

- name: Set up Xcode version
  run: sudo xcode-select -s /Applications/Xcode_15.2.app/Contents/Developer

Pushing this change and checking the logs, we can see that we now get the version of Xcode we want.

If you don’t need a particular version of Xcode, you can use macos-latest with its default. But I would still log which versions are available, and which version it uses, in case anything goes wrong. Debugging a remote workflow is always somewhat painful. Adding extra logging can help you reduce the number of tries you’ll make.

Run the Tests

With everything configured, we can now add a step that runs the test script we set up earlier.

- name: Run tests
  run: ./run_tests.sh

With this change, the workflow is suddenly quite slow, taking 6–8 minutes at best. There several factors behind this:

  • Unless you pay extra, GitHub runs your job on an Intel machine
  • For an app, the test target runs on a specified Host Application (usually your app).
  • The Simulator has to start up.
  • Xcode 15 is still painfully slow at running tests the first time. On our local machines, we can pay this price once, getting faster feedback on the following test runs. But a build machine always starts from a clean state, so we always pay this first-time penalty.

There are ways to improve this speed, but that’s a full topic on its own.

The workflow logs show you all the gory details of the test run, including every single build detail. Scroll to the end to get the bottom line:

Test Suite 'All tests' passed at 2024-03-10 23:30:50.361.
    Executed 1 test, with 0 failures (0 unexpected) in 0.003 (0.010) seconds

You may want to add steps to install xcbeautify to clean up the output. This tool also integrates with GitHub Actions to show test failures in-line. Just remember that for the benefit, there is a cost.

  • Benefit: it makes the output easier to read.
  • Cost: it hides details.

The Whole Build Workflow

Here’s the entire build workflow. You can also see it on GitHub.

name: Build
on:
  workflow_dispatch:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
jobs:
  build:
    name: Build and test
    runs-on: macos-13
    steps:
    - uses: actions/checkout@v4
    - name: List available Xcode versions
      run: ls /Applications | grep Xcode
    - name: Set up Xcode version
      run: sudo xcode-select -s /Applications/Xcode_15.2.app/Contents/Developer
    - name: Show current version of Xcode
      run: xcodebuild -version
    - name: Run tests
      run: ./run_tests.sh


Show Results in a Status Badge

GitHub Actions has a nice feature that shows your workflow results in a status badge. Go to the Actions tab and select your workflow. Click the three dots to show workflow options, and select “Create status badge”:

Copy the markdown into your README.md.

Because I used the name “Build” for my workflow, my project has a status badge that looks like this:

Build | passing

This communicates important information to anyone looking at this project.

Conclusion

GitHub Actions has a lot of flexibility. This can make it harder to work with, but if you’re patient, it gives you a lot of power. And if you can make your repository public, it’s free!

Setting up and debugging any remote workflow is always somewhat painful. To make it less painful, here are the two main ideas to remember:

  1. If you can, extract part of your workflow into a script you can debug locally.
  2. Add extra logging.

What do you like to put in your automated workflows? And what ways have you found the pain of remote debugging? Share your tips and tricks in the comments below.

Where Will Jon Be in 2024?

Jon Reid

Programming was fun when I was a kid. But working in Silicon Valley, I saw poor code led to fear, with real human costs. Looking for ways to make my life better, I learned about Extreme Programming, including unit testing, test-driven development (TDD), and refactoring. Programming became fun again! I've now been doing TDD in Apple environments for 20 years. I'm committed to software crafting as a discipline, hoping we can all reach greater effectiveness and joy. Now a coach with Industrial Logic!

  • Two quality of life things to note: If you’re working with newer SDKs, there’s a notable delay between them being released and GitHub Actions supporting that version of Xcode. Because of this, I’ve found it useful to explicitly run tests in against iOS simulator, if possible, when using the new SDKs – as those are there earliest, where macOS delays are even longer based on OS releases and updated runners.

    Second, when something _does_ fail, it can be obnoxiously hard to find out what went wrong. Make sure to use “search” in the build log output, as Xcode’s output is very noisy and overwhelms (at least me) with details that are irrelevant to the failure. Sometimes (recently) my issues have been that something new I’m using doesn’t compile on a slightly over version of Xcode (for example, the “if let x” statement updates a release or two ago). In those cases, it’s far easier to run an older version of Xcode – one that matches what’s running in CI – on a local VM to see what’s happening and going wrong than it is to get any useful information from the logs in GH Actions.

    • Thanks for your contribution, Joe! These are good reasons to be cautious about when you adopt the very latest version of Xcode.

      It’s important for a team with an established CI build system to stay in sync with the tooling. I like using a pre-build script phase that confirms that everyone is using the team-approved version of Xcode.

  • {"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}
    >