Continuous Integration with Github Actions for macOS and iOS projects

GitHub Actions released in August 2019 – I’ve been trying them out for nearly a full year, using beta access available the adventurous before it was generally available. It was a long time in coming, and I saw this feature as GitHub’s missing piece. Some great companies stepped into that early gap and provide excellent services: TravisCI, CircleCI, codeship, SemaphoreCI, Bitrise, and many others. I’ve used most of these, predominantly TravisCI because it was available before the rest, and I got started with it. When GitHub finally did circle back and make actions available, I was there trying it out and seeing how it worked.

Setting up CI for macOS and iOS project has always been a little odd, but doable. For many people who are making apps, the goal is to build the code, run any unit tests, maybe run some UI or integration tests, sign the resulting elements, and ship the whole out via testflight. Tools like fastlane do a spectacular job of helping to automate into these services where Apple hasn’t provided a lot of support, or connected the dots.

I’m going to focus a bit more narrowly in this post – looking at how to leverage swift package manager and xcodebuild, both command line tools for building swift projects or mac and iOS applications respectively. I’ll leave the whole “setting up fastlane”, dealing with the complexities of signing code, and submitting builds from CI systems to others.

Building swift packages with github actions

If you want to build a swift package, then reach for swiftpm. You can’t build macOS or iOS applications with swiftpm, but you can create command-line executables or compile swift libraries. Most interestingly, you can compile swift on other platforms – linux is supported, and other operating systems (Windows, Android, and more) are being worked on by the swift open source community. Swiftpm is also the go-to tooling if you want to use the burgeoning server-side swift capabilities.

While there are some complicated corners to using the swift package manager, especially when it comes to integrating existing C or C++ libraries, the basics for how to use it are gloriously simple:

swift test --enable-test-discovery

To use that tooling, we need to define how it’s invoked – and that whole accumulation of detail is what goes into a GitHub Action declarative workflow.

To set up an action, create a YAML file in the directory .github/workflows at the root of your repository. GitHub will look in this directory for YAML files and they’ll become the actions enabled for your repository. The documentation for github actions is available at https://help.github.com/en/actions, but it isn’t exactly easy deciphering unless you’re already familiar with CI and some github specific terms.

One the simplest CI definitions I’ve seen is the CI running on SwiftDocOrg‘s DocTest repository, which builds its executables for both swift on macOS and swift on Linux:

name: CI
on:
  push:
    branches: [master]
  pull_request:
    branches: [master]
jobs:
  macos:
    runs-on: macOS-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v1
      - name: Build and Test
        run: swift test
        env:
          DEVELOPER_DIR: /Applications/Xcode_11.4.app/Contents/Developer
  linux:
    runs-on: ubuntu-latest
    container:
      image: swift:5.2
      options: --cap-add=SYS_PTRACE --security-opt seccomp=unconfined --security-opt apparmor=unconfined
    steps:
      - name: Checkout
        uses: actions/checkout@v1
      - name: Build and Test
        run: swift test --enable-test-discovery

To explain the setup, let’s look at it in pieces. The very first piece is the name of the action: CI. For all practical purposes the name effects nothing, but knowing the name can be helpful. GitHub indexes actions by name. What I find most useful is that GitHub provides an easy-to-use badge that you can drop into a README file, so that people viewing the rendered markdown will have a quick look as to the current build status.

The badge uses the repository name and the workflow name together in a URL. The pattern for this URL is:

https://github.com/USERNAME/REPOSITORY_NAME/workflows/WORKFLOW_NAME/badge.svg

Make an image link in markdown to display this badge in your README. For example, a badge for DocTest’s repository could be:

[![Actions Status](https://github.com/SwiftDocOrg/DocTest/workflows/CI/badge.svg)](https://github.com/SwiftDocOrg/DocTest/actions)

The next segment is on, that defines when the action will be applied. DocTest’s repository has the action triggering when the master branch (but no other branches) changes via push, or when a pull request is opened against the master branch.

on:
  push:
    branches: [master]
  pull_request:
    branches: [master]

The next segment is jobs, which has two elements defined underneath it. Each job is run separately, and you may declare dependencies between jobs if you want or need. Each job defines where it runs – more specifically what operating system is used, and DocTest’s example has a job for building on macOS and another for building on Linux.

The first is the macos job, which runs within a macOS virtual machine:

jobs:
  macos:
    runs-on: macOS-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v1
      - name: Build and Test
        run: swift test
        env:
          DEVELOPER_DIR: /Applications/Xcode_11.4.app/Contents/Developer

The steps are run linearly, each having to complete without a failure or error response before the next. This example shows the common practice for leveraging actions/checkout, a pre-defined action in the GitHub “Marketplace“.

Marketplace gets quotes because I think marketplace is poor name choice – you’re not required to buy anything, which I originally thought was the intention. And to be clear, I’m glad it’s not. GitHub’s mechanism allows anyone to host their own actions, and the marketplace is the place to find them.

The second step is simply invoking swift test, just like you might on your own laptop with macOS installed. The environment variable DEVELOPER_DIR is being defined here, which Xcode uses as a means to indicate which version of Xcode to use when multiple are installed. The alternative way to do this is by explicitly selecting the version of Xcode with another command xcode-select.

The GitHub actions runners have been maintained impressively well over the past year, and even beta releases of Xcode are frequently available within weeks of when they are released. The VM image has an impressive array of commonly used tools, libraries, and languages pre-installed – and that’s maintained publicly in a list at https://github.com/actions/virtual-environments/blob/master/images/macos/macos-10.15-Readme.md.

By selecting the version of Xcode with the environment variable declaration, this also implies the version of swift that’s being used, swift version 5.2 in this case.

The last segment of this CI declaration is the version that builds the swift package on Linux.

  linux:
    runs-on: ubuntu-latest
    container:
      image: swift:5.2
      options: --cap-add=SYS_PTRACE --security-opt seccomp=unconfined --security-opt apparmor=unconfined
    steps:
      - name: Checkout
        uses: actions/checkout@v1
      - name: Build and Test
        run: swift test --enable-test-discovery

In this case, it’s using Ubuntu 18.04 (the latest supported by GitHub as I’m writing this post) – which has a corresponding README of everything that includes at https://github.com/actions/virtual-environments/blob/master/images/linux/Ubuntu1804-README.md.

The container declaration defines a docker contain that’s used to run these steps on top of that base linux image, in this case the swift:5.2 image. The additional options listed are to open up specific security mechanisms otherwise locked down within a container – in this case, it’s enabling the ptrace system call, which is critical to allowing swift to run an integrated REPL or use the LLDB debugger when run within a container.

The last bit that you might have noticed is the option --enable-test-discovery. This is an option available from the swift package manager that only recently released. Where the Xcode leverages the objective-C runtime to dynamically inspect and identify test classes to run, the same didn’t exist (and was a right pain in the butt) for swift on Linux until this option was made available in swift 5.2. The build system creates an index while it’s building the code on Linux, and then uses this index to identify functions that should be invoked based on their name (the ones prefixed with test and that are within subclasses of XCTest). The end result is swift test “finding the tests” as most other unit testing libraries do.

Building macOS or iOS applications using xcodebuild with github actions

If you want to build a macOS, tvOS, iOS, or even watchOS application, use xcodebuild. xcodebuild is the command-line invocation that uses the build toolchain built into xcode, leveraging all the built in mechanisms with targets, schemes, build settings, and overlays interactions with the simulators. To use xcodebuild, you’ll need to have xcode installed – and with github actions, that’s available through virtualized instances of macOS with Xcode (and a lot of other tools) pre-installed.

The example repository I’m using is one of my own (https://github.com/heckj/MPCF-TestBench/blob/master/.github/workflows/build.yml), although it was a hard choice – as I really like NetNewsWire’s CI setup as a good example. Definitely take a look at https://github.com/Ranchero-Software/NetNewsWire/blob/master/.github/workflows/build.yml and the corresponding CI Tech Note for some excellent detail and to see how another project enabled CI on GitHub.

The whole CI file, from https://github.com/heckj/MPCF-TestBench/blob/master/.github/workflows/build.yml:

name: CI
on: [push]
jobs:
  build:
    runs-on: macos-latest
    strategy:
      matrix:
        run-config:
          - { scheme: 'MPCF-Reflector-mac', destination: 'platform=macOS' }
          - { scheme: 'MPCF-TestRunner-mac', destination: 'platform=macOS' }
          - { scheme: 'MPCF-Reflector-ios', destination: 'platform=iOS Simulator,OS=13.4.1,name=iPhone 8' }
          - { scheme: 'MPCF-TestRunner-ios', destination: 'platform=iOS Simulator,OS=13.4.1,name=iPhone 8' }
          - { scheme: 'MPCF-Reflector-tvOS', destination: 'platform=tvOS Simulator,OS=13.4,name=Apple TV' }
    steps:
    - name: Checkout Project
      uses: actions/checkout@v1
    - name: Homebrew build helpers install
      run: brew bundle
    - name: Show the currently detailed version of Xcode for CLI
      run: xcode-select -p
    - name: Show Build Settings
      run: xcodebuild -workspace MPCF-TestBench.xcworkspace -scheme '${{ matrix.run-config['scheme'] }}' -showBuildSettings
    - name: Show Build SDK
      run: xcodebuild -workspace MPCF-TestBench.xcworkspace -scheme '${{ matrix.run-config['scheme'] }}' -showsdks
    - name: Show Available Destinations
      run: xcodebuild -workspace MPCF-TestBench.xcworkspace -scheme '${{ matrix.run-config['scheme'] }}' -showdestinations
    - name: lint
      run: swift format lint --configuration .swift-format-config -r .
    - name: build and test
      run: xcodebuild clean test -scheme '${{ matrix.run-config['scheme'] }}' -destination '${{ matrix.run-config['destination'] }}' -showBuildTimingSummary

Both this example and NetNewsWire’s CI use the technique of a matrix build. This is immensely useful when you have multiple targets in the same Xcode project and want to verify that they’re all building and testing correctly. The matrix is defined right at the top of this file as a strategy:

jobs:
  build:
    runs-on: macos-latest
    strategy:
      matrix:
        run-config:
          - { scheme: 'MPCF-Reflector-mac', destination: 'platform=macOS' }
          - { scheme: 'MPCF-TestRunner-mac', destination: 'platform=macOS' }
          - { scheme: 'MPCF-Reflector-ios', destination: 'platform=iOS Simulator,OS=13.4.1,name=iPhone 8' }
          - { scheme: 'MPCF-TestRunner-ios', destination: 'platform=iOS Simulator,OS=13.4.1,name=iPhone 8' }
          - { scheme: 'MPCF-Reflector-tvOS', destination: 'platform=tvOS Simulator,OS=13.4,name=Apple TV' }

This is a single job – meaning a single operating system to run the build – but when you use a matrix is replicates the job by the size of the matrix. In this case, there are 5 matrix definitions – 2 for macOS targets, 2 for iOS targets, and 1 target for tvOS. When this is run, it runs 5 parallel instances, each with its own matrix definition and applies those values to further steps. This example defines two properties, scheme and destination, to be filled out with different values for each matrix run, and which are used later in the steps. The scheme definition corresponds to the names of schemes that are in the Xcode workspace, and destination maps to parameters used to xcodebuild’s destination argument which is a combination of target platform, name, and version of the operating system to use.

Something to be aware of – the specific values that are used in the destinations of the matrix will vary with the version of Xcode, and the latest version of Xcode will always be used unless you explicitly override it. In this example, I am intentionally setting it to latest to keep tracking any updates, but if you’re building a CI system for verifying stability over time, that’s probably something you want to explicitly declare and lock down in your CI configuration.

Checkout is pretty self explanatory, and right after that step you’ll see the step that installs helpers.

    - name: Homebrew build helpers install
      run: brew bundle

This uses a feature of Homebrew called bundle, which reads a file named Brewfile, allowing you to define a single place to say “install all these additional tools for my later use”.

The next steps are purely informational, and aren’t actually needed for the build, but are handy to have for debugging:

    - name: Show the currently detailed version of Xcode for CLI
      run: xcode-select -p
    - name: Show Build Settings
      run: xcodebuild -workspace MPCF-TestBench.xcworkspace -scheme '${{ matrix.run-config['scheme'] }}' -showBuildSettings
    - name: Show Build SDK
      run: xcodebuild -workspace MPCF-TestBench.xcworkspace -scheme '${{ matrix.run-config['scheme'] }}' -showsdks
    - name: Show Available Destinations
      run: xcodebuild -workspace MPCF-TestBench.xcworkspace -scheme '${{ matrix.run-config['scheme'] }}' -showdestinations

These all invoke xcodebuild with the various options to show the parameters available. These parameters vary with the version of Xcode that is running, and the very first command (xcode-select -p) prints that version.

The next command, named lint, uses one of the helpers that is defined in that Brewfileswift format. In this example, I’m using Apple’s swift-format command (the other common example is Nick Lockwood’s swiftformat). In other projects I’ve used Nick’s swiftformat (and the often paired tool: swiftlint), both of which I like quite a lot. This project was an excuse to try out the tooling from the Apple’s open source version and see how it worked, and how I liked working with it. My final opinion is still pending, but it mostly does the right thing.

    - name: lint
      run: swift format lint --configuration .swift-format-config -r .

The lint step is really applying a verification of formatting, so arguably might not be relevant to be included in a build. I rely on linters (in several languages) to yell at me, as otherwise I’m lousy about consistency in how I format code. In practice, I also try and use the same tools to auto-format the code to just keep it up to whatever standard seems predominantly acceptable.

The final step is where it compiles and run the tests:

    - name: build and test
      run: xcodebuild clean test -scheme '${{ matrix.run-config['scheme'] }}' -destination '${{ matrix.run-config['destination'] }}' -showBuildTimingSummary

And you can see the references to the matrix values that are applied directly as parameters to xcodebuild. The text ${{ matrix.run-config['scheme'] }} is a replacement definition, indicating that the value of scheme for the running matrix build should be dropped into that position for the command line argument.

The NetNewsWire project uses the exact same technique, although the developers run xcodebuild from within a shell script so that they can arrange for signing keys and other parameters to be set up. It’s a thoughtful and ingenious system, but quite a bit harder to explain or show the matrix being used directly.

The downsides of GitHub Actions

While I am very pleased with how GitHub actions works, I am completely leveraging the “no cost to run” options. The cost of the pay-for GitHub Actions is notable, and in some cases, injuriously expensive.

If you’re running GitHub actions in a private repository (and paying for the privilege) you may find it just too expensive. The billing information for github actions shows that running macOS virtual images is 10 times the price of running Linux images ($0.08 per minute vs. $0.008 per minute). If you build on every pull request you’re going to wrack up an impressive number of minutes, very quickly. On top of that, techniques like the matrix build add an additional multiplier – 5x in my case. GitHub does offer the option of allowing you to create your own “GitHub Action runners”, and I’d recommend seriously looking at that option – just using GitHub as the coordinator – from the cost perspective alone. It’s more “stuff you have to maintain”, but in the end – likely quite a bit cheaper than paying the GitHub Actions hosting fees.

If you are building something purely open source, then you’re allowed to take advantage of the free services. That’s pretty darned nice.

Where GitHub is not yet supporting the swift ecosystem

This isn’t directly related to GitHub Actions, but more a related note on how GitHub is (or isn’t) supporting the swift ecosystem. There are a tremendous number of support options in their site for some great features, but really all of them are pretty anemic when it comes to supporting swift, either via Apple or through the open source ecosystem:

  • There’s no current availability for swift language syntax highlighting. It does offer Objective-C, which is a pretty good fallback, but swift specific highlighting would be really lovely to have when reviewing pull requests or just reading code.
  • GitHub’s security mechanisms, which host security advisories, track dependency flow, and the recently announced security code scanning – don’t support swift:
    • Dependencies through Package.swift, although parsable, aren’t tracked and aren’t reflected.
    • While you can draft a security advisory and host it at Github, it won’t propagate to any dependencies because it’s not tracked.
  • A year ago GitHub announced that it would be supporting “Swift Packages” – I’m not aware of much that came of that effort, if anything.
  • The same constraint of not parsing and tracking dependencies is highlighted in their Insights tab and the “Dependency graph”. On swift packages, there’s nothing. Same with Xcode projects. Nada, zilch, zip.
  • The code scanning, which uses a really great technology called CodeQL, doesn’t support Swift, or even Objective-C.

At a bare minimum, I would have hoped that GitHub would have enabled parsing and showing dependencies for Package.swift. I can understand not wanting to reverse engineer the .pbproj XML plist structure to get to dependencies, but swift itself is open source and the package manifest is completely open.

In addition Swift Syntax, the AST parsing mechanisms that swift enables through open source, are being used for swift language server support. These, I think, would be a perfect complement to leverage within CodeQL.

I do hope we’ll so more movement on these fronts from GitHub, but if not – well, I suppose it’s a good differentiating opportunity for competitive sites such as BitBucket or GitLab.