CI/CD for KMP with GitHub Actions
|
This content was generated with the assistance of AI and should be verified against the official Kotlin Multiplatform documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
A Kotlin Multiplatform (KMP) project is built by several toolchains at once: the JVM and Android build tools, the Kotlin/Native compiler, Node.js for Kotlin/Wasm, and Xcode for everything Apple. Continuous integration (CI) is where that matrix stops being a local accident and becomes a contract: every pull request is compiled and tested on every target before it can merge, and every release is produced by a clean machine rather than by one developer’s laptop. GitHub Actions is the natural fit for a repository already hosted on GitHub; if you need a refresher on branches, pull requests and tags, see Git and GitHub.
This page targets the section baseline: Kotlin 2.4.20, Compose Multiplatform 1.12.1, AGP 9.4 with
com.android.kotlin.multiplatform.library, and Xcode 26.4, which is the Xcode version Kotlin 2.4.20 is tested
against. The actions used are actions/checkout@v7, actions/setup-java@v6, gradle/actions/setup-gradle@v6,
actions/cache@v6, actions/upload-artifact@v7 and maxim-lobanov/setup-xcode@v1. Stability is stated where it is
not Stable: Kotlin/Wasm and Compose Multiplatform for web are Beta, and Swift export and SwiftPM import are
Alpha, so a CI pipeline that depends on them should treat their failures as informative rather than blocking when
you adopt them early. The examples slice the running example, TrailMate Everywhere (package
com.example.trailmate), introduced in
What Kotlin Multiplatform Is and When to Use It, and
its small library com.example:trail-geo. Android-only pipelines (emulators, Sonar, Play tracks) are covered in
Android: CI/CD with GitHub Actions and are linked rather than
repeated; Xcode-centred pipelines are covered in
Apple: CI/CD with Xcode Cloud and the Command Line.
The Shape of a KMP Pipeline
The defining constraint is that Apple targets compile only on macOS. The Kotlin/Native compiler can cross-compile to Linux and Windows from other hosts, but it needs Xcode’s SDKs and linker to produce iOS, macOS, watchOS and tvOS binaries. Conversely, macOS runners are slower to start and more expensive than Linux runners. A good pipeline therefore splits the work by host:
| Host | Runs | Why there |
|---|---|---|
|
JVM and desktop tests ( |
Fast, cheap, cached well; no Apple SDK needed |
|
|
The only place Apple targets compile; the only place the iOS simulator runs |
The cost trade-off is how often the slow job runs. A common policy is to run the Linux job on every push and the macOS job on every pull request (and on merge to the default branch), so iOS regressions are caught before merge without paying macOS minutes for each work-in-progress commit.
JVM, Android host, Wasm tests"] PR --> MAC["macOS job
iOS tests, xcodebuild"] LIN --> MERGE["Merge to main"] MAC --> MERGE MERGE --> REL["GitHub release
(tag)"] REL --> MC["Maven Central
(trail-geo)"] REL --> SPM["SwiftPM
(XCFramework + checksum)"] REL --> STORES["Google Play and
TestFlight"]
Pinning Tool Versions
Reproducibility is the first requirement of CI, and three runner-provided tools drift silently: the JDK, Gradle and Xcode. Pin each one explicitly.
| Tool | How it is pinned | What breaks if you do not |
|---|---|---|
JDK |
|
Gradle toolchain mismatches; AGP 9 needs a recent JDK |
Gradle |
The wrapper ( |
A different Gradle than local builds, outside the range tested with Kotlin 2.4.20 |
Xcode |
|
|
Kotlin’s compatibility guide lists the Xcode version each Kotlin release is tested against, and the runner image list shows which Xcode versions exist on which image. When you upgrade Kotlin, upgrade the pinned Xcode in the same pull request.
The CI Workflow
The workflow below is the heart of the pipeline. It has two jobs, one per host, a concurrency group that cancels superseded runs, and a matrix on the macOS job to build both the library tests and the app.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
# Cancel an older run of the same branch or pull request when a new commit arrives.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
linux:
name: Linux (JVM, Android host, Wasm)
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: '21'
- uses: gradle/actions/setup-gradle@v6
# Cache the Kotlin/Native toolchain so it is downloaded once, not on every run.
- uses: actions/cache@v6
with:
path: ~/.konan
key: konan-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: konan-${{ runner.os }}-
- name: Test JVM, Android host and Wasm
run: ./gradlew jvmTest testAndroidHostTest wasmJsTest --continue
- name: Check public API (ABI) and build docs
run: ./gradlew checkKotlinAbi dokkaGenerate
- uses: actions/upload-artifact@v7
if: always()
with:
name: linux-test-reports
path: |
**/build/reports/tests/
**/build/test-results/
if-no-files-found: ignore
Each piece earns its place:
-
concurrencywithcancel-in-progressstops a stale run when you push again. On macOS minutes this is the single biggest saving, because an iOS job is the slowest in the workflow. -
permissions: contents: readgives the automaticGITHUB_TOKENthe least access that tests need; jobs that must publish raise it locally. -
setup-gradle@v6configures the Gradle user home cache (dependencies, wrapper and build-cache entries) and produces a build summary on the run page. It replaces hand-writtenactions/cachesteps for~/.gradle. -
~/.konanholds the downloaded Kotlin/Native compiler distribution, LLVM bits and dependency libraries; it is several hundred megabytes and is not covered by the Gradle cache, so cache it explicitly. Key it by the files that decide the Kotlin version, so a Kotlin upgrade creates a fresh entry. -
--continuelets Gradle run every test task and report all failures at once, instead of stopping at the first failing target. -
if: always()on the upload step is what makes reports available exactly when something failed.
The allTests aggregate task runs the tests of every target the current host can build; on Linux it skips Apple
targets, which is why naming the host’s tasks explicitly (jvmTest testAndroidHostTest wasmJsTest) is clearer in a
Linux job. The task names are explained in Testing Multiplatform Code.
The macOS Job and the iOS Simulator Build
The macOS job pins Xcode, runs the Apple tests on the iOS simulator, and then builds the real iosApp Xcode project
for the simulator with xcodebuild. This second step is the one that catches errors a Kotlin-only build cannot: a
mis-wired Run Script build phase, a missing framework search path, a Swift API that changed after the Kotlin
framework was regenerated.
# .github/workflows/ci.yml (continued)
macos:
name: macOS (${{ matrix.task.name }})
runs-on: macos-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
task:
- name: iOS shared tests
gradle: ':sharedLogic:iosSimulatorArm64Test'
- name: iOS app build
gradle: ':sharedLogic:linkDebugFrameworkIosSimulatorArm64'
steps:
- uses: actions/checkout@v7
# Pin Xcode: macos-latest moves to newer images, Kotlin 2.4.20 is tested with Xcode 26.4.
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '26.4'
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: '21'
- uses: gradle/actions/setup-gradle@v6
- uses: actions/cache@v6
with:
path: ~/.konan
key: konan-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: konan-${{ runner.os }}-
- name: Run Gradle task
run: ./gradlew ${{ matrix.task.gradle }}
- name: Build iosApp for the simulator
if: matrix.task.name == 'iOS app build'
run: |
xcodebuild build \
-project iosApp/iosApp.xcodeproj \
-scheme iosApp \
-configuration Debug \
-destination 'platform=iOS Simulator,name=iPhone 17' \
CODE_SIGNING_ALLOWED=NO
- uses: actions/upload-artifact@v7
if: always()
with:
name: macos-reports-${{ strategy.job-index }}
path: |
**/build/reports/
**/build/test-results/
if-no-files-found: ignore
Points worth understanding:
-
The matrix turns one job definition into several parallel jobs, each with its own runner. Here it separates fast shared tests from the slower app build so a failure names the stage.
fail-fast: falselets the other entries finish, which gives a full picture rather than one red cross. -
CODE_SIGNING_ALLOWED=NObuilds without certificates. CI for pull requests needs no signing identity, which is also a security property: untrusted pull-request code never sees signing material. -
The destination must name a simulator that exists on the pinned Xcode. List the available devices on the runner with
xcrun simctl list devices availableand keep the name in sync with the Xcode pin. -
Gradle runs from the Xcode build phase too. The
iosAppproject calls./gradlew :sharedLogic:embedAndSignAppleFrameworkForXcodeduring the build; the project settings iniosApp/Configuration/Config.xcconfigdecide the framework flavor. See iOS Integration Options: Framework, SwiftPM, CocoaPods and Swift Export.
A tiny Swift test target is part of the same story: if iosApp has an XCTest bundle that calls the shared framework,
run it with xcodebuild test instead of build.
// iosApp/iosAppTests/PlatformNameTests.swift
import XCTest
import sharedLogic
final class PlatformNameTests: XCTestCase {
func testPlatformNameMentionsIOS() {
// platformName() is the expect/actual function from sharedLogic.
XCTAssertTrue(PlatformKt.platformName().contains("iOS"))
}
}
Speeding Up and Stabilizing Builds
CI time is dominated by three costs: downloading toolchains, compiling Kotlin/Native, and starting simulators.
| Technique | Effect |
|---|---|
Cache |
Avoids re-downloading the Kotlin/Native toolchain; the largest single win on a cold runner |
|
Dependency and task outputs are reused; only the default branch writes the cache, so a pull request cannot poison it |
Build only the host’s targets |
Declare Apple targets conditionally or run host-specific tasks (see below), so Linux does not try to configure what it cannot build |
Configuration cache |
|
|
A hung simulator costs minutes of billing; a timeout converts it into a visible failure |
Kotlin automatically disables Apple targets on non-macOS hosts with a warning. To silence the warning in CI logs, set
kotlin.native.ignoreDisabledTargets=true in gradle.properties, as in:
# gradle.properties
org.gradle.configuration-cache=true
org.gradle.caching=true
kotlin.native.ignoreDisabledTargets=true
Publishing a Library: the Release Workflow
A library such as com.example:trail-geo is released when a maintainer publishes a GitHub release. The trigger
release: types: [released] fires when a release goes from draft or pre-release to a normal release, which is the
moment you want a public artifact; created would also fire for drafts. The workflow runs on macOS because a single
macOS host is the only machine that can produce every publication (JVM, Android, Apple, JS, Wasm) in one pass; the
vanniktech plugin 0.37.0 then uploads them to the Central Portal and the workflow builds the XCFramework for Swift
users.
# .github/workflows/release.yml
name: Release trail-geo
on:
release:
types: [released]
permissions:
contents: write # needed to attach files to the release
jobs:
publish:
runs-on: macos-latest
timeout-minutes: 90
environment: maven-central # lets you require reviewers and scope secrets
steps:
- uses: actions/checkout@v7
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '26.4'
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: '21'
- uses: gradle/actions/setup-gradle@v6
- name: Publish to Maven Central
run: ./gradlew publishAndReleaseToMavenCentral --no-configuration-cache
env:
ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_IN_MEMORY_KEY }}
ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNING_IN_MEMORY_KEY_ID }}
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_IN_MEMORY_KEY_PASSWORD }}
See vanniktech — Publishing to Maven Central and Kotlin docs — Publish your library to Maven Central.
The magic is in the naming. Gradle treats any environment variable (or -P property) beginning with
ORG_GRADLE_PROJECT_ as a project property, so ORG_GRADLE_PROJECT_signingInMemoryKey becomes the property
signingInMemoryKey that the plugin reads. The secrets never appear in gradle.properties or in the repository. The
build script that consumes them is short, and keeps the signing decision out of local builds:
// sharedLogic/build.gradle.kts (trail-geo library module)
import com.vanniktech.maven.publish.SonatypeHost
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.android.kotlin.multiplatform.library)
alias(libs.plugins.vanniktech.maven.publish) // com.vanniktech.maven.publish 0.37.0
}
mavenPublishing {
// Credentials come from ORG_GRADLE_PROJECT_mavenCentral* in CI, or ~/.gradle/gradle.properties locally.
publishToMavenCentral()
if (providers.gradleProperty("signingInMemoryKey").isPresent) {
signAllPublications()
}
coordinates("com.example", "trail-geo", version.toString())
}
See vanniktech — Publishing to Maven Central. The full publishing story (namespace, GPG, POM metadata) is in Publishing Multiplatform Libraries.
Attaching an XCFramework and its Checksum
Swift users consume a KMP library through Swift Package Manager (SwiftPM). A binary target in Package.swift points
at a zip of an XCFramework and a checksum that SwiftPM verifies before linking. The release workflow therefore
produces both and uploads them to the same release: the zip as a downloadable asset, the checksum as text that goes
into Package.swift. The XCFramework is assembled by a Gradle task that the Kotlin plugin registers when you declare
XCFramework("TrailGeo").
// sharedLogic/build.gradle.kts (continued)
import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework
kotlin {
val xcf = XCFramework("TrailGeo")
listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
target.binaries.framework {
baseName = "TrailGeo"
xcf.add(this)
}
}
}
The additional steps, appended to the publish job, build the zip, compute the checksum with SwiftPM itself, and
attach both files to the release that triggered the run:
# .github/workflows/release.yml (continued, steps of the publish job)
- name: Assemble the XCFramework
run: ./gradlew :sharedLogic:assembleTrailGeoReleaseXCFramework
- name: Zip it and compute the checksum
id: xcf
run: |
cd sharedLogic/build/XCFrameworks/release
zip -r TrailGeo.xcframework.zip TrailGeo.xcframework
CHECKSUM=$(swift package compute-checksum TrailGeo.xcframework.zip)
echo "checksum=$CHECKSUM" >> "$GITHUB_OUTPUT"
echo "$CHECKSUM" > TrailGeo.xcframework.zip.sha256
- name: Attach to the release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release upload "${{ github.event.release.tag_name }}" \
sharedLogic/build/XCFrameworks/release/TrailGeo.xcframework.zip \
sharedLogic/build/XCFrameworks/release/TrailGeo.xcframework.zip.sha256 \
--clobber
See Kotlin docs — Set up an environment to publish a library as SwiftPM and GitHub CLI — gh release upload.
The consumer’s manifest is then updated to the new URL and checksum, usually by the same workflow or by a follow-up pull request:
// Package.swift (trail-geo repository root)
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "TrailGeo",
platforms: [.iOS(.v15)],
products: [.library(name: "TrailGeo", targets: ["TrailGeo"])],
targets: [
.binaryTarget(
name: "TrailGeo",
url: "https://github.com/example/trail-geo/releases/download/1.2.0/TrailGeo.xcframework.zip",
checksum: "<checksum printed by swift package compute-checksum>"
)
]
)
Common mistakes: publishing the checksum of a different zip than the one uploaded (always zip once and compute from
that exact file), forgetting --clobber on a re-run so the upload fails, and leaving Package.swift pointing at a
version whose release asset does not exist yet.
Delivering Apps
Library publishing ends at a registry; app delivery ends at a store. The KMP part of an app pipeline is thin, because
androidApp is an ordinary Android application module and iosApp is an ordinary Xcode project; the shared code is
just one more dependency. What changes is the host split from earlier: Android delivery can run on Linux, iOS delivery
must run on macOS.
Android: Upload to Google Play
Build a signed Android App Bundle from androidApp and upload it to a Play track. The keystore decoding, the
service-account JSON and the track-promotion strategy are identical to a pure Android project, so they are described
once in Android: CI/CD with GitHub Actions, with the surrounding
concepts in Building, Signing and Releasing and
Publishing on Google Play. In a KMP repository the only difference is
the Gradle path of the app module:
# .github/workflows/release-android.yml
name: Release Android app
on:
push:
tags: ['app-v*']
jobs:
play:
runs-on: ubuntu-latest
environment: play-store
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: '21'
- uses: gradle/actions/setup-gradle@v6
- name: Build the signed bundle
run: ./gradlew :androidApp:bundleRelease
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Upload to the internal track
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
packageName: com.example.trailmate
releaseFiles: androidApp/build/outputs/bundle/release/androidApp-release.aab
track: internal
iOS: TestFlight with fastlane
TestFlight delivery needs a macOS runner, a distribution certificate, a provisioning profile and an App Store Connect API key. The signing concepts are covered in Signing, Capabilities and Configuration and the review process in App Store Connect, TestFlight and Review; fastlane wraps archive, signing and upload into a single lane that CI can call.
# iosApp/fastlane/Fastfile
default_platform(:ios)
platform :ios do
desc "Build and upload TrailMate to TestFlight"
lane :beta do
api_key = app_store_connect_api_key(
key_id: ENV["ASC_KEY_ID"],
issuer_id: ENV["ASC_ISSUER_ID"],
key_content: ENV["ASC_KEY_CONTENT"],
is_key_content_base64: true
)
# match fetches signing material from a private, encrypted store.
match(type: "appstore", readonly: true, api_key: api_key)
build_app(scheme: "iosApp", export_method: "app-store")
upload_to_testflight(api_key: api_key, skip_waiting_for_build_processing: true)
end
end
The workflow installs Xcode, lets fastlane do the signing, and passes the key as a secret:
# .github/workflows/release-ios.yml
name: Release iOS app
on:
push:
tags: ['app-v*']
jobs:
testflight:
runs-on: macos-latest
environment: app-store
steps:
- uses: actions/checkout@v7
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '26.4'
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: '21'
- uses: gradle/actions/setup-gradle@v6
- name: Upload to TestFlight
working-directory: iosApp
run: bundle exec fastlane beta
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
Two KMP-specific points apply before archiving. The Kotlin framework is built by the Xcode build phase, so the Java setup step is required on the macOS runner even though no Kotlin appears in the workflow. And App Store submission now expects a privacy manifest for the app and for frameworks it embeds; see Publishing KMP Apps for the checklist, including dSYM upload for symbolicating Kotlin frames.
Alternatives: TeamCity Cloud and Xcode Cloud
If you prefer not to own signing in YAML, the Kotlin Multiplatform IDE plugin can generate a TeamCity Cloud
configuration for an iOS pipeline, and Xcode Cloud (see
Apple: CI/CD with Xcode Cloud and the Command Line) can run
the iosApp workflow from App Store Connect. Both are mentioned here for completeness; GitHub Actions remains the
common denominator when the rest of the pipeline already lives there.
Dependabot
A KMP repository has three dependency ecosystems: Gradle (the version catalog), GitHub Actions (the pinned action versions in this page) and Swift Package Manager or Bundler for the Xcode side. Dependabot opens pull requests for all of them, and the CI workflow above validates each one.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: gradle
directory: /
schedule:
interval: weekly
groups:
kotlin-and-compose:
patterns: ["org.jetbrains.kotlin*", "org.jetbrains.compose*"]
open-pull-requests-limit: 10
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
- package-ecosystem: bundler
directory: /iosApp
schedule:
interval: monthly
Grouping Kotlin and Compose updates matters because they must move together: the Compose compiler plugin follows the Kotlin version, and Compose Multiplatform declares a Kotlin range. Bumping them in separate pull requests produces a red build for the first one for no good reason. Dependabot does not know about Xcode, so the Xcode pin is bumped by hand in the same pull request as a Kotlin upgrade.
Secrets Hygiene
Release pipelines hold the most valuable credentials in the project: the Maven Central token, the GPG signing key, the Android upload keystore and the App Store Connect API key. Treat the workflow as part of the attack surface.
| Practice | How it looks in these workflows |
|---|---|
Placeholders in the repository, secrets in GitHub |
Only |
Least privilege for |
Top-level |
Environments with required reviewers |
|
No secrets on untrusted code |
Pull requests from forks do not receive secrets; never use |
Short-lived credentials where possible |
Prefer OpenID Connect for cloud providers and scoped API keys over personal passwords |
Pin or review third-party actions |
Dependabot updates action versions; for high-risk actions, pin to a commit SHA |
No secrets in logs |
Avoid |
Rotation |
Rotate the signing key, tokens and API keys on a schedule and after any leak suspicion |
Pair the repository settings with a scanner on every push so an accidentally committed token is caught before it
reaches the default branch. The same workflow style can also run the library’s ABI check
(checkKotlinAbi), which is described in
Publishing Multiplatform Libraries.
Common Mistakes
-
Leaving Xcode on the runner default. The first image update turns a green pipeline red. Pin it with
setup-xcodeand bump it together with Kotlin. -
Running everything on macOS. It is slower and costs more; keep JVM, Android and Wasm checks on Linux.
-
Not caching
~/.konan. Every run re-downloads the Kotlin/Native toolchain. -
Forgetting
if: always()on report uploads. The reports vanish exactly when you need them. -
Skipping the
xcodebuildstep. A Gradle-only macOS job misses breakage in the Xcode project and in the Swift consumption of the framework. -
Releasing on
createdinstead ofreleased. Drafts and pre-releases would publish public artifacts. -
Computing the checksum from a different zip than the uploaded one. SwiftPM then refuses the package.
-
Committing signing keys or tokens. Use environment secrets and
ORG_GRADLE_PROJECT_*variables. -
Treating Beta targets as blocking too early. Run Wasm in a separate, non-blocking job until it is Stable.