Publishing KMP Apps to Google Play, the App Store, Desktop and the Web
|
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. |
Building a Kotlin Multiplatform (KMP) app is half the job; the other half is getting it into the hands of users through four very different channels. Each channel has its own packaging format, signing identity, review process and crash tooling, and none of them cares that the business logic is shared. A Kotlin library is published once to Maven Central, but an application is published once per platform: an Android App Bundle to Google Play, an archive to App Store Connect and TestFlight, per-OS installers for desktop, and a folder of static files for the web.
This page walks through the release path of each target of TrailMate Everywhere (package com.example.trailmate),
the running example introduced in
What Kotlin Multiplatform Is and When to Use It. It
targets the section baseline: Kotlin 2.4.20, Compose Multiplatform 1.12.1, AGP 9.4 (the
com.android.kotlin.multiplatform.library plugin in shared modules, and com.android.application in androidApp)
and Xcode 26.4. Stability is stated wherever it is not Stable: Android, iOS and desktop are Stable, while the
Kotlin/Wasm web target and Compose Multiplatform for web are Beta, so a production web release is a conscious
choice. Publishing libraries (Maven Central, SwiftPM, npm) is a different workflow, covered in
Publishing Multiplatform Libraries; automating
the steps below is covered in
CI/CD for KMP with GitHub Actions.
One App, Four Release Pipelines
The shared code is compiled into a different artifact per target, and the artifact decides everything downstream.
| Target | Artifact | Where it goes | Signing identity |
|---|---|---|---|
Android |
|
Google Play Console |
Upload key; Google holds the app signing key (Play App Signing) |
iOS |
|
App Store Connect, TestFlight, App Store |
Apple Distribution certificate and provisioning profile |
Desktop |
|
Your website, a package repository, or a store |
Developer ID (macOS), Authenticode (Windows, optional) |
Web |
Static folder from |
Any static host, such as GitHub Pages |
None; HTTPS certificate of the host |
Two consequences follow. First, versioning must be coordinated, otherwise the same release is "1.4.0" on one store and "1.3.9" on another. Second, the shared module is only as releasable as its weakest platform: a Kotlin change that compiles for Android can still break the iOS archive or the Wasm bundle, so every release candidate should be built on every target. See iOS Integration Options for how the shared framework reaches the Xcode project.
A Single Source of Truth for Versions
Every store wants two values: a human-readable version name shown to users, and a monotonically increasing build number the store uses to order uploads. They go by different names on each platform.
| Concept | Android | iOS | Desktop |
|---|---|---|---|
Version name |
|
|
|
Build number |
|
|
Not used by stores; |
Keep the numbers in one place, gradle.properties in the repository root, and let each platform read them.
# gradle.properties
app.versionName=1.4.0
app.versionCode=140
The Android and desktop modules read the properties directly. For iOS, a small Gradle task writes an .xcconfig file
that the Xcode project includes, so Xcode never holds a second copy of the numbers.
// build.gradle.kts (root)
tasks.register("syncIosVersion") {
val out = layout.projectDirectory.file("iosApp/Configuration/Version.xcconfig")
val name = providers.gradleProperty("app.versionName")
val code = providers.gradleProperty("app.versionCode")
inputs.property("name", name)
inputs.property("code", code)
outputs.file(out)
doLast {
out.asFile.writeText(
"MARKETING_VERSION = ${name.get()}\nCURRENT_PROJECT_VERSION = ${code.get()}\n"
)
}
}
In Xcode, set iosApp/Configuration/Config.xcconfig to #include "Version.xcconfig" and leave the target’s version
fields as $(MARKETING_VERSION) and $(CURRENT_PROJECT_VERSION). The same values can be read at run time, for
example on the About screen of the SwiftUI version of TrailMate.
// iosApp/iosApp/AboutView.swift
import SwiftUI
struct AboutView: View {
private var info: [String: Any] { Bundle.main.infoDictionary ?? [:] }
var body: some View {
let version = info["CFBundleShortVersionString"] as? String ?? "?"
let build = info["CFBundleVersion"] as? String ?? "?"
Text("TrailMate \(version) (\(build))")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
A common mistake is bumping only versionName. Play rejects an upload whose versionCode is not greater than every
previous one, and App Store Connect rejects a build whose build number was already used for that version. Derive the
build number from the CI run number or a git commit count if manual bumping becomes error-prone.
Android: the Release Build of androidApp
Under AGP 9, the Android application cannot be combined with the KMP plugin in one module: it lives in its own
androidApp module and consumes sharedUI and sharedLogic like any other dependency (see
The Android Target and AGP 9). That is good news for
releasing, because the release configuration of androidApp is the ordinary Android release configuration, fully
described in Android: Building, Signing and Preparing a Release.
This section shows only the multiplatform-specific parts.
// androidApp/build.gradle.kts
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.compose.compiler)
}
android {
namespace = "com.example.trailmate.android"
compileSdk = 36
defaultConfig {
applicationId = "com.example.trailmate"
minSdk = 24
targetSdk = 36
versionCode = providers.gradleProperty("app.versionCode").get().toInt()
versionName = providers.gradleProperty("app.versionName").get()
}
signingConfigs {
create("release") {
// Values come from the environment; never commit a keystore or its passwords
storeFile = System.getenv("TRAILMATE_KEYSTORE")?.let(::file)
storePassword = System.getenv("TRAILMATE_KEYSTORE_PASSWORD")
keyAlias = System.getenv("TRAILMATE_KEY_ALIAS")
keyPassword = System.getenv("TRAILMATE_KEY_PASSWORD")
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
signingConfig = signingConfigs.getByName("release")
}
}
}
dependencies {
implementation(projects.sharedUI)
implementation(projects.sharedLogic)
}
Build the bundle with ./gradlew :androidApp:bundleRelease; the result is
androidApp/build/outputs/bundle/release/androidApp-release.aab. Play distributes an optimized APK per device
configuration from it, which is why Play requires the bundle format for new apps (see
Android: Publishing on Google Play for the Play Console steps:
store listing, Data safety form, testing tracks, staged rollout).
R8 and Shared Code
R8 shrinks and obfuscates the whole app, including the Kotlin code from sharedLogic, which reaches androidApp as
an ordinary JVM-bytecode library. Most multiplatform libraries ship their own consumer rules, so the app usually needs
nothing extra, but reflection-based code needs explicit keep rules. In a KMP project the typical offenders are:
-
kotlinx.serialization with a hand-written
KSerializerlooked up by reflection; -
expect/actualclasses instantiated by name, for example throughClass.forName; -
native libraries loaded through JNI by a dependency.
# androidApp/proguard-rules.pro
# Keep generated serializers for DTOs shared in sharedLogic
-keepclassmembers class com.example.trailmate.data.remote.** {
*** Companion;
kotlinx.serialization.KSerializer serializer(...);
}
After every release, archive the mapping.txt file from androidApp/build/outputs/mapping/release/. Play Console
accepts it with the bundle so that Android vitals show de-obfuscated Kotlin stack traces, and your own crash reporter
needs it too. Without it, a crash in OfflineFirstTrailRepository appears as a.b.c.d and cannot be traced.
iOS: from Bundle ID to TestFlight
The iOS release is built by Xcode, not by Gradle. Gradle’s role ends when the shared Kotlin framework is linked for
iosArm64 (the embedAndSignAppleFrameworkForXcode task runs from an Xcode build phase), after which Xcode signs,
archives and uploads exactly as for a pure Swift app. The Apple-side details are in
Apple: Membership, Signing, Capabilities and Info.plist and
Apple: App Store Connect, TestFlight and App Review; the
KMP-specific points follow.
Bundle ID and Signing
The bundle ID (com.example.trailmate for the store build) identifies the app to Apple. It need not equal the
Android applicationId, but keeping them identical makes analytics and deep-link configuration simpler. Register the
App ID in the Apple Developer account, then create the app record in App Store Connect with that ID. Put it in the
.xcconfig file so debug and release builds can differ if required.
// iosApp/Configuration/Config.xcconfig
#include "Version.xcconfig"
PRODUCT_BUNDLE_IDENTIFIER = com.example.trailmate
DEVELOPMENT_TEAM = ABCDE12345
CODE_SIGN_STYLE = Automatic
ABCDE12345 is a placeholder team ID; use your own from the Developer account. Automatic signing is fine for local
builds, but archives for distribution usually use manual signing or Xcode Cloud / CI-managed profiles so that no
build depends on one developer’s keychain. The Kotlin framework itself needs no separate certificate: when it is
embedded, Xcode re-signs it with the app’s identity during the build. A framework that was not re-signed is the cause
of the classic "code signature invalid" validation failure.
Archiving
An archive is a release-configuration build packaged for distribution. Do it from Xcode (Product > Archive) or from the command line, which is what CI does.
# iosApp (run on macOS with Xcode 26.4 selected)
./gradlew syncIosVersion
xcodebuild archive \
-project iosApp/iosApp.xcodeproj \
-scheme iosApp \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath build/TrailMate.xcarchive
xcodebuild -exportArchive \
-archivePath build/TrailMate.xcarchive \
-exportOptionsPlist iosApp/ExportOptions.plist \
-exportPath build/export
The Release configuration makes the embed task link the release framework (linkReleaseFrameworkIosArm64),
which is optimized and noticeably smaller than the debug one. The Kotlin/Native release build is slower than a debug
build, often several minutes, so archive only for release candidates. The Kotlin compatibility matrix names the Xcode
version each Kotlin release was tested with (Xcode 26.4 for Kotlin 2.4.20); archiving with a much newer Xcode can work,
but pin the version on CI so a runner image update does not change your release toolchain. See
Gradle Configuration and Compatibility.
A minimal ExportOptions.plist for the App Store looks like this.
<!-- iosApp/ExportOptions.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<key>teamID</key>
<string>ABCDE12345</string>
<key>uploadSymbols</key>
<true/>
<key>signingStyle</key>
<string>automatic</string>
</dict>
</plist>
uploadSymbols makes Xcode send the debug symbols of the app and of embedded frameworks to Apple, so that crash
reports collected by TestFlight and the App Store can be symbolicated, as described below.
TestFlight
Upload the exported build with Xcode’s Organizer, the xcrun altool/Transporter tools or an API-key-based pipeline.
After processing, the build appears in App Store Connect and can be released to internal testers immediately; external
testers require a short Beta App Review for the first build of each version. TestFlight builds expire after 90 days.
Tester groups, what-to-test notes and feedback screenshots are covered in
Apple: App Store Connect, TestFlight and App Review.
The shared-UI variant of TrailMate (Compose Multiplatform inside MainViewController) is distributed in exactly the
same way, since it is just another Swift app that embeds a Kotlin framework; see
Compose Multiplatform on iOS.
JetBrains also documents a TeamCity Cloud setup, created from the Kotlin Multiplatform IDE plugin, that builds, signs and uploads to TestFlight. Treat it as one option among fastlane, Xcode Cloud and GitHub Actions; see Kotlin docs — Set up CI/CD for iOS apps with TeamCity.
Crash Symbolication for Kotlin Frames
A crash report from an iOS device contains addresses, not names. Symbolication maps those addresses back to function names and lines using the app’s dSYM (debug symbols) file, matched by build UUID. For Kotlin code the process is the same as for Swift, because Kotlin/Native compiles to LLVM and produces ordinary DWARF debug information.
| Frame in the crash report | Meaning |
|---|---|
|
Swift code, symbolicated from the app’s dSYM |
|
Kotlin function in |
Unsymbolicated hex address in the framework |
The framework’s dSYM was missing or did not match the build UUID |
Two things must be true for readable Kotlin frames:
-
The release framework must be built with debug information stripped into a dSYM, not stripped entirely. In an Xcode archive, keep Debug Information Format at
DWARF with dSYM Filefor the Release configuration (the default for new projects). The Kotlin Gradle plugin produces a.dSYMfor frameworks it links on macOS, and Xcode includes it in the archive underdSYMs/. -
The dSYM must be kept for every build you ship. The archive that was uploaded is the only place with the matching UUID. Store
build/TrailMate.xcarchive/dSYMsas a CI artifact, or letuploadSymbolssend it to Apple.
You can check that a dSYM matches a binary and symbolicate a single address by hand.
# Verify the UUIDs of the shipped framework and its dSYM match
dwarfdump --uuid build/TrailMate.xcarchive/Products/Applications/iosApp.app/Frameworks/Shared.framework/Shared
dwarfdump --uuid build/TrailMate.xcarchive/dSYMs/Shared.framework.dSYM
# Symbolicate one address from a crash log (arm64, load address from the report)
xcrun atos -arch arm64 \
-o build/TrailMate.xcarchive/dSYMs/Shared.framework.dSYM/Contents/Resources/DWARF/Shared \
-l 0x104a00000 0x104b2c1f4
See Apple Developer — Building your app to include debugging information and Apple Developer — Acquiring crash reports and diagnostic logs.
Apple’s Organizer symbolicates crashes automatically when the dSYM was uploaded. Third-party crash reporters
(Firebase Crashlytics, Sentry) need their own upload step, normally a build phase or CI step that sends the dSYMs
folder. A Kotlin exception that escapes into Swift terminates the app as an uncaught Objective-C-visible crash; how
exceptions cross the boundary, and why @Throws matters, is explained in
Objective-C and Swift Interop, and debugging
tools for the native side are in
Kotlin/Native Memory, Performance and Debugging.
Reading the reports themselves is covered in Apple: Logging, MetricKit, Crash Reports and Organizer.
The equivalent artifacts for the other targets are the R8 mapping.txt on Android (see above) and, on the web, the
source maps that the Kotlin/Wasm build emits next to the .wasm file; keep them out of the public folder if you do not
want to expose your sources.
The Privacy Manifest
Apple requires a privacy manifest, a property-list file named PrivacyInfo.xcprivacy, for apps that use required
reason APIs (file timestamps, disk space, system boot time, user defaults and active keyboards) or that collect data.
It also asks that commonly used third-party SDKs ship their own manifest. App Store Connect warns, and later
rejects, uploads that use such APIs without declaring a reason. In a KMP app there are two sources of API usage: your
Swift code, and the Kotlin code compiled into the framework, including its libraries (Ktor’s Darwin engine, DataStore,
SQLDelight’s native driver, Coil).
The approach that works regardless of how the framework is linked:
-
Add
PrivacyInfo.xcprivacyto the app target in Xcode and declare every reason that applies to the Swift code, the Kotlin framework and its dependencies. -
If the framework is static (the default
isStatic = trueof the KMP wizard), its code is linked into the app binary, and the app’s manifest covers it. -
If the framework is dynamic, or is distributed to others as an XCFramework (see the library page), it should carry its own manifest inside the framework bundle, since Apple evaluates third-party SDKs separately.
-
Run Product > Archive, then Generate Privacy Report from the Organizer to see the aggregated declarations that Apple will see, and compare them with the Kotlin libraries' documentation.
<!-- iosApp/iosApp/PrivacyInfo.xcprivacy -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
</array>
</dict>
</plist>
See Kotlin docs — Privacy manifest for Kotlin Multiplatform and Apple Developer — Privacy manifest files.
The reason codes above are examples: CA92.1 means the app reads and writes its own user defaults and C617.1 means it
accesses timestamps of files inside the app container. Choose the codes that match what your app and its libraries
actually do, from the list in
Apple Developer — Describing use of required reason API.
Declaring a reason that is untrue is a policy violation, so check the documentation of each library rather than
copying a manifest from another project. On Android the equivalent disclosure is the Play Data safety form, and for
the web it is your own privacy policy and cookie banner; see
Apple: Security and Privacy.
Desktop: Native Installers
Desktop apps built with Compose Multiplatform are packaged by the Compose Gradle plugin through the JDK’s jpackage
tool: a self-contained installer with a trimmed runtime, so users need no Java. The nativeDistributions block,
the formats table, ProGuard release builds and macOS signing and notarization are described in detail in
Compose Multiplatform for Desktop; this section
covers the release workflow.
# desktopApp: run each command on the matching operating system
./gradlew :desktopApp:packageReleaseDmg # macOS: TrailMate-1.4.0.dmg
./gradlew :desktopApp:packageReleaseMsi # Windows: TrailMate-1.4.0.msi
./gradlew :desktopApp:packageReleaseDeb # Linux: trailmate_1.4.0-1_amd64.deb
# macOS only: sign with Developer ID, notarize with Apple, staple the ticket
./gradlew :desktopApp:notarizeReleaseDmg
# Quick smoke test of the packaged app without an installer
./gradlew :desktopApp:runReleaseDistributable
Release decisions that are specific to desktop:
-
Cross-compilation is not supported. Build the
.dmgon macOS, the.msion Windows and the.debon Linux, usually with a three-runner matrix in CI. -
packageVersionmust be numeric and follow each format’s rules. macOS requiresMAJOR[.MINOR][.PATCH]withMAJORgreater than zero, Windows.msitakesMAJOR.MINOR.BUILD. Derive it fromapp.versionNameand strip any suffix such as-rc1. -
macOS distribution outside the Mac App Store requires signing and notarization, otherwise Gatekeeper blocks the app on first launch. The signing identity is a Developer ID Application certificate, distinct from the iOS Apple Distribution certificate. See Apple: macOS Distribution Outside the App Store.
-
Windows SmartScreen shows a warning for unsigned installers until the app gains reputation; an Authenticode certificate avoids it. The Compose plugin does not sign Windows packages, so sign the
.msias a CI step. -
Updates are your responsibility. There is no store to push them. Ship a version check against a JSON file hosted next to the installers, or use a package manager channel such as Homebrew, winget or an APT repository.
// desktopApp/build.gradle.kts
compose.desktop {
application {
nativeDistributions {
// "1.4.0" or "1.4.0-rc1" -> "1.4.0"
packageVersion = providers.gradleProperty("app.versionName").get()
.substringBefore('-')
}
}
}
Web: Static Deployment
The Wasm web app is a folder of static files: index.html, a JavaScript loader, one or more .wasm files and
resources. There is no server component, and no review process. Build it with the production distribution task and
upload the folder; the full GitHub Pages workflow and the compatibility build with a JavaScript fallback for browsers
without WasmGC are in
Web Targets: Kotlin/Wasm, Kotlin/JS and Compose for Web.
Remember that Kotlin/Wasm and Compose Multiplatform for web are Beta, so state this in release notes and keep an
HTML fallback for unsupported browsers.
# webApp
./gradlew :webApp:wasmJsBrowserDistribution
# Output: webApp/build/dist/wasmJs/productionExecutable
# Publish to any static host, for example an S3 bucket or an rsync target
rsync -av --delete webApp/build/dist/wasmJs/productionExecutable/ deploy@web.example.com:/var/www/trailmate/
Hosting needs to get three things right. The server must send Content-Type: application/wasm for .wasm files so
that browsers can compile them while streaming. Compression (gzip or Brotli) matters because Wasm bundles with Compose
are several megabytes. Caching must separate the entry point, which changes with every release, from the
content-hashed assets.
# webApp/deploy/nginx.conf
server {
listen 443 ssl;
server_name trailmate.example.com;
root /var/www/trailmate;
types { application/wasm wasm; }
gzip on;
gzip_types application/wasm application/javascript text/css;
# The entry point must never be cached for long, or users keep an old build
location = /index.html {
add_header Cache-Control "no-cache";
}
location ~* \.(wasm|js|css)$ {
add_header Cache-Control "public, max-age=3600";
}
}
Because the web has no store-side rollback, keep the previous build in a dated folder and switch a symbolic link on deploy. Rolling back is then a one-line change.
Release Checklist per Platform
Use this table as a template for the release issue of each version. Items that apply to every target come first.
| Platform | Before you publish | Keep after publishing |
|---|---|---|
All |
|
Git tag matching the version; the exact commit that produced every artifact |
Android |
|
|
iOS |
Bundle ID and App ID registered; version and build number from |
The |
Desktop |
One installer per OS built on its own OS; macOS build signed with Developer ID and notarized; Windows installer signed; installer tested on a clean machine without Java; update feed updated |
Installers and checksums (SHA-256) attached to the GitHub release; notarization log |
Web |
Production distribution built; MIME type for |
Previous build folder for rollback; source maps stored privately |
Common Mistakes
-
Only incrementing
versionName. Stores key on the build number; derive both fromgradle.properties. -
Archiving iOS with the debug Kotlin framework. The Release configuration must link the release framework, or the app is large and slow; check the Build Phases script uses
$CONFIGURATION. -
Losing the dSYM. Without the matching archive, Kotlin frames in crash reports stay as hex addresses forever.
-
Copying a privacy manifest from another project. Declared reasons must match what the app does.
-
Committing keystores or signing passwords. Use CI secrets and environment variables; see Android: Building, Signing and Preparing a Release.
-
Expecting cross-compilation of desktop installers. Build each format on its own operating system.
-
Serving Wasm without the right MIME type or compression. The app fails to start or loads very slowly.
-
Releasing from a laptop. A release built on one developer’s machine cannot be reproduced; use the pipeline in CI/CD for KMP with GitHub Actions.