Post

Android Gradle Build: AGP, Build Variants, Signing and CI

How Android apps are built with Gradle and the Android Gradle Plugin, which files are involved, how to lint, scan, test and sign, and a complete GitHub Actions pipeline.

Android Gradle Build: AGP, Build Variants, Signing and CI

Android apps are built with Gradle and the Android Gradle Plugin (AGP). AGP adds Android specific steps to a normal JVM build: resource compilation, DEX conversion, code shrinking with R8, packaging into an APK or Android App Bundle (AAB) and signing. Kotlin is the default language, with Jetpack Compose for UI.

This post is part of Build Ecosystems by Programming Language. General Gradle concepts are covered in Gradle Build, and the iOS side in iOS Build with Xcode and SwiftPM.

At a glance

CategoryCommon tools
Build toolsGradle + Android Gradle Plugin, Android SDK command-line tools, fastlane
Package registryGoogle Maven, Maven Central, private Artifactory / Nexus
Build filessettings.gradle.kts, build.gradle.kts (root + module), gradle.properties, gradle/libs.versions.toml, AndroidManifest.xml, proguard-rules.pro, gradlew, local.properties
Lint and formatAndroid Lint, ktlint, detekt
SAST (open source)Android Lint security checks, MobSF / mobsfscan, Semgrep, CodeQL (Kotlin/Java)
SAST (commercial)NowSecure, Guardsquare AppSweep, Checkmarx One, Veracode, Snyk Code, SonarQube Server
SCAOWASP Dependency-Check, Snyk, Mend, Trivy, GitHub dependency submission
FrameworksJetpack Compose, Android Jetpack (Room, Navigation, WorkManager), Hilt, Retrofit; cross-platform: Kotlin Multiplatform, Flutter, React Native
Test and coverageJUnit, Robolectric, Espresso, Compose UI tests; JaCoCo, Kover
Build output.apk (install / testing), .aab (Google Play), .aar (libraries), R8 mapping.txt
CI cache~/.gradle/caches, ~/.gradle/wrapper

How the build works

In CI, each stage of the generic build pipeline maps to a Android Gradle command, plugin or scanner. Press play, or step through it:

  1. Source + build scripts: A commit triggers CI. settings.gradle.kts, the module build.gradle.kts files and libs.versions.toml define the app; gradlew pins Gradle.
  2. Resolve dependencies: Gradle and the Android Gradle Plugin resolve dependencies from Google Maven and Maven Central; setup-gradle caches them.
  3. Lint and format: ./gradlew lint runs Android Lint (including security checks), and ktlintCheck and detekt cover Kotlin style and smells.
  4. Compile: ./gradlew assembleDebug compiles Kotlin and Java, merges resources and builds a debug APK.
  5. Test and coverage: ./gradlew testDebugUnitTest runs the JVM unit tests (including Robolectric), and koverXmlReport records coverage.
  6. Security scans in parallel: mobsfscan and the Android Lint security checks cover our code, ./gradlew dependencyCheckAnalyze checks dependencies, and Gitleaks scans for secrets.
  7. Quality gate: Tests, coverage and every scanner are required checks, so a single failure stops the pipeline here.
  8. Fail the build (fail): A failed check stops the pipeline at the gate, so nothing is packaged, signed or published.
  9. Build the app bundle (pass): ./gradlew bundleRelease builds the release .aab, shrunk by R8; keep mapping.txt to read crash reports.
  10. Sign (pass): The bundle is signed with the upload keystore decoded from CI secrets; Play App Signing re-signs it with the app signing key.
  11. Upload to Google Play (pass): The signed .aab is uploaded to Google Play, which signs and delivers optimised APKs to each device.
  • Build variants = build types (debug, release) × product flavors (free, paid, staging, prod). Every variant gets its own tasks, for example assembleProdRelease or bundleStagingDebug.
  • R8 removes unused code and obfuscates names in release builds; keep the generated mapping.txt to de-obfuscate crash reports.
  • App Bundles (.aab) are required for Google Play; Play generates optimised APKs per device and signs them with the app signing key (Play App Signing), while you sign uploads with an upload key.

Build files

