Your notes app works on both Android and iOS. The data layer syncs, the UI layer has navigation and editing, and the shared ViewModel handles all business logic. Now it is time to publish.

In this tutorial, you will learn how to build a release APK for Android, archive an IPA for iOS, set up GitHub Actions CI/CD, and prepare for the Play Store and App Store.

What We Are Covering

  1. Android release build — signing, APK, AAB
  2. iOS release build — Xcode archive, IPA export
  3. GitHub Actions CI/CD — automated builds on every push
  4. Signing basics — keystores and provisioning profiles
  5. Store submission overview — Play Store and App Store requirements

Android: Building a Release APK

Debug vs Release

So far, we have been building debug APKs with ./gradlew :composeApp:assembleDebug. Debug builds are not optimized and include debugging tools. For publishing, you need a release build.

A release build:

  • Removes debug symbols and logging
  • Enables code shrinking (ProGuard/R8)
  • Requires signing with a keystore

Step 1: Create a Keystore

A keystore is a file that holds your signing key. Every Android app on the Play Store must be signed. You create a keystore once and use it for all future releases.

keytool -genkey -v -keystore release-keystore.jks \
  -keyalg RSA -keysize 2048 -validity 10000 \
  -alias my-app-key

This creates a file called release-keystore.jks. You will be asked for a password and some information (name, organization, etc.). Remember the password — you need it for every release.

Important: Keep the keystore safe. If you lose it, you cannot update your app on the Play Store. Store a backup somewhere secure.

Step 2: Configure Signing in Gradle

Add the signing config to composeApp/build.gradle.kts:

android {
    // ... existing config

    signingConfigs {
        create("release") {
            storeFile = file("../release-keystore.jks")
            storePassword = System.getenv("KEYSTORE_PASSWORD") ?: ""
            keyAlias = "my-app-key"
            keyPassword = System.getenv("KEY_PASSWORD") ?: ""
        }
    }

    buildTypes {
        getByName("release") {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
            signingConfig = signingConfigs.getByName("release")
        }
    }
}

We read passwords from environment variables, not hardcoded in the build file. This is important for security, especially in CI/CD.

Step 3: Build the Release APK

# Set environment variables
export KEYSTORE_PASSWORD="your_password"
export KEY_PASSWORD="your_password"

# Build release APK
./gradlew :composeApp:assembleRelease

# Build release AAB (for Play Store)
./gradlew :composeApp:bundleRelease

The APK is at composeApp/build/outputs/apk/release/composeApp-release.apk. The AAB (Android App Bundle) is at composeApp/build/outputs/bundle/release/composeApp-release.aab.

APK vs AAB

  • APK — a single file that works on all devices. Good for direct distribution (sharing the file, sideloading).
  • AAB — an optimized format that the Play Store uses. It generates smaller APKs tailored to each device. The Play Store requires AAB for new apps.

If you are publishing to the Play Store, use AAB. If you are distributing directly (beta testing, internal use), use APK.

iOS: Building an IPA

Building an iOS app for distribution requires Xcode. You cannot build a release IPA from the command line alone.

Step 1: Build the Shared Framework

First, build the shared framework for the real device (not the simulator):

./gradlew :shared:linkReleaseFrameworkIosArm64

This creates the framework at shared/build/bin/iosArm64/releaseFramework/Shared.framework.

Step 2: Open in Xcode

Open the iOS project in Xcode:

open iosApp/iosApp.xcodeproj

Or, if you use CocoaPods or an .xcworkspace:

open iosApp/iosApp.xcworkspace

Step 3: Configure Signing

In Xcode:

  1. Select the project in the navigator
  2. Select the target (your app)
  3. Go to the Signing & Capabilities tab
  4. Select your Team (your Apple Developer account)
  5. Set a unique Bundle Identifier (e.g., com.yourname.kmptutorial)
  6. Xcode will create a provisioning profile automatically

You need an Apple Developer account ($99/year) to distribute on the App Store.

Step 4: Archive and Export

  1. In Xcode, select Product > Archive
  2. Wait for the build to complete
  3. In the Archives window, select your archive
  4. Click Distribute App
  5. Choose the distribution method:
    • App Store Connect — for publishing to the App Store
    • Ad Hoc — for testing on specific devices
    • Development — for your own testing devices
  6. Follow the wizard to export the IPA

iOS Signing Overview

iOS signing is more complex than Android:

ConceptWhat It Is
CertificateProves you are a registered developer
Provisioning ProfileLinks your certificate, app ID, and allowed devices
App IDYour app’s bundle identifier (e.g., com.yourname.kmptutorial)
EntitlementsPermissions your app needs (push notifications, iCloud, etc.)

For the App Store, Xcode handles most of this automatically if you have a valid Apple Developer account. For CI/CD, you need to manage certificates manually (more on this below).

GitHub Actions CI/CD

Automated builds catch problems early and save time. We set up a GitHub Actions workflow that builds both platforms on every push.

The Workflow File

Create .github/workflows/build.yml in the project root:

name: Build KMP Tutorial

on:
  push:
    branches: [ main, "tutorial-*" ]
  pull_request:
    branches: [ main ]

jobs:
  build-android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@v4

      - name: Build Android Debug APK
        run: ./gradlew :composeApp:assembleDebug

      - name: Build Android Release APK
        run: ./gradlew :composeApp:assembleRelease

      - name: Upload Android APK
        uses: actions/upload-artifact@v4
        with:
          name: android-apk
          path: composeApp/build/outputs/apk/debug/composeApp-debug.apk

  build-ios-framework:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@v4

      - name: Build iOS Shared Framework
        run: ./gradlew :shared:linkDebugFrameworkIosSimulatorArm64

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@v4

      - name: Run shared tests
        run: ./gradlew :shared:allTests

How It Works

The workflow has three jobs:

  1. build-android — builds debug and release APKs on Ubuntu. Uploads the debug APK as an artifact.
  2. build-ios-framework — builds the shared iOS framework on macOS. The iOS build needs macOS because the Kotlin/Native compiler for iOS only works on macOS.
  3. test — runs all shared module tests on Ubuntu.

Why Three Separate Jobs?

  • Parallelism — all three jobs run at the same time
  • Cost — macOS runners are more expensive than Ubuntu runners. We only use macOS for the iOS build.
  • Failure isolation — if the iOS build fails, you still see Android build results

Trigger Rules

The workflow runs on:

  • Every push to main or any tutorial-* branch
  • Every pull request targeting main

This means every code change gets a build check. Broken builds are caught before they reach main.

Adding Secrets for Release Builds

For release builds in CI, store your keystore password as a GitHub secret:

  1. Go to your repository on GitHub
  2. Settings > Secrets and variables > Actions
  3. Add secrets: KEYSTORE_PASSWORD and KEY_PASSWORD

Then reference them in the workflow:

- name: Build Android Release APK
  env:
    KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
    KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
  run: ./gradlew :composeApp:assembleRelease

For iOS release builds in CI, you need to handle code signing certificates. This is more complex:

  • Use fastlane match to manage certificates
  • Or store the certificate as a GitHub secret and install it in the workflow
  • Or use Xcode Cloud (Apple’s own CI/CD)

For a small project, building iOS releases manually in Xcode is fine. CI handles the debug build and tests.

Play Store Submission

Here is a brief overview of the steps to publish on the Google Play Store.

Requirements

  • A Google Play Developer account ($25 one-time fee)
  • A release AAB (signed with your keystore)
  • App listing: title, description, screenshots, icon, feature graphic
  • Privacy policy URL
  • Content rating questionnaire
  • Target audience and content declaration

Steps

  1. Go to Play Console
  2. Create a new app
  3. Fill in the store listing (title, description, screenshots)
  4. Upload the AAB to the Production or Internal Testing track
  5. Complete the content rating questionnaire
  6. Set pricing (free or paid)
  7. Submit for review

