Skip to content
Go back

Every Commit on the Clock: CI/CD for Kotlin Multiplatform with GitHub Actions

by KMP Bits

KMP Bits Cover

In endurance racing, every car on the grid carries a timing transponder. The circuit infrastructure picks it up at every sector boundary, at the finish line, in and out of the pit lane. You don’t have to ask whether a car is on the pace — the timing tower knows. The split is on the board before the marshal even looks up.

I thought about that when I tried to explain to a colleague why I cared so much about CI for a side project. “It’s a side project,” he said. “You know if it builds.” I do know if it builds — on my machine, for the one target I just tested, in the configuration I happen to be running today. That’s not the same as knowing the build is clean. And in a Kotlin Multiplatform project, the gap between “it works on my machine” and “it works” is wider than you’d think.

KMP gives you one codebase and two (or more) build targets. That’s the pitch. What the pitch leaves out is that Android and iOS builds have completely separate toolchains, separate runners, and separate failure modes. The shared module compiles fine for JVM and then refuses to link for the iOS simulator because you used a dependency that doesn’t support iosArm64. The Android unit tests pass and the commonTest suite throws a NullPointerException that only surfaces on the JVM. Both builds succeed locally because you only ran one of them.

CI is the timing transponder. It runs everything, every time, and puts the result on the board.


Why KMP CI is harder than Android CI

A pure Android project needs one runner: Ubuntu or macOS, Java, Gradle, done. KMP adds a constraint that’s easy to miss until your first failed iOS pipeline: iOS builds require a macOS runner. You can’t compile an Xcode project or link an iOS framework on Linux. macOS runners on GitHub Actions cost roughly ten times more per minute than Ubuntu runners, so the naive “run everything on macOS” approach burns through your free tier fast.

The good news is that most of your KMP code — everything in commonMain — can be tested on the JVM. commonTest runs on jvmTest without a device, without a simulator, without Xcode. That means you can keep the expensive macOS runner focused on a single job: verifying that the Kotlin framework links and that the iOS app builds. Fast feedback on shared logic, expensive verification only where it counts.

The setup I’ve settled on for my own projects uses three jobs:

  1. common-tests — Ubuntu, JVM, runs commonTest. Fast and cheap. Everything downstream waits for it.
  2. android-build — Ubuntu, runs the Android build and Android unit tests. Depends on common-tests.
  3. ios-build — macOS, links the KMP framework and builds the iOS app. Depends on common-tests.

The dependency structure means the JVM tests act as a gate. If shared logic breaks, you find out in two minutes on an Ubuntu runner, not ten minutes into the macOS job.


Setting up the workflow

Create .github/workflows/ci.yml at the root of your project. Here’s the full workflow:

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  common-tests:
    name: Common Tests (JVM)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - uses: gradle/actions/setup-gradle@v4
        with:
          # PRs from forks can read cache but not write — prevents cache poisoning
          cache-read-only: ${{ github.event_name == 'pull_request' }}

      - name: Run commonTest on JVM
        run: ./gradlew :shared:jvmTest

  android-build:
    name: Android Build + Unit Tests
    runs-on: ubuntu-latest
    needs: common-tests
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - uses: gradle/actions/setup-gradle@v4
        with:
          cache-read-only: ${{ github.event_name == 'pull_request' }}

      - name: Assemble Android release APK
        run: ./gradlew :androidApp:assembleRelease

      - name: Run Android unit tests
        run: ./gradlew :androidApp:testReleaseUnitTest :shared:testReleaseUnitTest

  ios-build:
    name: iOS Build
    runs-on: macos-15
    needs: common-tests
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - uses: gradle/actions/setup-gradle@v4
        with:
          cache-read-only: ${{ github.event_name == 'pull_request' }}

      - name: Link KMP framework for iOS Simulator
        run: ./gradlew :shared:linkDebugFrameworkIosSimulatorArm64

      - name: Build iOS app (no signing)
        run: |
          xcodebuild build \
            -scheme iosApp \
            -destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \
            -derivedDataPath build/DerivedData \
            CODE_SIGNING_ALLOWED=NO
        working-directory: iosApp

Three jobs, two runners, one gate. The needs: common-tests key on both android-build and ios-build means GitHub Actions won’t start either of those jobs until the JVM tests pass.


commonTest on the JVM: what it actually covers

The target jvmTest compiles your commonTest sources against the JVM implementation. That gives you full coroutines support, real collections, real time — no runBlocking workarounds or fake dispatchers unless you want them. Any logic in commonMain that you’ve unit-tested is covered here.

What it doesn’t cover: expect/actual implementations. The JVM test run uses jvmMain actuals, not the Android or iOS ones. If your actual implementations have platform-specific logic — file paths, platform APIs, anything that diverges between targets — those need to be tested on their respective platforms.

For most projects, that’s fine. The majority of bugs in shared code are logic bugs, not platform-specific failures, and the JVM catches those. Platform-specific actuals usually wrap a system API that either works or doesn’t.

A quick note: if you have a module that only targets Android and iOS (no jvmMain), you won’t have a jvmTest target. In that case, run androidUnitTest on the Ubuntu runner for your shared logic test pass. It’s not quite as fast, but it avoids the macOS runner for the bulk of your tests.


The Android job

The Android build step does two things: assembleRelease and testReleaseUnitTest. I run release instead of debug because the release configuration is what ships, and I’ve been burned enough times by ProGuard rules that only break in release to make it a habit.

The :shared:testReleaseUnitTest task in the same job catches the Android-specific actuals. If you have an actual implementation in androidMain that uses Context or an Android API, this is where it runs.

One thing I always add to assembleRelease on CI: a local.properties override for the SDK path. The GitHub-hosted Ubuntu runner has the Android SDK, but Gradle sometimes expects a local.properties file that references it explicitly. The cleanest fix is to add this step before your Gradle tasks:

# .github/workflows/ci.yml (android-build job, before Gradle steps)
      - name: Write local.properties
        run: echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties

Without it, you’ll occasionally see SDK location not found errors that have nothing to do with your code.


The iOS job: linking the framework is the real test

The iOS job does two things. First, linkDebugFrameworkIosSimulatorArm64 — this is the Gradle task that produces the .framework bundle your Xcode project consumes. If your commonMain has a dependency that doesn’t publish a iosSimulatorArm64 variant, or if your expect declarations don’t have matching actual implementations, this task fails. It’s the earliest point at which KMP-specific iOS problems surface.

Second, the xcodebuild call builds the actual iOS app. This catches Xcode-side issues: missing files, broken Swift code, misconfigured build phases. CODE_SIGNING_ALLOWED=NO skips code signing entirely — you’re not distributing from CI, you’re just verifying the build compiles and links.

One detail I didn’t expect: the -destination flag with OS=latest resolves to whatever simulator runtime is available on the runner. GitHub’s macOS 15 runners ship with Xcode 16.x and its corresponding simulator runtimes. If your app requires a specific iOS version for a certain API, replace OS=latest with an explicit version:

-destination 'platform=iOS Simulator,name=iPhone 16,OS=18.2'

That said, I prefer OS=latest on CI unless I have a specific reason not to. The goal is to verify the build, not to pin to a simulator version that might not exist on the next runner image.


Gradle caching: the thing that makes it bearable

Without caching, a cold KMP build on GitHub Actions takes five to eight minutes on Ubuntu and twelve to fifteen minutes on macOS. With Gradle’s build cache and the gradle/actions/setup-gradle action, warm builds drop to two to three minutes on Ubuntu and five to six minutes on macOS.

The gradle/actions/setup-gradle@v4 action handles this automatically. It caches the Gradle wrapper, the dependency cache, and the build cache between runs. You don’t need to configure cache keys manually — the action generates them from your Gradle files.

The cache-read-only: ${{ github.event_name == 'pull_request' }} line is worth understanding. On pull requests from forks, the workflow runs without write access to the repository secrets — and GitHub Actions cache writes are scoped to the repository. Without this flag, fork PRs will attempt to write cache entries and fail silently, which can cause confusing build slowdowns. Setting cache-read-only on PRs means fork PRs warm from the existing cache but don’t try to write to it.

For push events on main, cache writes are enabled. That’s how the cache stays warm for the next PR.


Gotchas

1. The Xcode version on the runner changes without warning.

GitHub updates runner images, including Xcode, on their own schedule. A workflow that worked last month might break because the Xcode version changed and xcodebuild now behaves differently. Pin your runner to a specific macOS image version when stability matters:

runs-on: macos-15-xlarge

Or use the macos-14 tag if you need the previous image. The GitHub runner images page lists what each tag ships with.

2. linkDebugFrameworkIosSimulatorArm64 vs. linkReleaseFramework.

On CI, linkDebug is fine. It skips optimizations and compiles faster. If you want to verify the release framework — for example, to catch issues with @OptIn annotations or dead-code elimination — add a separate step:

- name: Link KMP release framework (optional)
  run: ./gradlew :shared:linkReleaseFrameworkIosSimulatorArm64

I don’t run this on every PR — it’s slower and catches edge cases I’d rather catch in a dedicated release workflow. For the day-to-day CI gate, debug is enough.

3. The Android emulator is almost never worth it on CI.

Instrumented tests require a running Android emulator. Spinning one up on GitHub Actions adds five minutes to your job before a single test runs. Unless you have UI tests that can’t be run with Robolectric or Compose’s runComposeUiTest, I’d keep instrumented tests out of the default CI pipeline and run them on a schedule or before release. Unit tests and Robolectric cover the vast majority of business logic without the overhead.

4. KMP compiler tasks don’t always play nicely with Gradle’s configuration cache.

If you see Configuration cache is an incubating feature warnings followed by errors, the configuration cache is enabled and something in your build is breaking it. Check your gradle.properties:

# gradle.properties
org.gradle.configuration-cache=true

KMP’s Gradle plugin has improved its configuration cache support significantly in recent versions. If you’re on Kotlin 2.0+ and still seeing failures, the culprit is usually a custom Gradle task or a third-party plugin that hasn’t been updated. The fix is usually to exclude that specific task from configuration caching or wait for the plugin update.


Wrapping up

The three-job structure (JVM gate, Android build, iOS build) gives you fast feedback on logic bugs and platform-level verification without burning macOS runner minutes on tests that don’t need them. Gradle caching keeps warm builds fast enough that CI doesn’t feel like a tax. The timing transponder is on, and the board updates on every commit.

Set it up once and you stop thinking about it. That’s the point.

Every commit on the clock, every platform in the results. Keep it flat out. 🏁


The full demo for this article will be available on GitHub soon.

The KMP Bits app is available on App Store and Google Play — built entirely with KMP.


Cover image prompt

A minimalist racing timing tower display rendered in dark navy and carbon fibre textures, showing three live sector times side by side — labelled “COMMON”, “ANDROID”, “iOS” — with green check indicators next to each. A subtle Kotlin logo watermark in the background. The aesthetic is GT3 endurance racing telemetry: crisp monospaced numbers, amber accent lines, no clutter. Wide format, 1600×900.


Share this post on:

Comments

0 / 250

Loading comments...


Previous Post
The Pit Crew: Advanced Ktor Client Configuration for KMP
Next Post
KMP Splash: How I Stopped Opening Xcode for Splash Screens