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:

GitHub - AKJAW/kotlin-multiplatform-github-actions
Contribute to AKJAW/kotlin-multiplatform-github-actions development by creating an account on GitHub.

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.

Having automatic verifications is an important part of Software Development, as the project and team grows it becomes crucial that no regressions are introduced into the "main" branch by mistake.

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.

steps:
  - uses: actions/checkout@v3

  - uses: actions/setup-java@v3
    with:
      distribution: "adopt"
      java-version: "11"

  - name: Setup Gradle
    uses: gradle/gradle-build-action@v2
The repeated set-up code

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

./gradlew clean testDebugUnitTest -p shared/
KMP Android
./gradlew clean desktopTest -p shared
KMP Desktop

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).

if [[ $(uname -m) == 'arm64' ]]; then ./gradlew clean iosSimulatorArm64Test -p shared/; else ./gradlew clean iosX64Test -p shared/; fi
KMP iOS
./gradlew clean testDebug -p androidApp/
Android
./gradlew clean jvmTest -p desktopApp/
Desktop
💡
The Gradle -p parameter is needed if the project is modularized. It basically runs the command for all sub-modules in the given module/directory.
xcodebuild build test -workspace iosApp/iosApp.xcworkspace -configuration Debug -scheme iosApp -sdk iphoneos -destination name='iPhone 14' -verbose
iOS

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.

Uncaught Kotlin exception: kotlin.IllegalStateException: Metal is not supported on this system
The error when running the iOS tests

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).

If you'd like to learn more about Kotlin Multiplatform Testing, you can check out my other articles which focus on this topic.

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.

Running only specified jobs speeds up the CI and also helps with reducing the costs of GitHub Actions on paid plans / private repositories. With Kotlin Multiplatform, the bulk of the costs comes from iOS, as the macOS machines cost 10 times more than Ubuntu!

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).

Blocking merging when the CI fails is a good way to decrease the number of issues on the main branch. It gives us confidence, that at any point in time the main branch is working and can be more or less released.

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:

Adding Konsist and Ktlint to a GitHub Actions Continuous Integration
Automating code and architecture checks in the Continuous Integration pipeline can significantly reduce Code Review time and prevent human forgetfulness.

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:

Kotlin Multiplatform GitHub Actions Automated Pull Request Labels
Having automated labels makes the Code Review process much safer by preventing merges for broken Pull Requests, ensuring that the main branch is stable

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!

Android Next Level
A course about processes outside of coding like: CI/CD, scaling your project, good git hygiene or how to cooperate with your teammates