Kotlin Multiplatform Builds on GitHub Actions

Kotlin Multiplatform on GitHub Actions needs Linux and macOS runners. Workflow YAML, warp- labels, Gradle and konan caching, sizing, and per-minute rates.

Last verified:

A Kotlin Multiplatform build on GitHub Actions has to span two operating systems, because Apple targets link only on macOS while the JVM, Android, JS, Wasm, and Linux native targets all build on Linux. WarpBuild runs that split with two runs-on labels, warp-ubuntu-latest-x64-8x at $0.016 per minute for the JVM and Android half and warp-macos-15-arm64-6x at $0.08 per minute for the Apple half, so the macOS minutes stay scoped to the work that genuinely requires macOS.

Overview

Kotlin Multiplatform compiles one commonMain source set into a separate binary per target, and each target carries a host requirement that the workflow file has to respect. Putting every target in one job forces the whole build onto macOS, which is the most expensive minute in the catalog.

Target groupGradle target functionsHost requiredRunner label
JVMjvm()Linux, macOS, or Windowswarp-ubuntu-latest-x64-8x
AndroidandroidTarget()Linux, macOS, or Windowswarp-ubuntu-latest-x64-8x
JS and Wasmjs(IR), wasmJs()Linux, macOS, or Windowswarp-ubuntu-latest-x64-8x
Linux nativelinuxX64(), linuxArm64()Linuxwarp-ubuntu-latest-x64-8x
AppleiosArm64(), iosSimulatorArm64(), macosArm64(), watchosArm64(), tvosArm64()macOSwarp-macos-15-arm64-6x
Full publicationpublishAllPublicationsToMavenRepositorymacOS, because the Apple klibs are part of the publicationwarp-macos-15-arm64-6x

Publication is the case teams get wrong most often. A release job that publishes the whole coordinate set needs the Apple klibs present, so either the publish job runs on macOS or the build uses host-specific publication and stitches the Linux and macOS halves together against the same version. Both work. Only one of them keeps macOS minutes off the pull request path.

WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, which covers the whole target matrix above with label changes rather than a second CI system. Sizes, images, and rates for every label live in the WarpBuild cloud runners documentation.

Configuration

The workflow below splits the build in two. The Linux job owns the JVM, Android, and JS targets. The macOS job owns the Apple targets. Each job configures its own Gradle cache through WarpBuilds/gradle-actions/setup-gradle@v5, and the Apple job adds a second entry for the Kotlin/Native toolchain directory, which sits outside the Gradle User Home and is therefore missed by a Gradle-only cache step.

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

jobs:
  jvm-android:
    runs-on: warp-ubuntu-latest-x64-8x
    steps:
      - uses: actions/checkout@v5

      - uses: WarpBuilds/setup-java@v5
        with:
          distribution: temurin
          java-version: "21"

      - uses: WarpBuilds/gradle-actions/setup-gradle@v5
        with:
          cache-read-only: ${{ github.ref != 'refs/heads/main' }}

      - run: ./gradlew jvmTest testDebugUnitTest jsNodeTest --build-cache --parallel

  apple:
    runs-on: warp-macos-15-arm64-6x
    steps:
      - uses: actions/checkout@v5

      - uses: WarpBuilds/setup-java@v5
        with:
          distribution: temurin
          java-version: "21"

      - uses: WarpBuilds/gradle-actions/setup-gradle@v5
        with:
          cache-read-only: ${{ github.ref != 'refs/heads/main' }}

      - name: Restore the Kotlin/Native toolchain
        uses: actions/cache@v4
        with:
          path: ~/.konan
          key: konan-macos-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
          restore-keys: |
            konan-macos-

      - run: ./gradlew iosSimulatorArm64Test macosArm64Test linkDebugFrameworkIosArm64 --build-cache

Two details in that file are worth stating plainly. WarpBuilds/gradle-actions/setup-gradle@v5 is the WarpBuild fork of gradle/actions, takes the same inputs, and caches the Gradle User Home including wrapper distributions, resolved dependencies, and build caches. The WarpBuild cache backend is enabled by default on WarpBuild runners for this fork, so both the Linux job's entries and the macOS job's entries are served by it without extra configuration. The full input list for the fork and its sibling actions is in the setup actions documentation.

