/SKILL.md
Guide and review the preparation of a Kotlin Multiplatform (KMP) project for Google Play Store release. Covers module detection, keystore generation, signing configuration, ProGuard/R8 with consumerProguardFiles, variant alignment, and AAB build. Use when the user wants to publish, release, deploy, sign, or prepare a KMP app for Play Store, or when they mention "composeApp bundleRelease", "KMP release", "play store kmp", "Compose Multiplatform release", "shared module variant", or "androidApp bundleRelease".
npx skillsauth add tacuchi/playstore-kmp playstore-kmpInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Build a signed Android App Bundle (AAB) from a KMP project, ready for Google Play Store.
KMP release is fundamentally a multi-module problem. Assess the structure BEFORE touching any files:
Which is the Android module? Look for the directory containing an android {} block with applicationId:
composeApp/ → Compose Multiplatform template (KMP Wizard default)androidApp/ → Classic KMP templatesettings.gradle.kts for include(":moduleName")Shared module structure? Check for modules that the Android module depends on:
android {} with libraryNamespace → It's a KMP library module; needs consumerProguardFilesandroid {} → Pure common code; no ProGuard concerns from this moduleVariant alignment? Check if shared/library modules define release build type:
release build type → Variants will match, no action neededrelease → Android module fails with "Could not resolve :shared variant"matchingFallbacks += listOf("release", "debug") in consuming moduleKMP libraries in use? Check shared module dependencies:
Existing keystore? Ask the user before generating a new one
.jks → Reuse it, skip Step 1AGP + Kotlin version compatibility? Check libs.versions.toml:
| Step | Action | Key file |
|------|--------|----------|
| 1 | Generate upload keystore | upload-keystore.jks |
| 2 | Create credentials file | keystore.properties |
| 3 | Configure signing in Android module | <module>/build.gradle.kts |
| 4 | Configure ProGuard / R8 (by module) | <module>/proguard-rules.pro + shared/consumer-rules.pro |
| 5 | Build release AAB | CLI |
| 6 | Verify output | CLI + checklist |
keytool -genkeypair \
-alias upload \
-keyalg RSA -keysize 2048 \
-validity 10000 \
-storetype PKCS12 \
-keystore upload-keystore.jks
Critical details:
-validity 10000 = ~27 years. Google requires validity beyond Oct 22 2033.-storetype PKCS12 — avoids JKS migration warnings. But with PKCS12, store password and key password must be identical. keytool silently uses the store password for the key. Different passwords → signing fails later with misleading "Cannot recover key" error..jks outside the project. Recommended: ~/.android/keystores/ or a secrets manager.Create keystore.properties in the project root (must NOT be committed):
storePassword=<password>
keyPassword=<same-password-as-store>
keyAlias=upload
storeFile=<absolute-or-relative-path-to-upload-keystore.jks>
Add to .gitignore:
keystore.properties
*.jks
*.keystore
local.properties
KMP is Kotlin DSL only (.gradle.kts). Claude knows Gradle signing config syntax. These are the KMP-specific traps:
android {} block lives in the Android module (composeApp/ or androidApp/), NOT in root build.gradle.kts. KMP root build file typically only applies plugins.rootProject.file() scope: keystore.properties is in project root. From within composeApp/build.gradle.kts, rootProject.file() correctly resolves to root. project.file() would look inside composeApp/.import java.util.Properties
import java.io.FileInputStream
val keystoreProperties = Properties().apply {
val file = rootProject.file("keystore.properties")
if (file.exists()) load(FileInputStream(file))
}
android {
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
R8 is NOT enabled by default — you must set isMinifyEnabled = true. KMP has a critical multi-module nuance: rules must be in the right module.
| Rule location | Applies to | Use for |
|---------------|-----------|---------|
| composeApp/proguard-rules.pro | Only composeApp's own code | App-level rules, signing config |
| shared/consumer-rules.pro | Propagated to any module that depends on shared | Library code using reflection (Ktor, serialization) |
The Android module's proguard-rules.pro does NOT apply to library modules. If shared/ uses Ktor or kotlinx.serialization, those rules MUST go in shared/consumer-rules.pro:
// In shared/build.gradle.kts
android {
defaultConfig {
consumerProguardFiles("consumer-rules.pro")
}
}
# Kotlin (always needed with minification)
-keep class kotlin.Metadata { *; }
-dontwarn kotlin.**
# Coroutines
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-keepclassmembers class kotlinx.coroutines.** { volatile <fields>; }
# kotlinx.serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclassmembers class kotlinx.serialization.json.** { *** Companion; }
-keep,includedescriptorclasses class **$$serializer { *; }
-keepclassmembers class * { @kotlinx.serialization.Serializable *; }
# Ktor Client (engine loaded via ServiceLoader — R8 strips it)
-keep class io.ktor.** { *; }
-dontwarn io.ktor.**
-keep class io.ktor.client.engine.** { *; }
# kotlinx.datetime (JVM implementation classes stripped by R8)
-keep class kotlinx.datetime.** { *; }
-dontwarn kotlinx.datetime.**
Error: Could not resolve :shared or No matching variant of :shared was found
This happens when the shared module doesn't define a release build type but the Android module tries to resolve one for bundleRelease.
Fix in the consuming module (preferred — doesn't require modifying shared):
// composeApp/build.gradle.kts
android {
buildTypes {
release {
matchingFallbacks += listOf("release", "debug")
}
}
}
Or fix in the shared module — explicitly declare build types:
// shared/build.gradle.kts
android {
buildTypes {
release { }
debug { }
}
}
# Module name determines the command — use the correct one:
./gradlew :composeApp:bundleRelease
# or
./gradlew :androidApp:bundleRelease
Output path — filename matches the module name, NOT app-release.aab:
composeApp/build/outputs/bundle/release/composeApp-release.aabandroidApp/build/outputs/bundle/release/androidApp-release.aab# Adjust module name in paths below (composeApp or androidApp)
# Verify signing — confirm alias is "upload", NOT "androiddebugkey"
keytool -printcert -jarfile composeApp/build/outputs/bundle/release/composeApp-release.aab
# Verify version (requires bundletool)
bundletool dump manifest --bundle=composeApp/build/outputs/bundle/release/composeApp-release.aab \
| grep -E "versionCode|versionName"
Checklist:
versionCode higher than the previous uploadkeystore.properties and *.jks in .gitignoreisMinifyEnabled = true and isShrinkResources = true both setconsumerProguardFiles configured (if shared uses reflection-heavy libs)NEVER assume output is app-release.aab — KMP output filename matches the module name: composeApp-release.aab or androidApp-release.aab. CI scripts, upload commands, and Fastlane configs that hardcode app-release.aab will silently fail to find the file or upload nothing.
NEVER use Groovy DSL in a KMP project — The KMP Gradle plugin only supports .gradle.kts. A .gradle file in a KMP module silently breaks multiplatform dependency resolution. Do not convert to Groovy, do not mix DSLs.
NEVER put ProGuard rules only in the Android module — Rules in composeApp/proguard-rules.pro only apply to that module's direct code. If shared/ uses Ktor or kotlinx.serialization, the shared module MUST publish its own rules via consumerProguardFiles("consumer-rules.pro"). Otherwise R8 strips shared module classes and the app crashes at runtime with no build-time warning.
NEVER ignore variant mismatch errors — "Could not resolve :shared variant" is NOT a generic Gradle issue. It means the shared module's build types don't align with the Android module. Fix with matchingFallbacks, not by deleting the dependency.
NEVER set different store/key passwords with PKCS12 — keytool silently uses store password for key. Different passwords → signing fails with "Cannot recover key" (misleading — it's a password mismatch).
NEVER skip testing the signed AAB on a real device — R8 stripping in multi-module KMP is invisible until runtime. Ktor engine stripped → network calls crash. Serialization stripped → data parsing crashes. These only manifest in release builds.
| Error | Cause | Fix |
|-------|-------|-----|
| ClassNotFoundException: io.ktor.* at runtime | R8 stripped Ktor engine (ServiceLoader) | Add Ktor -keep rules in shared/consumer-rules.pro |
| kotlinx.serialization crash at runtime | @Serializable serializers stripped by R8 | Add serialization rules in shared consumer-rules |
| Could not resolve :shared variant | Shared module missing release build type | Add matchingFallbacks in consuming module |
| kotlinx.datetime missing at runtime | R8 removed JVM datetime implementation | Add -keep class kotlinx.datetime.** |
| AAB not found by CI/upload script | Script looks for app-release.aab | Use <module>-release.aab (module name as prefix) |
| Missing class: ... during R8 | R8 strips classes used via reflection | Add -keep rules from build error output |
AGP + Kotlin version matrix — KMP requires compatible AGP and Kotlin plugin versions. Mismatches produce cryptic "Cannot find plugin" or "Unsupported metadata version" errors. Always check libs.versions.toml for version alignment. AGP 9/10 introduce breaking changes in the library plugin API.
Multiple shared modules — If the project has core/, data/, domain/ as separate KMP modules, EACH module that uses reflection-heavy libraries needs its own consumerProguardFiles. One missing module = one runtime crash.
App Signing by Google Play — Google re-signs your app with their app signing key. The keystore you generate is the upload key only. If you lose it, request a reset through Play Console (takes days, requires identity verification).
development
Maintainer-only workflow for handling GitHub Secret Scanning alerts on OpenClaw. Use when Codex needs to triage, redact, clean up, and resolve secret leakage found in issue comments, issue bodies, PR comments, or other GitHub content.
development
Maintainer workflow for OpenClaw releases, prereleases, changelog release notes, and publish validation. Use when Codex needs to prepare or verify stable or beta release steps, align version naming, assemble release notes, check release auth requirements, or validate publish-time commands and artifacts.
development
Run, watch, debug, and extend OpenClaw QA testing with qa-lab and qa-channel. Use when Codex needs to execute the repo-backed QA suite, inspect live QA artifacts, debug failing scenarios, add new QA scenarios, or explain the OpenClaw QA workflow. Prefer the live OpenAI lane with regular openai/gpt-5.4 in fast mode; do not use gpt-5.4-pro or gpt-5.4-mini unless the user explicitly overrides that policy.
development
End-to-end Parallels smoke, upgrade, and rerun workflow for OpenClaw across macOS, Windows, and Linux guests. Use when Codex needs to run, rerun, debug, or interpret VM-based install, onboarding, gateway smoke tests, latest-release-to-main upgrade checks, fresh snapshot retests, or optional Discord roundtrip verification under Parallels.