writing-skills
writing-skills
Use when creating new skills, editing existing skills, or verifying skills work before deployment
mobile-deploymentUse this skill when the user asks about mobile app deployment, App Store, Play Store, TestFlight, Fastlane, code signing, mobile CI/CD, app release, or beta testing.
Independent GitHub snapshot · Automated classification · Original documentation governs
Configure mobile app deployment pipelines including Fastlane automation, code signing, CI/CD, App Store Connect, Play Console, TestFlight, and phased releases.
User request includes: mobile deploy, app store, play store, testflight, fastlane, code signing, mobile ci/cd, app release, beta testing.
A markdown document containing:
Produce the artifact directly. No preamble, no postamble, no explanations. No filler, no hedging, no transitions. Strip articles a/an/the where unambiguous. Compress output.
4096 tokens
Which distribution channel?
├── Internal team testing (<100 users)
│ ├── iOS → TestFlight Internal (no review, 100 testers via App Store Connect)
│ └── Android → Play Internal Testing (no review, 100 testers)
├── External beta (>100 users)
│ ├── iOS → TestFlight External (up to 10k testers, Beta App Review required)
│ └── Android → Open Beta (unlimited, no review)
├── Production release
│ ├── iOS → App Store via App Store Connect (App Review, 24-48h typical)
│ └── Android → Play Console Production track (review hours-days)
└── Enterprise/internal (org-owned devices only)
├── iOS → Apple Developer Enterprise Program + MDM
└── Android → Managed Google Play + EMM / sideload APK
Which code signing approach?
├── Single developer / small team
│ └── Xcode Automatic Signing (simple, no match needed)
├── Multiple developers / CI
│ ├── Fastlane Match with encrypted git repo (recommended)
│ │ ├── One shared Apple Developer account
│ │ └── Multiple app identifiers
│ └── Manual certificates per machine
│ └── Export p12 + provisioning profiles manually
└── Enterprise distribution
└── In-house certificate + MDM profile distribution
Which CI/CD provider?
├── GitHub project → GitHub Actions (macOS-14 for iOS, ubuntu-latest for Android)
├── Self-hosted / on-prem → GitLab CI (bring your own macOS runner)
├── Mobile-focused → Bitrise (optimized macOS VMs, built-in code signing)
├── Cross-platform desktop → CircleCI (macOS M1 runner, Android Linux)
└── Fast feedback only → local Fastlane with pre-commit hooks
Initialize Fastlane, configure Match, create Appfile, and set up environment
fastlane init # creates Fastfile, Appfile, Matchfile
fastlane match init # creates encrypted cert repo
Configure Fastfile with beta and release lanes for both iOS and Android with proper build and upload steps. Include Matchfile for code signing, Appfile for metadata, and Deliverfile/Supplyfile for store metadata.
Set up Fastlane Match for iOS code signing with encrypted certificates or manual signing for specific targets. Configure Android keystore with env vars.
Create GitHub Actions workflows for iOS (macOS runner) and Android (Ubuntu runner) with secrets management. Add GitLab CI, Bitrise, or CircleCI as needed.
Set up TestFlight Internal/External for iOS and Internal Testing/Closed Alpha/Open Beta for Android. Configure phased releases and in-app updates.
Automate app metadata, screenshots, and pricing with Deliver (iOS) and Supply (Android) to eliminate manual store work.
app_identifier("com.example.app") # Bundle ID
apple_id("developer@example.com") # Apple ID
team_id("ABCDEF1234") # Developer Team ID
itc_team_id("123456789") # App Store Connect Team ID
git_url("https://github.com/org/certs.git")
storage_mode("git")
type("appstore") # or "development"
app_identifier(["com.example.app", "com.example.app.widget"])
username("developer@example.com")
# Match-specific
generate_apple_certs(true)
skip_docs(true)
default_platform(:ios)
platform :ios do
desc "Build and upload to TestFlight"
lane :beta do
match(type: "appstore")
build_app(scheme: "App", export_method: "app-store")
upload_to_testflight(skip_waiting_for_build_processing: true)
end
desc "Release to App Store"
lane :release do
match(type: "appstore")
build_app(scheme: "App", export_method: "app-store")
upload_to_app_store(
force: true,
submit_for_review: true,
release_on_approval: true,
phased_release: true
)
end
desc "Increment build number"
lane :bump do
increment_build_number(
build_number: number_of_commits
)
end
desc "Build for ad-hoc testing"
lane :adhoc do
match(type: "adhoc")
build_app(scheme: "App", export_method: "ad-hoc")
end
end
default_platform(:android)
platform :android do
desc "Build and upload to Play Internal Testing"
lane :beta do
gradle(task: "assembleRelease")
upload_to_play_store(
track: "internal",
release_status: "draft"
)
end
desc "Release to Production"
lane :release do
gradle(task: "assembleRelease")
upload_to_play_store(
track: "production",
release_status: "completed",
in_app_update_priority: 2
)
end
desc "Increment version"
lane :bump do
increment_version_name(
version_name: prompt(text: "New version name: ")
)
increment_version_code(
version_code: number_of_commits
)
end
end
app_identifier("com.example.app")
username("developer@example.com")
name("App Name")
subtitle("App Subtitle")
privacy_url({
"en-US" => "https://example.com/privacy"
})
support_url({
"en-US" => "https://example.com/support"
})
keywords("keyword1, keyword2, keyword3")
description("App description for App Store listing.")
release_notes("Bug fixes and performance improvements.")
# Pricing
price_tier(0) # Free
# price_tier(1) # $0.99
automatic_release(false) # Manual release after review
package_name("com.example.app")
track("production")
release_status("completed")
metadata_path("metadata/android")
# Listing details
name("App Name")
summary("Short description")
description("Full description for Play Store listing.")
short_description("Tagline")
# Contact
support_email("support@example.com")
support_phone("+1234567890")
support_website("https://example.com/support")
| Type | Usage | Validity |
|---|---|---|
| Apple Development | Debug builds, simulator | 1 year |
| Apple Distribution | App Store builds | 1 year |
| iOS Distribution (Ad Hoc) | Limited device testing | 1 year |
| iOS Distribution (In House) | Enterprise distribution | 1 year |
| Apple Push Services | Push notifications | 1 year |
| Pass Type ID | Apple Wallet passes | 1 year |
# Initialize encrypted cert repo
fastlane match init
# Generate development certs
fastlane match development
# Generate distribution certs
fastlane match appstore
# Generate ad-hoc certs
fastlane match adhoc
# Nuke and regenerate (use sparingly)
fastlane match nuke distribution
fastlane match nuke development
# Use in Matchfile
git_url("https://github.com/org/certs.git")
type("appstore")
app_identifier(["com.example.app"])
Target > Signing & Capabilities
- Automatically manage signing: OFF
- Provisioning Profile: Match AppStore
- Code Signing Identity: Apple Distribution
# CI signing
MATCH_PASSWORD=<your-match-passphrase>
MATCH_KEYCHAIN_NAME=<keychain-name>
MATCH_KEYCHAIN_PASSWORD=<keychain-password>
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=<app-specific-password>
FASTLANE_SESSION=<session-cookie>
FASTLANE_PASSWORD=<apple-id-password>
FASTLANE_DONT_STORE_PASSWORD=1 # Don't persist to keychain
SPACESHIP_2FA_ENABLED=true # 2FA support
SPACESHIP_SKIP_2FA_UPGRADE=true
# Generate keystore
keytool -genkey -v -keystore release.keystore \
-alias example -keyalg RSA -keysize 2048 \
-validity 10000
# Encode for CI
certutil -encode release.keystore release.keystore.base64
# OR
openssl base64 -in release.keystore > release.keystore.base64
// app/build.gradle.kts
android {
signingConfigs {
create("release") {
storeFile = file(System.getenv("ANDROID_KEYSTORE_PATH") ?: "release.keystore")
storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
}
}
}
name: iOS Beta
on:
push:
branches: [main]
jobs:
build:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- run: bundle install
- run: bundle exec fastlane beta
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
FASTLANE_SESSION: ${{ secrets.FASTLANE_SESSION }}
- uses: actions/upload-artifact@v4
with:
name: ios-ipa
path: "*.ipa"
name: Android Beta
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- run: bundle install
- run: bundle exec fastlane beta
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
- uses: actions/upload-artifact@v4
with:
name: android-apk-aab
path: "app/build/outputs/**/*.aab"
stages:
- build
- deploy
ios-beta:
stage: deploy
tags:
- macos
script:
- bundle install
- bundle exec fastlane beta
only:
- main
variables:
MATCH_PASSWORD: $MATCH_PASSWORD
FASTLANE_SESSION: $FASTLANE_SESSION
android-beta:
stage: deploy
tags:
- linux
script:
- bundle install
- bundle exec fastlane beta
only:
- main
variables:
ANDROID_KEYSTORE_BASE64: $ANDROID_KEYSTORE_BASE64
ANDROID_KEYSTORE_PASSWORD: $ANDROID_KEYSTORE_PASSWORD
ANDROID_KEY_ALIAS: $ANDROID_KEY_ALIAS
ANDROID_KEY_PASSWORD: $ANDROID_KEY_PASSWORD
Bitrise provides built-in code signing via codesigndoc and Secrets:
certificate-and-profile-installer stepsign-APK or sign-aab stepWorkflow: Deploy
1. git-clone
2. certificate-and-profile-installer (iOS)
3. fastlane beta
4. deploy-to-bitrise-io
5. google-play-deploy (Android)
6. testflight-deploy (iOS)
Version: 3.2.1 (semantic)
Major: breaking changes
Minor: feature additions
Patch: bug fixes
Build number: auto-increment from git
iOS CFBundleVersion = number_of_commits
iOS CFBundleShortVersionString = manual bump
Android versionCode = number_of_commits
Android versionName = manual bump
# iOS: derive from git commit count
lane :bump do
increment_build_number(
build_number: number_of_commits
)
end
# Android: derive from git commit count
lane :bump do
increment_version_code(
version_code: number_of_commits
)
increment_version_name(
version_name: get_version_name_from_git_tag
)
end
| Track | Purpose | Review Required | Max Testers |
|---|---|---|---|
| TestFlight Internal | Internal team testing | No | 100 |
| TestFlight External | External beta testing | Yes (Beta App Review) | 10,000 |
| App Store Production | Public release | Yes (App Review) | Unlimited |
| Track | Purpose | Review Required | Max Testers |
|---|---|---|---|
| Internal Testing | Internal testing | No | 100 |
| Closed Alpha | Per-group testing | No | 100 per group |
| Open Beta | Public beta | No | Unlimited |
| Production | Public release | Yes | Unlimited |
# Enable phased release (7-day gradual rollout via API)
xcrun altool --upload-app --phased-release true
# Via Fastlane
upload_to_app_store(
phased_release: true # 7-day gradual
)
# Download previous release metadata
xcrun altool --list-apps -u "user" -p "pass"
Phased release ramp schedule (automatic):
// In-app updates — Play Core API
val appUpdateManager = AppUpdateManagerFactory.create(context)
// Check for update
val appUpdateInfoTask = appUpdateManager.appUpdateInfo
appUpdateInfoTask.addOnSuccessListener { info ->
if (info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE
&& info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE)) {
appUpdateManager.startUpdateFlow(
info,
context,
AppUpdateType.FLEXIBLE
)
}
}
Staged rollout via Fastlane:
lane :staged_rollout do
upload_to_play_store(
track: "production",
rollout: "0.01", # Start at 1%
release_status: "inProgress"
)
end
Play Console staged rollout schedule:
<a href="itms-services://?action=download-manifest&url=https://example.com/manifest.plist">
Install App
</a>
{
"restrict_share": {
"type": "bool",
"value": true
},
"allowed_domains": {
"type": "string",
"value": "example.com"
}
}
iOS app thinning reduces download size using asset packs and on-demand resources:
<!-- Info.plist — on-demand resources tags -->
<key>NSBundleResourceRequestTags</key>
<dict>
<key>Level-1</key>
<array>
<string>asset-level-1</string>
</array>
<key>Level-2</key>
<array>
<string>asset-level-2</string>
</array>
</dict>
// Load on-demand resource
let request = NSBundleResourceRequest(tags: ["asset-level-1"])
request.beginAccessingResources { error in
if error == nil {
// Resources downloaded, use them
}
}
Three thinning mechanisms:
Always upload AAB (not APK) to Play Console — Google handles app slicing:
// build.gradle.kts
android {
bundle {
language {
enableSplit = true // Language-specific APKs
}
density {
enableSplit = true // Density-specific APKs
}
abi {
enableSplit = true // ABI-specific APKs
}
}
}
# Build AAB
./gradlew bundleRelease
# Convert AAB to universal APK for local testing
java -jar bundletool.jar build-apks \
--bundle=app-release.aab \
--output=app.apks \
--ks=release.keystore \
--ks-pass=pass:password
# Extract universal APK
unzip app.apks -d apks/
lane :screenshots do
snapshot(
devices: [
"iPhone 16 Pro Max",
"iPhone 16",
"iPhone SE (3rd generation)"
],
languages: ["en-US", "de", "ja", "zh-Hans"],
concurrent_workers: 4,
output_directory: "screenshots/ios"
)
deliver(
skip_build: true,
overwrite_screenshots: true
)
}
lane :screenshots do
screengrab(
app_apk_path: "app/build/outputs/apk/debug/app-debug.apk",
tests_apk_path: "app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk",
locales: ["en-US", "de", "ja"],
output_directory: "screenshots/android"
)
supply(
skip_upload_apk: true,
skip_upload_aab: true,
skip_upload_metadata: true,
sync_image_upload: true
)
}
// iOS — Xcode Build Configurations
// 1. Debug: local dev, no crash reporting
// 2. Staging: staging API, TestFlight
// 3. Release: production API, App Store
// Use xcconfig files:
// Config/Debug.xcconfig
API_BASE_URL = https://dev-api.example.com
BUNDLE_ID_SUFFIX = .dev
// Config/Release.xcconfig
API_BASE_URL = https://api.example.com
BUNDLE_ID_SUFFIX =
// Android — Build Config Fields in build.gradle.kts
android {
buildTypes {
debug {
buildConfigField("String", "API_BASE_URL", "\"https://dev-api.example.com\"")
applicationIdSuffix = ".dev"
}
release {
buildConfigField("String", "API_BASE_URL", "\"https://api.example.com\"")
}
}
}
After deployment, hand off to:
mobile/universal/crash-reporting — Crash monitoring post-releasemobile/universal/security — Code signing security, cert rotationmobile/universal/testing — Pre-release testing strategymobile/universal/push-notifications — Push notification certsmobile/universal/in-app-purchase — IAP products and receipt validationmobile/android — Android-specific build variantsmobile/ios — iOS-specific Xcode project config