Lesson 46 of 83 intermediate

Signing, Release Builds, Play Console & Versioning

Ship production-ready Android apps safely — signing, R8, Play tracks, and versioning done right

Open interactive version (quiz + challenge)

Real-world analogy

App signing is like a wax seal on a royal decree — it proves the document came from you and has not been tampered with since. If even one byte of the APK changes after signing, the seal breaks and Android refuses to install it. The Play Store uses this seal to verify that every update to your app is genuinely from the same publisher, not an impersonator.

What is it?

App signing is the cryptographic process of attaching a developer's private-key signature to an Android APK or AAB, enabling Android and the Play Store to verify authenticity and integrity. The Play Console's testing tracks and staged rollouts provide a controlled pipeline for delivering signed release builds to progressively larger user audiences while monitoring quality metrics.

Real-world relevance

A fintech mobile banking app (handling real transactions) uses Play App Signing so the distribution key is protected by Google's HSM — if the development laptop is stolen, the upload key can be revoked without losing the ability to update the app. The CI pipeline automatically sets versionCode = GITHUB_RUN_NUMBER and deploys to the Internal track on every merge to main, with staged rollout to production starting at 1% with 24-hour monitoring windows.

Key points

Code example

// 1. Generate keystore (run once, store securely — NOT in git)
// keytool -genkey -v -keystore fintech-release.jks
//   -alias fintechapp -keyalg RSA -keysize 2048 -validity 10000