cache-read-only on non-default branches is the pattern that keeps a Kotlin Multiplatform repository from writing one cache entry per pull request. The Gradle User Home for a multiplatform project is large, because it holds the dependency cache for every target plus the build cache, and letting every branch write it turns cache storage into the second-largest line on the bill.

Set the shared Gradle properties once so local runs and GitHub Actions runs agree.

org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.parallel=true
org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=1g
kotlin.daemon.jvmargs=-Xmx4g
kotlin.native.ignoreDisabledTargets=true
kotlin.incremental.native=true

kotlin.native.ignoreDisabledTargets=true is what lets the same build.gradle.kts be evaluated on both hosts, so the Linux job skips the Apple targets rather than failing configuration.

Sizing

The Linux half behaves like any Gradle build. Budget memory across three consumers, the Gradle daemon heap, the Kotlin compile daemon, and the forked test JVMs, then set worker counts against what is left. On warp-ubuntu-latest-x64-8x with 32GB, an 8GB daemon heap plus a 4GB Kotlin daemon plus four test forks at 2GB each reserves 20GB and leaves headroom for the page cache that keeps build cache reads fast.

The Apple half is sized differently, because the constraint is usually disk rather than cores. Multiple sizes and configurations per chip are available, and the two macOS shapes differ in storage as well as vCPU.

LabelmacOSvCPUMemoryStoragePer minute
warp-macos-15-arm64-6xmacOS 15622GB120GB SSD$0.08
warp-macos-15-arm64-12xmacOS 151244GB270GB SSD$0.16
warp-macos-26-arm64-6xmacOS 26622GB120GB SSD$0.08
warp-macos-26-arm64-12xmacOS 261244GB270GB SSD$0.16
warp-ubuntu-latest-x64-4xUbuntu 24.04416GB150GB SSD$0.008
warp-ubuntu-latest-x64-8xUbuntu 24.04832GB150GB SSD$0.016
warp-ubuntu-latest-x64-16xUbuntu 24.041664GB150GB SSD$0.032

Count what an Apple job holds on disk before choosing between them. The Kotlin/Native toolchain and its platform libraries under ~/.konan run to several gigabytes on their own. Add the Gradle User Home, a build directory per target, the klib outputs for every Apple target you declare, and DerivedData whenever the job also drives xcodebuild. A library with five Apple targets plus simulator variants clears 120GB more often than it saturates six cores, and that is the signal to move to warp-macos-15-arm64-12x.

The macOS 26 labels ship the Xcode 27.0 SDKs and simulator runtimes on top of the upstream GitHub macOS 26 image while GitHub's upstream macOS 27 runner image is in beta, and a dedicated macOS 27 image follows once that image is released. Pin the macOS version explicitly rather than relying on an alias, so a toolchain move is a reviewed commit.

Fan out on the Linux side. macOS concurrency is governed by a per-organization quota since July 27, 2026, and jobs beyond the quota wait for capacity, so a 20 way matrix over Apple targets buys queueing rather than throughput. Split by target group on macOS only when each leg is long enough to justify a separate runner, and keep wide matrices on the Ubuntu labels. Teams that need the Linux half inside their own cloud account can run it on BYOC, which runs on AWS, GCP, and Azure.

Bottlenecks

Kotlin/Native compilation. The link step for each Apple target is the slowest task in the build and does not parallelize across a single target the way JVM compilation does. Two moves help: keep kotlin.incremental.native=true so unchanged source sets skip recompilation, and build linkDebug* on pull requests while reserving linkRelease* for the release workflow, since release linking runs full optimization.

konan dependency downloads. A cold macOS job downloads the Kotlin/Native compiler distribution and the platform libraries for each Apple target into ~/.konan before any code compiles. This is the largest fixed cost in a first run and the one the cache step above removes. Key the entry on the Kotlin version so a version bump invalidates it exactly once rather than on every run.

Gradle configuration cache misses. A multiplatform build has a wide configuration phase, so the configuration cache pays off more here than in a single-target project. It also invalidates more easily. Reading a system property, an environment variable, or git describe output at configuration time ties the entry to that value, and a workflow that passes a run number into the build invalidates the cache on every run. Pass volatile values to task inputs instead.

Duplicated work across targets. commonMain is compiled once per target plus once for metadata, so adding a target adds compilation rather than reusing it. Two habits keep the growth linear rather than compounding: run common tests once on the JVM target instead of on every native target, and declare iosSimulatorArm64 for tests while keeping iosArm64 build only, so simulator test runs are not duplicated across device targets.

Cross-host cache keys. Cache versions are derived from the compression tool and the set of cached paths, both of which differ by runner operating system. An entry written on a macOS runner cannot be restored on an Ubuntu runner. Treat the Linux and macOS halves as two separate cache scopes and give each its own key prefix.

Missing visibility into which half is slow. CI observability reports OpenTelemetry system metrics from the runner agent alongside GitHub Actions job logs, which is where a link task pinned to one core or a daemon thrashing against its heap ceiling becomes visible.

Proof

Work the numbers on a concrete repository. Take a Kotlin Multiplatform library running 800 pull request and merge builds per month, where the Linux job takes 14 minutes on warp-ubuntu-latest-x64-8x and the Apple job takes 18 minutes on warp-macos-15-arm64-6x, holding 40GB of cache and performing 6 cache operations per Linux job.

Line itemArithmeticMonthly
Linux minutes11,200 at $0.016$179.20
macOS minutes14,400 at $0.08$1,152.00
Cache storage40GB at $0.20 per GB-month$8.00
Cache operations4,800 at $0.0001$0.48
Total$1,339.68

The same minutes on the comparable GitHub-hosted runners bill at $0.022 per minute for Linux and $0.102 per minute for the largest macOS ARM64 shape, which is $246.40 plus $1,468.80, or $1,715.20. That is $375.52 more on the month for the same job durations. GitHub publishes those rates on its Actions minute multipliers reference, checked on 2026-08-13.

Stated as list-price arithmetic, with rates for every label on the WarpBuild pricing page: warp-macos-latest-arm64-6x (6 vCPU, 22 GB) costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14 GB): 22 percent lower list price. warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list prices checked on 2026-08-13.

Verify the split on your own repository rather than taking the model on trust. Run the same commit with every target in one macOS job and again with the two-job layout above, then compare total billed minutes across both.

Gradle-side caching detail sits on the Gradle build caches on GitHub Actions runners page, the Android leg including emulator tests is covered on the Android builds on GitHub Actions runners page, the Swift and SwiftPM side of an Apple target is on the Swift package builds on GitHub Actions page, and the macOS image matrix is on the WarpBuild macOS runners page.

FAQ

Why does a Kotlin Multiplatform build need a macOS runner at all?

The Kotlin/Native compiler links Apple target binaries against the Apple SDKs and the linker that ships with Xcode, so iosArm64, iosSimulatorArm64, macosArm64, watchosArm64, and tvosArm64 can only be produced on macOS. JVM, Android, JS, Wasm, and Linux native targets all build on a Linux runner.

Which WarpBuild labels should the two halves of the build use?

Start the JVM and Android half on warp-ubuntu-latest-x64-8x at $0.016 per minute and the Apple half on warp-macos-15-arm64-6x at $0.08 per minute. Move the Apple job to warp-macos-15-arm64-12x when the target set overflows the 120GB disk or when link tasks run out of memory.

Can one cache entry serve both the Linux job and the macOS job?

No. Cache versions are derived from the compression tool and the cached paths, and those differ across runner operating systems, so a cache written on a macOS runner cannot be restored on an Ubuntu runner. Key the two halves separately and expect two sets of entries.

How many Apple jobs can run in parallel on WarpBuild?

macOS concurrency is governed by a per-organization quota since July 27, 2026, and jobs beyond the quota wait for capacity to free up. Linux and Windows runners on generally available features carry no such quota, so wide matrix fan-out belongs on the Linux half of the build.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.