mobile-release/SKILL.md
Build and release Android (Play Store) and iOS (App Store) apps with fastlane. Covers signing, versioning, track promotion, common gotchas, and full Fastfile templates. Use when deploying mobile apps, fixing signing issues, bumping versions, or setting up fastlane from scratch.
npx skillsauth add snqb/my-skills mobile-releaseInstall 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.
Before any release:
git pull origin mainTwo key systems exist:
Verify your keystore before uploading:
keytool -list -v -keystore path/to/keystore.jks -storepass PASSWORD 2>&1 | grep "SHA1:"
Compare SHA1 with what Play Console shows under Setup → App signing → Upload key certificate.
Common trap: Multiple keystores in the project. build.gradle.kts may reference one, Fastfile another. They must match what Play Store expects.
Injected signing (Fastfile overrides build.gradle):
gradle(
task: "bundleRelease",
project_dir: ".",
properties: {
"android.injected.signing.store.file" => File.expand_path("../path/to/upload.keystore"),
"android.injected.signing.store.password" => "PASSWORD",
"android.injected.signing.key.alias" => "aliasName",
"android.injected.signing.key.password" => "PASSWORD",
}
)
Play Store rejects duplicate versionCode. Always bump before deploy.
Where versions live (check both):
app/build.gradle.kts — often has versionCode / versionName directlybuildSrc/ or version catalogs — some projects centralize in AppConfig.kt or libs.versions.toml// app/build.gradle.kts
versionCode = 14 // integer, must increment
versionName = "1.0.5" // human-readable, shown in Play Store
Google Play enforces minimum targetSdk. As of 2025+: targetSdk ≥ 35 required for new uploads.
Check: buildSrc/src/main/kotlin/AppConfig.kt or app/build.gradle.kts → targetSdk and compileSdk.
default_platform(:android)
platform :android do
desc "Build debug APK"
lane :debug do
gradle(task: "assembleDebug", project_dir: ".")
UI.success "Debug APK: #{lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH]}"
end
desc "Build release AAB (signed for Play Store)"
lane :bundle do
gradle(
task: "bundleRelease",
project_dir: ".",
properties: {
"android.injected.signing.store.file" => File.expand_path("../path/to/upload.keystore"),
"android.injected.signing.store.password" => ENV["KEYSTORE_PASSWORD"] || `pass project/keystore-password`.strip,
"android.injected.signing.key.alias" => "myAlias",
"android.injected.signing.key.password" => ENV["KEYSTORE_PASSWORD"] || `pass project/keystore-password`.strip,
}
)
UI.success "Release AAB: #{lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH]}"
end
desc "Build + install on connected device"
lane :device do
gradle(task: "assembleDebug", project_dir: ".")
adb(command: "install -r #{lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH]}")
adb(command: "shell am start -n com.example.app/.MainActivity")
UI.success "Installed and launched"
end
desc "Deploy to Play Store internal track"
lane :deploy do
bundle
upload_to_play_store(
track: "internal",
aab: lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH],
skip_upload_apk: true, # IMPORTANT: stale APKs in build/ cause "both apk and aab" error
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
UI.success "Deployed to Play Store internal track"
end
desc "Promote internal → production"
lane :promote do
upload_to_play_store(
track: "internal",
track_promote_to: "production",
skip_upload_apk: true,
skip_upload_aab: true,
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true,
skip_upload_changelogs: true
)
UI.success "Promoted to production"
end
end
internal → closed testing → open testing → production
Promote between tracks (no re-upload needed):
# CLI one-liner (when no promote lane exists)
fastlane run upload_to_play_store \
track:internal track_promote_to:production \
skip_upload_apk:true skip_upload_aab:true \
skip_upload_metadata:true skip_upload_images:true \
skip_upload_screenshots:true skip_upload_changelogs:true \
json_key:path/to/service-account.json
upload_to_play_store needs a Google Play service account JSON:
keystores/play-store-service-account.jsonjson_key: "keystores/play-store-service-account.json"If stored at default Fastfile location (fastlane/), fastlane auto-detects. Otherwise pass explicitly.
| Gotcha | Fix |
|--------|-----|
| "Cannot provide both apk and aab" | Add skip_upload_apk: true to upload_to_play_store, or delete stale APKs from app/build/outputs/apk/ |
| "Version code N already used" | Bump versionCode in build.gradle |
| "Target SDK too low" | Bump targetSdk and compileSdk to ≥ 35 |
| "Signed with wrong key" | Verify keystore SHA1 matches Play Console upload key |
| Build takes forever | Run in tmux. Gradle caches — second build is faster if no SDK change |
| Deprecation warnings on SDK bump | Warnings are fine. Errors need fixing (rare with minor SDK bumps) |
Three things must align:
.p12) — developer or distribution.mobileprovision) — links cert + app ID + devicesfastlane match (recommended) — stores certs/profiles in git or cloud:
fastlane match init # one-time setup
fastlane match appstore # fetch/create App Store profiles
fastlane match development # fetch/create dev profiles
default_platform(:ios)
platform :ios do
desc "Build for testing"
lane :build do
build_app(
workspace: "App.xcworkspace",
scheme: "App",
configuration: "Debug",
export_method: "development"
)
end
desc "Deploy to TestFlight"
lane :beta do
increment_build_number
match(type: "appstore")
build_app(
workspace: "App.xcworkspace",
scheme: "App",
export_method: "app-store"
)
upload_to_testflight(
skip_waiting_for_build_processing: true
)
UI.success "Uploaded to TestFlight"
end
desc "Deploy to App Store"
lane :release do
increment_build_number
match(type: "appstore")
build_app(
workspace: "App.xcworkspace",
scheme: "App",
export_method: "app-store"
)
upload_to_app_store(
force: true,
skip_metadata: true,
skip_screenshots: true
)
UI.success "Submitted to App Store review"
end
end
# Appfile or lane
app_store_connect_api_key(
key_id: "ABC123",
issuer_id: "def-456-ghi",
key_filepath: "fastlane/AuthKey_ABC123.p8"
)
Generate at: App Store Connect → Users and Access → Integrations → Keys
| Gotcha | Fix |
|--------|-----|
| 2FA prompts block CI | Use App Store Connect API key (.p8) |
| "No signing certificate" | fastlane match nuke distribution then fastlane match appstore |
| "Profile doesn't include device" | Add UDID in Apple Developer portal, regenerate profile |
| Build number already used | increment_build_number before upload |
| "Missing compliance info" | Add ITSAppUsesNonExemptEncryption: NO to Info.plist (if no custom crypto) |
| Archive fails with SPM | Clean derived data: rm -rf ~/Library/Developer/Xcode/DerivedData |
When asked to "deploy android" / "release iOS" / "push to store":
# 1. Pull latest
git pull origin main
# 2. Check current version
grep -E "versionCode|versionName" android/app/build.gradle.kts
# 3. Bump version (ask user or auto-increment)
# 4. Build + upload in tmux
tmux has-session -t pi 2>/dev/null || tmux new-session -d -s pi
tmux new-window -d -t pi -n mobile-deploy 'cd project/android && fastlane deploy 2>&1 | tee /tmp/mobile-deploy.log'
# 5. Monitor
sleep 60 && tmux capture-pane -t pi:mobile-deploy -p -S -40
# 6. Promote if requested
fastlane promote # or fastlane run upload_to_play_store track:internal track_promote_to:production ...
# 7. Cleanup
tmux kill-window -t pi:mobile-deploy
# Android: check what Play Store has
fastlane run upload_to_play_store validate_only:true track:production \
skip_upload_apk:true skip_upload_aab:true json_key:path/to/key.json
# iOS: check TestFlight processing
fastlane run check_app_store_connect_status
# Both: verbose mode
fastlane deploy --verbose
documentation
Enrich Markdown articles with inline Wikipedia links. First mention of each notable entity gets a hyperlink. Use when asked to add wiki links, enrich, or add references to .md files.
development
Structured visual QA: screenshot → batch issues → fix all → verify. Replaces the 300-cycle screenshot→edit death spiral. Optional bishkek review as exit gate. Use when building/polishing UI with browser testing, or when user asks for N iterations/reviews.
development
Find complex code, analyze intent, recommend battle-tested library replacements. Uses radon/eslint for detection, GitHub quality search for alternatives.
research
Research real-world UI patterns from curated galleries (Collect UI, Component Gallery, Mobbin). Use when exploring what exists: dropdowns, accordions, inputs, navigation, cards, modals, etc.