4

Kotlin Multiplatform GitHub Actions CI Verification using Labels

 1 year ago
source link: https://akjaw.com/kotlin-multiplatform-github-actions-ci-verification-labels/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Kotlin Multiplatform GitHub Actions CI Verification using Labels

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:

kotlin-multiplatform-github-actions

Keep in mind that this article shows a version of the repository which contains boilerplate. How to remove this boilerplate will be the topic of my next article, as a sneak peek here's a PR after applying these improvements.

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.

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

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

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

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

Screenshot-2023-08-28-at-18.40.02.png

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.

Screenshot-2023-08-30-at-15.08.52.png

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.

Extras

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK