Kotlin Multiplatform GitHub Actions CI Verification using Labels
In this article, I'll show how to set up a Continuous Integration for a Kotlin Multiplatform repository. This CI will verify that the code changes don't break the main branch. The tool used for this is GitHub Actions which I use for every personal project because it is integrated with GitHub, and it is free for open source projects.
The CI verification consists of building the project and running tests both on PRs and on the main branch. However, depending on what label is selected for the PR only selected targets will be run, which saves time and costs (for paid plans).
The repository (which includes an Android, Desktop and iOS target) used in this article is available here:
Keep in mind that this article shows a version of the repository which contains boilerplate. How to remove this boilerplate is the topic of my other article.
Why is such verification needed?
Such a Continuous Integration will give us confidence, that changes made in the project don't break anything. It offloads the verification to an automated process, which doesn't forget and runs with little manual input.
If you'd like to fully automate this verification, checkout out my follow-up article on setting up automated labels.
Steps
Before delving into the details, I just want to show the steps which will be used in the workflow. Every job contains the same set-up steps, so they will be omitted for brevity in the examples, the full code is available in the repository.
Build
The Android and Desktop jobs are pretty much the same, with only the module being different. Both can be run on Ubuntu machines (which are the cheapest):
Android:
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.draft == false) }}
runs-on: ubuntu-latest
steps:
# ...
- run: ./gradlew :androidApp:assemble
Desktop:
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.draft == false) }}
runs-on: ubuntu-latest
steps:
# ...
- run: ./gradlew :desktopApp:assemble
iOS requires a macOS machine and is more involved as besides Gradle it also needs to set up CocoaPods and pods installation before running the build:
iOS:
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.draft == false) }}
runs-on: macos-12
steps:
# ...
- run: ./gradlew :shared:generateDummyFramework
- name: Set up cocoapods
uses: maxim-lobanov/setup-cocoapods@v1
with:
version: latest
- name: Install Dependencies
run: |
cd iosApp
pod install --verbose
- run: xcodebuild build -workspace iosApp/iosApp.xcworkspace -configuration Debug -scheme iosApp -sdk iphoneos -destination name='iPhone 14' -verbose
Test
These jobs are pretty much the same, the only difference being the last Gradle / Xcode command
Because now Macs with M1/M2 have a different architecture than Intel, they require a different command. The if statement launches the correct command depending on the system architecture (As of now every GitHub runner has Intel, but this future proofs the CI and allows for self-hosted runners using M1).
With native iOS tests I've run into a problem caused by Compose Multiplatform which I was not able to solve, so for now they are just skipped.
Maybe someone will be able to show me a different way of running tests which works on GHA 😄 (Comment below if you have any ideas).
Code Review Workflow
Both the Build and UnitTests workflows are called from the main Code Review workflow:
name: Code review
on:
workflow_dispatch:
push:
branches:
- main
pull_request:
types: [opened, ready_for_review, synchronize]
branches:
- main
jobs:
Build:
uses: ./.github/workflows/build.yml
UnitTests:
uses: ./.github/workflows/test.yml
This is the workflow which will be run on opened PRs and on the main branch after merging.
Running the verification only when it makes sense
You may have noticed, that all the build jobs share the same if statement (along with UnitTests):
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.draft == false) }}
This is because the jobs should only run on certain conditions
- When a PR is opened
- When a commit is pushed (but only on the main branch or on an opened PR)
- When stared manually on GitHub
The jobs shouldn't run when the PR is a draft, because these PRs are still a Work In Progress, so it makes no sense to waste resources on verifying them.
Running only the required jobs
The current solution runs every target / job regardless of what was changed. This can be improved by introducing labels to the project
Then these labels can be used on GHA to decide if something should be run or not:
Android:
if: ${{ contains(github.event.pull_request.labels.*.name, 'KMP') || contains(github.event.pull_request.labels.*.name, 'Android')}}
runs-on: ubuntu-latest
steps:
# ...
- run: ./gradlew :androidApp:assemble
The Android build job will be only run when the PR contains either a KMP or an Android label.
Please note that the above if statement for labels needs to be combined with the if statement from the previous section, which results in this pretty thing:
${{ github.event_name == 'workflow_dispatch' || github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.draft == false && (contains(github.event.pull_request.labels.*.name, 'KMP') || contains(github.event.pull_request.labels.*.name, 'Android'))) }}
This can be improved, by creating a set-up job and moving the labels to variables, however this will be explained more in-depth in my next article as stated in the beginning.
Blocking merging when the CI fails
Unfortunately, GitHub treats skipped jobs as failed, meaning that with status checks, only the KMP label would work correctly, all other labels would result in a blocked merge option, because some jobs were skipped.
This can be fixed by adding another job which runs after all other jobs complete:
jobs:
SetUp: # ...
Build: # ...
UnitTests: # ...
AllowMerge:
if: always()
runs-on: ubuntu-latest
needs: [ Build, UnitTests ]
steps:
- run: |
if [ ${{ github.event_name }} == pull_request ] && [ ${{ join(github.event.pull_request.labels.*.name) == '' }} == true ]; then
exit 1
if [ ${{ (contains(needs.Build.result, 'failure')) }} == true ] || [ ${{ (contains(needs.UnitTests.result, 'failure')) }} == true ]; then
exit 1
else
exit 0
fi
After this change, the GitHub repository can block merging when the AllowMerge doesn't pass.
The first if statement ensures that any PR has at least one label, when it's missing a label, the run will fail. Thanks to this, the problem of human forgetfulness is out of the CI equation. When someone forgets to add the label, the PR won't be allowed to merge.
The second if statement verifies that there were no failures on any of the previous jobs. In case there was at least one failure, the PR will be blocked from merging. This should guarantee that no regressions can be introduced into the main branch (unless someone puts a wrong label on the PR).
Static analysis tools
Using these types of tools as part of the Code Review workflow will keep the codebase more predictable and healthy. If you're interested in this topic, checkout my article on it:
Extras
Automated Labels
Depending on human memory isn't always the best way of doing things. Adding labels can be automated using the Labeler actions, to learn more click here:
Gradle Caching
Caching can be achieved using the official gradle-build-action, which has a built-in caching system. The default behavior only saves the cache on the main branch, and other branches can only read from the cache.
This behavior can be customized, so the cache can be saved on multiple branches as specified in the documentation.
Concurrency
A lot of time was spent optimizing the CI, to not run when not needed, e.g. when only changing Android, the iOS jobs shouldn't run. However, there is one more optimization which cancels any unneeded jobs.
By unneeded I mean a Code Review run which is not valid anymore, for example when a new commit was pushed on an opened PR. In that case, the currently running CI on the old commit should be stopped.
Fortunately, this functionality is supported in GitHub Actions:
name: Code review
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
These 3 lines, will automatically cancel any jobs which will be replaced by new ones, potentially saving a lot of unnecessary "billing minutes".
Course
I'm a co-author of an Android course which goes more in-depth into CI/CD for Android like, the above Code Review, Manual builds for testers, Release builds, Nightly verification and more. Currently, the course is only available in Polish, but there is a waitlist for those interested in an English version of it, so feel free to check it out if you're interested!