I’ve been exploring more and more tooling around iOS ecosystem. One tool I really enjoy using those days is Github Action as a continuous integration for my projects. Today we’ll dive into tips and tweaks to make the most of it.
Github Action is a docker environment that allows you to execute different steps around your project. Like any cloud-based continuous integration system, it can help you analyse your code, run your test and verify your build, but also deploy it, and so on.
Github Action relies on a workflow based on a YAML file that is part of the repository, under a .github
folder at the root of your repo. You can have as many workflow as you want.
From there, you can integrate steps and actions in your workflow. Most of them are open-sourced (I’m not sure if it can be private), free to use, and available through Github Marketplace.
The setup is really straight forward, so we’ll focus directly into the workflow and steps.
Let’s imagine I have a Swift Package I want to build and test over time. I would like to run the build and test on a macOS environment for any new pull request, to make sure I don’t break anything.
Here what the .yml
file would look like:
name: Swift
on:
pull_request:
branches: [ main ]
jobs:
build:
runs-on: macos-latest
steps:
# Checking out our code base
- uses: actions/checkout@v2
- name: Build
run: swift build -v
- name: Run tests
run: swift test -v
Pretty simple, right?
I chose macOS for this workflow but I can also use Linux operating system, they are much more common, we wouldn’t have to wait for an available machine.
Of course, we know that iOS project can be a bit more complex than just a standalone package. It often includes dependencies about tooling, like bundler, fastlane or slather for instance. It can also include code dependencies through CocoaPods or Carthage that we need to resolve before testing.
name: iOS project
on:
pull_request:
branches: [ main ]
jobs:
build:
runs-on: macos-latest
steps:
- uses: actions/checkout@v2
- name: Bundle dependencies
run: bundle install
- name: CocoaPods dependencies
run: bundle exec pod install
- name: Run tests
run: bundle exec fastlane scan
If you’re familiar with other continuous integration system, you’ll notice I don’t enforce CocoaPods to get the latest --repo-update
. Here, I assume that since Github Action runs on docker image, there is no cache to rely on, so it should stay up-to-date. But I’ll come back to this one.
So far so good, now let’s look into tweaks to improve our workflow
Cancelling previous build
First thing I can think of is to avoid having multiple build for the same pull request. After all, the service is free until a certain limit.
concurrency:
group: build-ios-${{ github.ref }}
cancel-in-progress: true
jobs:
#...
From cancelling in-progress build, any new commit will stop the previous one. It saves build time, so it saves money.
Environment variables and secrets
For a stable build across developers, it can important for all your tests to run on the same environment. For same version of Xcode.
jobs:
build:
runs-on: macOS-11
env:
XCODE_VERSION: 12.5.1
steps:
- uses: actions/checkout@v2
- name: Select Xcode 12
run: sudo xcode-select --switch /Applications/Xcode_${{ env.XCODE_VERSION }}.app
We can also define a matrix to define multiple variation for the job. For instance, we can define multiple Xcode versions, or iOS devices.
strategy:
matrix:
xcode_version: [10.3, 12.5.1]
jobs:
build:
# ...
steps:
- uses: actions/checkout@v2
- name: Select Xcode
run: sudo xcode-select --switch /Applications/Xcode_${{ matrix.xcode_version }}.app
The same way, we can set other environment variables, something that your tooling can pick. For instance, we can override fastlane variables.
jobs:
build:
runs-on: macOS-11
env:
# Override fastlane tooling variables
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 180
FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 10
We can also access secret environment defined at the repository level, or the organization level.
${{ secrets.MY_VARIABLE }}
Runs steps on conditions
Some part of the workflow might only make sense if the rest succeeded before.
For instance, we could limit code coverage only when tests successfully passed.
At the same time, we might want another step to capture the tests report regardless.
- name: Run coverage
if: success()
run: capture-coverage.sh
- name: Publish test report
if: always()
run: publish-report.sh
We can also use if: failure()
for failing conditions.
Draft pull request workflow
One state of Github pull request is to be as draft when it’s still in progress.
By default, only opened
, synchronize
or reopened
pull request are triggering Github Action.
To enable it for draft conversion (and back), we need to extend the workflow. You can read more about the pull requests on the official documentation.
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
From there we can also detect when the job runs for a draft pull request.
if: github.event.pull_request.draft == true
Speeding up iOS workflow
One issue that is constant for cloud-based continuous integration is the build time of the workflow.
A big part of it is to get the dependencies ready. If you have already think about it ahead, using remote caching system for your dependencies, you might be in a great place of optimization.
If you haven’t, Carthage supports remote caching, CocoaPods have few solutions as well. Bazel or Buck builds do also caching and incremental builds that could speed up your workflow.
That being said, Github Action also proposes a caching action that we can leverage for your next build.
From cache, we can setup what to keep for the next build. It has to be set on a key.
- uses: actions/cache@v2
with:
path: Pods
key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }}
restore-keys: |
${{ runner.os }}-pods-
Running workflows sequently
By default, workflows run in parallel but steps run one at a time.
To make simpler workflow and more reusable, while keeping dependencies between them (i.e. test only if build passed, etc.), we can use needs
keyword to chain them.
We can still add conditions to it when required.
jobs:
job1:
job2:
needs: job1
job3:
if: ${{ always() }}
needs: [job1, job2]
In this case, job3
would still run regardless of job1
and job2
results.
Pull Private Dependencies
For security reason, some organisations required to access Github only from whitelist IP, from the company VPN for instance. The great part of Github Action, it doesn’t require this VPN access and can directly access the code.
That being said, some dependencies can be other private repositories and harder to access. At the moment, I found only two ways to work around:
# ...
- uses: webfactory/ssh-agent@v0.5.4
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
# ...
In any case, be considerate to the security access you granted, the second one handles a private key in the workflow, it’s not something to handle lightly.
If Github Action is quite recent in the Continuous Integration system, it already have a lot of options to make it a performant environment to consider. It’s super simple to integrate and maintain, and decently priced for indie developers or small organizations (2000 free minutes per month).
That being said, as the team and iOS project grow over time, I have few concern how to keep the cost under control. For instance, macOS machine run time is multiplied 10x what Linux is. So improving the running time becomes key.
The other part I haven’t finalized yet is the caching system. Ideally we want an environment to run fast and often, and waiting ~15mins just for CocoaPods dependencies at every build sounds like a waste. There are few actions to improve this, but it’s a never ending quest of optimization.
The final great point of Github Action is probably the actions are open-sourced, so we can see what the action does before integrating it. Like any dependencies, you should still have a look at its content. For instance, just for security concern, it is an external dependency, maybe one could upload your code somewhere else? We’re never too careful.
Thanks for reading!