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

.aab (Android App Bundle) built by :androidApp

Google Play Console

Upload key; Google holds the app signing key (Play App Signing)

iOS

.xcarchive exported to an .ipa, built by Xcode from iosApp

App Store Connect, TestFlight, App Store

Apple Distribution certificate and provisioning profile

Desktop

.dmg/.pkg, .msi/.exe, .deb/.rpm built by :desktopApp

Your website, a package repository, or a store

Developer ID (macOS), Authenticode (Windows, optional)

Web

Static folder from :webApp (wasmJs)

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

versionName

MARKETING_VERSION (CFBundleShortVersionString)

packageVersion in nativeDistributions

Build number

versionCode (integer, must increase for every upload)

CURRENT_PROJECT_VERSION (CFBundleVersion, must increase per version for TestFlight)

Not used by stores; packageVersion only

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 KSerializer looked up by reflection;

  • expect/actual classes instantiated by name, for example through Class.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

iosApp / Swift symbol

Swift code, symbolicated from the app’s dSYM

Shared (or your framework name) with a kfun:…​ symbol

Kotlin function in sharedLogic; the name encodes package, class and signature, for example kfun:com.example.trailmate.data.OfflineFirstTrailRepository#refresh

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:

  1. 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 File for the Release configuration (the default for new projects). The Kotlin Gradle plugin produces a .dSYM for frameworks it links on macOS, and Xcode includes it in the archive under dSYMs/.

  2. 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/dSYMs as a CI artifact, or let uploadSymbols send 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

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:

  1. Add PrivacyInfo.xcprivacy to the app target in Xcode and declare every reason that applies to the Swift code, the Kotlin framework and its dependencies.

  2. If the framework is static (the default isStatic = true of the KMP wizard), its code is linked into the app binary, and the app’s manifest covers it.

  3. 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.

  4. 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>

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 .dmg on macOS, the .msi on Windows and the .deb on Linux, usually with a three-runner matrix in CI.

  • packageVersion must be numeric and follow each format’s rules. macOS requires MAJOR[.MINOR][.PATCH] with MAJOR greater than zero, Windows .msi takes MAJOR.MINOR.BUILD. Derive it from app.versionName and 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 .msi as 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

app.versionName and app.versionCode bumped; changelog written; shared module tests pass on allTests; release candidate built on every target; no debug flags, test servers or logging of personal data left in commonMain

Git tag matching the version; the exact commit that produced every artifact

Android

:androidApp:bundleRelease passes; R8 smoke test on a real device; signed with the upload key from CI secrets; versionCode above the last upload; Data safety form current; 16 KB page-size check passes for native libraries

mapping.txt; the .aab; staged rollout percentage and Android vitals

iOS

Bundle ID and App ID registered; version and build number from Version.xcconfig; archive built with the pinned Xcode; PrivacyInfo.xcprivacy reviewed for app and frameworks; usage descriptions present in Info.plist; TestFlight build tested by testers

The .xcarchive with its dSYMs/ folder; App Store Connect build number; review notes

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 .wasm verified; compression and cache headers verified; tested in the latest Chrome, Firefox and Safari; Beta status noted

Previous build folder for rollback; source maps stored privately

Common Mistakes

  • Only incrementing versionName. Stores key on the build number; derive both from gradle.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.