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
- Android release build — signing, APK, AAB
- iOS release build — Xcode archive, IPA export
- GitHub Actions CI/CD — automated builds on every push
- Signing basics — keystores and provisioning profiles
- 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:
- Select the project in the navigator
- Select the target (your app)
- Go to the Signing & Capabilities tab
- Select your Team (your Apple Developer account)
- Set a unique Bundle Identifier (e.g.,
com.yourname.kmptutorial) - 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
- In Xcode, select Product > Archive
- Wait for the build to complete
- In the Archives window, select your archive
- Click Distribute App
- 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
- Follow the wizard to export the IPA
iOS Signing Overview
iOS signing is more complex than Android:
| Concept | What It Is |
|---|---|
| Certificate | Proves you are a registered developer |
| Provisioning Profile | Links your certificate, app ID, and allowed devices |
| App ID | Your app’s bundle identifier (e.g., com.yourname.kmptutorial) |
| Entitlements | Permissions 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:
- build-android — builds debug and release APKs on Ubuntu. Uploads the debug APK as an artifact.
- 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.
- 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
mainor anytutorial-*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:
- Go to your repository on GitHub
- Settings > Secrets and variables > Actions
- Add secrets:
KEYSTORE_PASSWORDandKEY_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
- Go to Play Console
- Create a new app
- Fill in the store listing (title, description, screenshots)
- Upload the AAB to the Production or Internal Testing track
- Complete the content rating questionnaire
- Set pricing (free or paid)
- 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
- Go to App Store Connect
- Create a new app
- Fill in the app information (name, bundle ID, primary language)
- Upload the build using Xcode or Transporter
- Fill in the store listing (description, keywords, screenshots)
- Set pricing
- 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:
- Upload a build to App Store Connect
- Add internal testers (your team)
- Add external testers (up to 10,000 people)
- 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 = truein 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:linkReleaseFrameworkIosArm64for release,linkDebugFrameworkIosSimulatorArm64for 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
| Component | What It Does |
|---|---|
build.yml | GitHub Actions workflow for Android, iOS, and tests |
| Signing config | Gradle signing configuration for Android release builds |
| Keystore | Created 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.