Google’s review usually takes a few hours to a few days. Internal testing tracks allow you to distribute to a small group without review.

Play App Signing

Google recommends using Play App Signing. When you upload your AAB, Google re-signs it with their key before distributing to users. This means:

  • Google holds the distribution key
  • You keep your upload key (your keystore)
  • If you lose your upload key, Google can reset it
  • The app is optimized for each device

Play App Signing is required for new apps.

App Store Submission

Here is a brief overview of publishing on the Apple App Store.

Requirements

  • An Apple Developer account ($99/year)
  • An archived IPA (signed with your distribution certificate)
  • App listing: title, description, screenshots, icon
  • Privacy policy URL
  • App Review guidelines compliance

Steps

  1. Go to App Store Connect
  2. Create a new app
  3. Fill in the app information (name, bundle ID, primary language)
  4. Upload the build using Xcode or Transporter
  5. Fill in the store listing (description, keywords, screenshots)
  6. Set pricing
  7. Submit for review

Apple’s review typically takes 1-2 days. They check for guideline compliance, crashes, and privacy.

TestFlight

Before publishing to the App Store, use TestFlight for beta testing:

  1. Upload a build to App Store Connect
  2. Add internal testers (your team)
  3. Add external testers (up to 10,000 people)
  4. Testers install the app via the TestFlight app

TestFlight is free and a great way to get feedback before the official release.

KMP-Specific Publishing Considerations

Shared Framework Size

The shared Kotlin/Native framework adds some size to the iOS app. For a small app like our notes app, the framework is around 5-10 MB. For larger apps, consider these optimizations:

  • Use isStatic = true in the framework config (we already do this)
  • Enable compiler optimizations for release builds
  • Strip unused symbols

Kotlin/Native Compilation Time

Kotlin/Native (iOS) compiles slower than Kotlin/JVM (Android). For CI, this means:

  • The iOS build job takes longer than the Android job
  • Consider caching Gradle and Konan (Kotlin/Native compiler) directories
  • Use ./gradlew :shared:linkReleaseFrameworkIosArm64 for release, linkDebugFrameworkIosSimulatorArm64 for CI testing

Version Synchronization

Keep the version number in sync between Android and iOS:

Android (composeApp/build.gradle.kts):

defaultConfig {
    versionCode = 1
    versionName = "1.0.0"
}

iOS (iosApp/iosApp/Info.plist):

<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>

Consider extracting the version to a shared Gradle property:

// gradle.properties
app.versionName=1.0.0
app.versionCode=1

Then reference it in both build configs. This ensures both platforms always have the same version.

Build and Run

Verify everything builds:

# Android debug
./gradlew :composeApp:assembleDebug

# Android release (unsigned, for testing the build)
./gradlew :composeApp:assembleRelease

# iOS shared framework
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64

Push to GitHub and check that the CI workflow runs:

git push origin tutorial-18-publishing

Go to your repository’s Actions tab on GitHub to see the workflow running.

Publishing Checklist

Before you submit to either store, make sure:

  • App icon is set (both platforms)
  • Version number and version code are correct
  • Release build works on a real device (not just emulator/simulator)
  • No debug logging or test API URLs in the release build
  • Privacy policy is published at a URL
  • Store listing screenshots are prepared
  • App description is written
  • Content rating questionnaire is completed (Play Store)
  • CI builds pass on the release branch

What We Built

ComponentWhat It Does
build.ymlGitHub Actions workflow for Android, iOS, and tests
Signing configGradle signing configuration for Android release builds
KeystoreCreated with keytool for signing the APK/AAB

Source Code

Full source code for this tutorial: GitHub — tutorial-18-publishing

What’s Next?

In the next tutorial, we explore KMP for Desktop and Web — how to add Compose Multiplatform Desktop and Kotlin/Wasm targets to share your code on even more platforms.