Proper signing, versioning, and build variants are prerequisites for a professional release pipeline. Getting them wrong means failed Play Store submissions or production builds with debug configs.
Signing Configuration
Secure Key Handling in CI
Never commit the keystore to source control. Store secrets in CI environment variables:
// app/build.gradle.kts
android {
signingConfigs {
create("release") {
// Read from environment variables — injected by CI
storeFile = file(System.getenv("KEYSTORE_PATH") ?: "debug.keystore")
storePassword = System.getenv("KEYSTORE_PASSWORD") ?: ""
keyAlias = System.getenv("KEY_ALIAS") ?: ""
keyPassword = System.getenv("KEY_PASSWORD") ?: ""
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
}
# GitHub Actions: decode base64-encoded keystore from secret
- name: Decode keystore
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/release.keystore
- name: Build release APK
env:
KEYSTORE_PATH: release.keystore
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: ./gradlew bundleRelease
Versioning Strategy
// app/build.gradle.kts
android {
defaultConfig {
// versionCode: monotonically increasing integer
// Use timestamp for CI builds: YYYYMMDDNN (e.g., 2024030401)
versionCode = System.getenv("VERSION_CODE")?.toInt() ?: 1
// versionName: semantic version from git tag
versionName = System.getenv("VERSION_NAME") ?: "1.0.0-dev"
}
}
# Derive version from git tag (e.g., v2.3.1)
- name: Set version
run: |
TAG=${GITHUB_REF#refs/tags/v}
echo "VERSION_NAME=$TAG" >> $GITHUB_ENV
# versionCode: date-based YYYYMMDDNN
echo "VERSION_CODE=$(date +%Y%m%d)01" >> $GITHUB_ENV
Build Variants
android {
flavorDimensions("environment")
productFlavors {
create("dev") {
dimension = "environment"
applicationIdSuffix = ".dev"
versionNameSuffix = "-dev"
buildConfigField("String", "API_BASE_URL", "\"https://dev-api.example.com\"")
buildConfigField("Boolean", "ENABLE_LOGGING", "true")
}
create("staging") {
dimension = "environment"
applicationIdSuffix = ".staging"
versionNameSuffix = "-staging"
buildConfigField("String", "API_BASE_URL", "\"https://staging-api.example.com\"")
buildConfigField("Boolean", "ENABLE_LOGGING", "true")
}
create("production") {
dimension = "environment"
buildConfigField("String", "API_BASE_URL", "\"https://api.example.com\"")
buildConfigField("Boolean", "ENABLE_LOGGING", "false")
}
}
buildTypes {
debug {
isDebuggable = true
isMinifyEnabled = false
}
release {
isDebuggable = false
isMinifyEnabled = true
}
}
}
This creates 6 variants: devDebug, devRelease, stagingDebug, stagingRelease, productionDebug, productionRelease.
Using BuildConfig
// Usage in code
class NetworkConfig {
val baseUrl: String = BuildConfig.API_BASE_URL
val loggingEnabled: Boolean = BuildConfig.ENABLE_LOGGING
}
Build Variant Source Sets
app/src/
├── main/ shared across all variants
├── dev/ dev-specific resources/code
├── staging/ staging-specific
└── production/ prod-specific (e.g., Firebase google-services.json)
// src/dev/kotlin/com/myapp/FlipperInitializer.kt
object FlipperInitializer {
fun init(context: Context) { Flipper.start(context) }
}
// src/production/kotlin/com/myapp/FlipperInitializer.kt
object FlipperInitializer {
fun init(context: Context) { } // No-op in production
}
Key Takeaways
| Practice | Rule |
|---|---|
| Keystore in CI | Base64-encode → CI secret → decode in pipeline; never commit keystore |
versionCode | Always incrementing; use date-based format for CI traceability |
versionName | Derived from git tag at release time |
| Product flavors | Separate applicationId per environment so they install side-by-side |
BuildConfig fields | Inject environment URLs/flags; never use if (BuildConfig.DEBUG) for production feature flags |
| Source sets | Swap entire implementations (Flipper, analytics) per variant, not if statements |