FilePurposeCommit?
settings.gradle.ktsModules, plugin and dependency repositoriesYes
build.gradle.kts (root)Plugin versions applied to modulesYes
app/build.gradle.ktsandroid {} block: SDK levels, variants, signing, dependenciesYes
gradle/libs.versions.tomlVersion catalog (AGP, Kotlin, libraries)Yes
gradle.propertiesandroid.useAndroidX, JVM args, caching flagsYes
AndroidManifest.xmlComponents, permissions, app metadataYes
proguard-rules.proR8 keep rulesYes
lint.xml, lint-baseline.xmlAndroid Lint configuration and baselineYes
local.propertiesLocal SDK pathNo
*.jks / *.keystoreUpload signing keyNever (store in secrets)
google-services.jsonFirebase configDepends on policy (not a secret, but environment-specific)

Commands by category

1
2
3
4
5
6
7
8
9
./gradlew assembleDebug                          # debug APK
./gradlew lint                                   # Android Lint (incl. security checks)
./gradlew ktlintCheck detekt                     # Kotlin lint (ktlint + detekt plugins)
mobsfscan app/src/main                           # SAST (MobSF static rules)
./gradlew dependencyCheckAnalyze                 # SCA (OWASP plugin)
./gradlew testDebugUnitTest                      # JVM unit tests (incl. Robolectric)
./gradlew koverHtmlReport                        # coverage (Kover)
./gradlew connectedDebugAndroidTest              # instrumented tests on an emulator/device
./gradlew bundleRelease                          # signed .aab for Google Play

Minimal project

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// app/build.gradle.kts
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
}

android {
    namespace = "com.example.demo"
    compileSdk = 35

    defaultConfig {
        applicationId = "com.example.demo"
        minSdk = 26
        targetSdk = 35
        versionCode = (System.getenv("BUILD_NUMBER") ?: "1").toInt()
        versionName = "1.0.0"
    }

    signingConfigs {
        create("release") {
            storeFile = file(System.getenv("KEYSTORE_PATH") ?: "upload.jks")
            storePassword = System.getenv("KEYSTORE_PASSWORD")
            keyAlias = System.getenv("KEY_ALIAS")
            keyPassword = System.getenv("KEY_PASSWORD")
        }
    }

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

    buildFeatures { compose = true }
    lint { warningsAsErrors = true; abortOnError = true }
}

dependencies {
    implementation(platform(libs.androidx.compose.bom))
    implementation(libs.androidx.activity.compose)
    implementation(libs.androidx.compose.material3)
    testImplementation(libs.junit)
}

Frameworks

FrameworkStart a project
Jetpack ComposeAndroid Studio → New Project → Empty Activity (Compose)
Hilt (DI)Add com.google.dagger.hilt.android plugin + hilt-android dependency
Kotlin MultiplatformGenerate at kmp.jetbrains.com
Flutterflutter create app && flutter build appbundle
React Nativenpx @react-native-community/cli init App then cd android && ./gradlew bundleRelease

CI with GitHub Actions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
name: android-ci
on: [push, pull_request]
permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest                     # Android SDK is preinstalled on hosted Ubuntu runners
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '17'
      - uses: gradle/actions/setup-gradle@v4

      - run: ./gradlew lint ktlintCheck detekt
      - run: ./gradlew testDebugUnitTest koverXmlReport

      - name: Decode upload keystore
        if: github.ref == 'refs/heads/main'
        run: echo "${{ secrets.UPLOAD_KEYSTORE_B64 }}" | base64 -d > upload.jks

      - name: Signed App Bundle
        if: github.ref == 'refs/heads/main'
        run: ./gradlew bundleRelease
        env:
          BUILD_NUMBER: ${{ github.run_number }}
          KEYSTORE_PATH: ${{ github.workspace }}/upload.jks
          KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}

      - uses: actions/upload-artifact@v4
        if: github.ref == 'refs/heads/main'
        with:
          name: release
          path: |
            app/build/outputs/bundle/release/*.aab
            app/build/outputs/mapping/release/mapping.txt

Best practices

  • Use Play App Signing: keep only the upload key in CI secrets, so a leaked key can be reset.
  • Never commit keystores or local.properties; read signing values from environment variables.
  • Enable R8 (isMinifyEnabled) for release and archive mapping.txt with every build.
  • Make Android Lint a gate (warningsAsErrors) and use a baseline file for legacy issues.
  • Derive versionCode from CI (run number) so every upload is unique.
  • Run instrumented tests selectively (emulators are slow on CI); rely on Robolectric and Compose tests for most coverage, and use Firebase Test Lab or a device farm for the rest.
  • Keep AGP, Kotlin and the Compose BOM in the version catalog and update them together.
This post is licensed under CC BY 4.0 by the author.