// 2. app/build.gradle.kts — signing config
android {
    signingConfigs {
        create("release") {
            // Read from environment variables — NEVER hardcode
            storeFile = file(System.getenv("KEYSTORE_PATH") ?: "release.jks")
            storePassword = System.getenv("KEYSTORE_PASSWORD") ?: ""
            keyAlias = System.getenv("KEY_ALIAS") ?: ""
            keyPassword = System.getenv("KEY_PASSWORD") ?: ""
        }
    }

    buildTypes {
        release {
            signingConfig = signingConfigs.getByName("release")
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }

    defaultConfig {
        versionCode = (System.getenv("GITHUB_RUN_NUMBER") ?: "1").toInt()
        versionName = "2.7.1"
    }
}

// 3. proguard-rules.pro — keep Retrofit/Gson model fields
-keepattributes Signature
-keepattributes *Annotation*
-keep class com.teamzlab.fintech.data.model.** { *; }
-keep interface com.teamzlab.fintech.data.api.** { *; }
-dontwarn okhttp3.**

// 4. Build a release AAB for Play Store upload
// ./gradlew :app:bundleProdRelease

// 5. VersionCode strategy helper (in a buildSrc convention plugin)
fun computeVersionCode(): Int {
    val runNumber = System.getenv("GITHUB_RUN_NUMBER")?.toIntOrNull()
    return runNumber ?: 1  // fallback for local builds
}

Line-by-line walkthrough

  1. 1. keytool -genkey ... -validity 10000 — 10,000 days (~27 years) ensures the key does not expire during the app's commercial lifetime; Android requires the certificate to be valid at the time of each update.
  2. 2. System.getenv('KEYSTORE_PATH') — reads the keystore path from a CI/CD environment variable; locally developers set this in their shell profile or local.properties (git-ignored). Never substitute a literal path here.
  3. 3. storePassword = System.getenv('KEYSTORE_PASSWORD') ?: '' — the Elvis operator provides an empty fallback for local builds where the env var is absent; the build will fail at signing time if the password is wrong, not at configuration time.
  4. 4. signingConfig = signingConfigs.getByName('release') inside buildTypes.release — links the release build type to the release signing config; without this, the release buildType uses the default debug key (not accepted by Play Store).
  5. 5. isMinifyEnabled = true + isShrinkResources = true — both must be enabled together for maximum size reduction; minify handles code, shrinkResources handles XML/drawables/layouts; enabling only one leaves significant waste.
  6. 6. -keep class com.teamzlab.fintech.data.model.** { *; } — prevents R8 from renaming fields in data model classes that Gson/Retrofit uses via reflection; without this, JSON fields like 'transaction_id' cannot be mapped to Kotlin properties after obfuscation.
  7. 7. versionCode = (System.getenv('GITHUB_RUN_NUMBER') ?: '1').toInt() — ties versionCode to the CI run number, guaranteeing monotonic incrementing without manual intervention; local builds fall back to 1.
  8. 8. ./gradlew :app:bundleProdRelease — the Gradle task name encodes the variant: 'bundle' (AAB not APK) + 'Prod' (flavor) + 'Release' (buildType); the output AAB is in app/build/outputs/bundle/prodRelease/.

Spot the bug

// app/build.gradle.kts — fintech banking app
android {
    signingConfigs {
        create("release") {
            storeFile = file("fintech-release.jks")  // Bug 1
            storePassword = "MyS3cr3tP@ss"            // Bug 2
            keyAlias = "fintechkey"
            keyPassword = "KeyP@ss123"                // Bug 3
        }
    }

    buildTypes {
        release {
            isMinifyEnabled = false    // Bug 4
            isShrinkResources = true
            signingConfig = signingConfigs.getByName("release")
        }
    }

    defaultConfig {
        versionCode = 1   // Bug 5
        versionName = "1.0"
    }
}
Need a hint?
Check what is hardcoded that should not be, the contradiction between minify and shrinkResources, and the versionCode strategy for production releases.
Show answer
Bug 1: storeFile = file('fintech-release.jks') — the keystore path is relative and assumes the file is in the app/ directory. More critically, if this file is tracked by git (even accidentally), the keystore is now in version history forever. Fix: read the path from an environment variable: storeFile = file(System.getenv('KEYSTORE_PATH') ?: 'local.jks'). Add *.jks and *.keystore to .gitignore. Bug 2: storePassword = 'MyS3cr3tP@ss' — hardcoded secret in a build file. If this file is committed to git, the password is visible to every developer and in the full git history. Fix: storePassword = System.getenv('KEYSTORE_PASSWORD') ?: ''. Same for Bug 3: keyPassword = 'KeyP@ss123' must become System.getenv('KEY_PASSWORD') ?: ''. For a fintech app, leaking signing credentials could allow an attacker to publish a malicious update under your identity. Bug 4: isMinifyEnabled = false with isShrinkResources = true is a contradiction — isShrinkResources requires isMinifyEnabled = true to function. Gradle will throw a build error. More importantly, for a fintech app, R8 minification is critical: it reduces APK size (smaller download = higher install conversion), obfuscates code (makes reverse engineering financial logic harder), and removes unused classes (reduces attack surface). Fix: set isMinifyEnabled = true and add proguardFiles(...) with keep rules for your data models. Bug 5: versionCode = 1 hardcoded — every CI build will produce versionCode 1. The Play Store rejects uploads where the versionCode is not greater than the currently published version. Fix: read from CI: versionCode = (System.getenv('GITHUB_RUN_NUMBER') ?: '1').toInt(). This ensures every CI build has a unique, monotonically increasing versionCode automatically.

Explain like I'm 5

Signing your app is like putting your personal stamp on a sealed envelope — everyone can see it came from you and no one tampered with it. The Play Store checks this stamp every time you send an update. If you lose your stamp (keystore), you can never prove you are the same person who sent the original, so you cannot update anymore. That is why it is so important to keep the keystore safe.

Fun fact

The Android signing system has a dark side: if you lose your original keystore AND you are NOT enrolled in Play App Signing, you can NEVER update your app on the Play Store. You must publish a brand-new app with a new package name and ask all users to migrate. This has happened to real companies — the Play Store has no key recovery for un-enrolled apps.

Hands-on challenge

You are the Android lead for a fintech app. A CI run just published versionCode 187 to the Internal track. You now want to promote it to Closed testing (Alpha) for 200 beta testers, then do a 5% staged rollout to production. Write: (1) the Gradle signingConfig block reading from environment variables; (2) the proguard-rules.pro keep rules needed for a Retrofit API interface and its response data class; (3) the shell commands to build and sign the release AAB. Explain what Play App Signing buys you vs self-managed signing.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery