Continuous integration for a single-platform app is a solved problem. For Kotlin Multiplatform it gets interesting fast: one push has to compile Android, JVM, and native targets, run an iOS build that requires a macOS runner, execute tests across all of them, and then publish multiplatform artifacts to a registry — without rebuilding things that didn't change. This post is the CI/CD shape that I use to keep KMP libraries shippable.
What makes KMP CI different
Three things you don't deal with on a plain JVM project:
-
A target matrix.
androidTarget,jvm,iosArm64,iosSimulatorArm64,macosArm64, sometimeslinuxX64/wasmJs. Each is its own compile + test. - macOS runners for Apple targets. iOS/macOS compilation needs Xcode, so those jobs run on (pricier, slower-to-spin-up) macOS runners. You want to not waste them.
- Multiplatform publishing. You're not shipping one JAR — you publish a Gradle module metadata graph plus per-target artifacts (and often an XCFramework for iOS consumers).
The Kotlin/Native compiler also keeps a large ~/.konan cache; if you don't persist it, every run re-downloads and re-warms it, and native builds crawl.
The pipeline, in layers
1. Gate before you build. Cheap checks first, fail fast: lint, the architecture rules (ArchUnit), and commonTest on the JVM target — most logic lives in the framework-free core, so you catch the majority of bugs in seconds without a device or simulator.
2. Build the matrix, cached. Split Apple targets onto macOS, everything else onto Linux, and cache aggressively:
jobs:
verify:
strategy:
matrix:
include:
- { os: ubuntu-latest, tasks: ":core-lib:jvmTest :core-lib:testDebugUnitTest" }
- { os: macos-latest, tasks: ":core-lib:iosSimulatorArm64Test" }
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: 17 }
- uses: gradle/actions/setup-gradle@v3 # Gradle + build cache
- name: Cache Kotlin/Native
uses: actions/cache@v4
with:
path: ~/.konan
key: konan-${{ runner.os }}-${{ hashFiles('**/gradle/libs.versions.toml') }}
- run: ./gradlew ${{ matrix.tasks }} --build-cache
The two levers that matter: the Gradle remote/local build cache (unchanged modules are downloaded, not rebuilt) and the konan cache (native compiler artifacts persist between runs). Together they turn most CI runs into "compile only what changed."
3. Only run what changed. With a modular repo, use path filters so a change in ALib doesn't trigger the BLib's full matrix. Smaller blast radius = fewer billed minutes.
4. Publish on tag. A clean push to main verifies; a version tag publishes. Each library ships as a versioned package to GitHub Packages (Maven), credentials via the workflow's GITHUB_TOKEN:
publish:
needs: verify
if: startsWith(github.ref, 'refs/tags/v')
runs-on: macos-latest # needed to publish the Apple artifacts too
steps:
- uses: actions/checkout@v4
- run: ./gradlew publishAllPublicationsToGitHubPackagesRepository
env:
GITHUB_ACTOR: ${{ github.actor }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
For iOS consumers, the publish step can also assemble an XCFramework (and a CocoaPods/SPM spec) so Swift projects get a normal native dependency.
Composite builds vs. publishing
Locally you don't want a publish round-trip to test a one-line library change against the app — so the app uses a Gradle composite build to include library source directly during development, while CI treats each library as a real published artifact. Same code, two assembly modes: fast iteration locally, clean isolation in the pipeline.
Keeping it fast (and cheap)
-
Persist
~/.konanand the Gradle cache — the single biggest win for native build time. - Keep macOS jobs minimal. They're the expensive runners; only put Apple-target work there, and let Linux carry JVM/Android.
- Fail fast on cheap checks so you don't spin up a macOS runner just to discover a lint error.
- Parallelize the matrix instead of one serial "build everything" job.
- Path-filter so unrelated libraries don't rebuild.
Gotchas worth knowing
- Flaky iOS simulator tests — boot the simulator deliberately and add retries; cold simulators time out.
- Cache keys — key on your version catalog / lockfiles so the cache invalidates when dependencies actually change, not constantly.
-
GITHUB_TOKENscope — publishing to GitHub Packages needspackages: writeon the job. -
Tag discipline — gate publish on
v*tags so a normal merge never accidentally cuts a release.
The payoff
The goal is a pipeline where a normal PR runs cheap, fast, mostly-cached checks, and a tagged release fans out across every target and publishes the whole library suite — without a human touching a build. Combined with the modular architecture, "one push, every target" stops being aspirational and becomes the boring default.
Top comments (0)