You push code. Tests run automatically. The App Bundle (AAB) is built and signed. It gets uploaded to the Play Store internal track. You never touch the build machine.

This is CI/CD — Continuous Integration and Continuous Deployment. It saves hours of manual work and catches bugs before they reach users.

In this tutorial, you will set up a complete CI/CD pipeline for your Android app using GitHub Actions and Fastlane.

Prerequisites: You should know Git basics and have an Android app with tests. See Android Tutorial #16: App Security for release build configuration.


Why CI/CD Matters

Without CI/CD:

  1. You run tests manually (or forget to run them)
  2. You build the APK on your laptop
  3. You sign it with a keystore stored somewhere on your desktop
  4. You upload to Play Console manually
  5. Something breaks on a different Android version because you didn’t test there

With CI/CD:

  1. Push code → tests run automatically
  2. Tests pass → signed AAB is built
  3. AAB is uploaded to Play Console → available for testing
  4. Everything is reproducible, traceable, and version-controlled

GitHub Actions Basics

GitHub Actions runs workflows defined in YAML files. Put them in .github/workflows/.

Key concepts:

  • Workflow — a YAML file that defines automation
  • Job — a set of steps that run on a virtual machine
  • Step — a single command or action
  • Trigger — when the workflow runs (push, PR, schedule)

CI Workflow: Build and Test

This workflow runs on every pull request:

# .github/workflows/ci.yml
name: CI

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@v4
        with:
          cache-read-only: ${{ github.ref != 'refs/heads/main' }}

      - name: Run unit tests
        run: ./gradlew testDebugUnitTest

      - name: Run lint checks
        run: ./gradlew lintDebug

      - name: Build debug APK
        run: ./gradlew assembleDebug

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: app/build/reports/tests/

      - name: Upload lint results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: lint-results
          path: app/build/reports/lint-results-debug.html

Gradle Caching

The gradle/actions/setup-gradle action handles Gradle caching automatically. This reduces build times from 10+ minutes to 3-5 minutes.

For multi-module projects, Gradle Build Cache and Configuration Cache provide additional savings:

# gradle.properties
org.gradle.caching=true
org.gradle.configuration-cache=true

Instrumented Tests with Gradle Managed Devices

Instead of using a separate emulator, use Gradle Managed Virtual Devices to run instrumented tests in CI:

// build.gradle.kts (app module)
android {
    testOptions {
        managedDevices {
            localDevices {
                create("pixel6Api34") {
                    device = "Pixel 6"
                    apiLevel = 34
                    systemImageSource = "aosp-atd"
                }
            }
        }
    }
}

Run in CI:

      - name: Run instrumented tests
        run: ./gradlew pixel6Api34DebugAndroidTest

This downloads and manages the emulator automatically. No manual setup needed.


CD Workflow: Build and Deploy

This workflow builds a signed AAB and uploads it to the Play Store:

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    tags:
      - 'v*'

jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@v4

      - name: Decode keystore
        run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/release.keystore

      - name: Build signed AAB
        env:
          KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
        run: ./gradlew bundleRelease

      - name: Upload AAB artifact
        uses: actions/upload-artifact@v4
        with:
          name: release-aab
          path: app/build/outputs/bundle/release/app-release.aab

      - name: Deploy to Play Store
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
          packageName: com.example.myapp
          releaseFiles: app/build/outputs/bundle/release/app-release.aab
          track: internal
          mappingFile: app/build/outputs/mapping/release/mapping.txt

Setting Up Secrets

In your GitHub repository, go to Settings → Secrets and variables → Actions:

SecretDescription
KEYSTORE_BASE64Your keystore encoded as base64
KEYSTORE_PASSWORDKeystore password
KEY_ALIASKey alias name
KEY_PASSWORDKey password
PLAY_SERVICE_ACCOUNT_JSONGoogle Play service account JSON

Encode your keystore:

# macOS
base64 -i release.keystore -o keystore_base64.txt

# Linux / Ubuntu (GitHub Actions runners)
base64 -w 0 release.keystore > keystore_base64.txt

# Copy the contents of keystore_base64.txt to the secret

Signing Configuration

In your build.gradle.kts:

android {
    signingConfigs {
        create("release") {
            storeFile = file("release.keystore")
            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"
            )
        }
    }
}

Automated Versioning

Use the CI build number for the version code:

// build.gradle.kts
android {
    defaultConfig {
        val buildNumber = System.getenv("GITHUB_RUN_NUMBER")?.toIntOrNull() ?: 1
        versionCode = buildNumber
        versionName = "1.0.$buildNumber"
    }
}

This ensures every build has a unique, incrementing version code.


Fastlane for Android

Fastlane automates repetitive tasks — building, testing, deploying, taking screenshots.

Setup

# Install Fastlane
gem install fastlane

# Initialize in your project
cd your-android-project
fastlane init

Fastfile

Create fastlane/Fastfile:

default_platform(:android)

platform :android do
  desc "Run unit tests"
  lane :test do
    gradle(task: "testDebugUnitTest")
  end

  desc "Build debug APK"
  lane :build_debug do
    gradle(task: "assembleDebug")
  end

  desc "Build release AAB"
  lane :build_release do
    gradle(
      task: "bundleRelease",
      properties: {
        "android.injected.signing.store.file" => ENV["KEYSTORE_PATH"],
        "android.injected.signing.store.password" => ENV["KEYSTORE_PASSWORD"],
        "android.injected.signing.key.alias" => ENV["KEY_ALIAS"],
        "android.injected.signing.key.password" => ENV["KEY_PASSWORD"]
      }
    )
  end

  desc "Deploy to internal track"
  lane :deploy_internal do
    build_release
    upload_to_play_store(
      track: "internal",
      aab: "app/build/outputs/bundle/release/app-release.aab",
      mapping: "app/build/outputs/mapping/release/mapping.txt",
      json_key: ENV["PLAY_JSON_KEY_PATH"]
    )
  end

  desc "Promote internal to production"
  lane :promote_to_production do
    upload_to_play_store(
      track: "internal",
      track_promote_to: "production",
      json_key: ENV["PLAY_JSON_KEY_PATH"],
      rollout: "0.1"  # 10% rollout
    )
  end
end

Using Fastlane in CI

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2'
          bundler-cache: true

      - name: Decode keystore and Play key
        run: |
          echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > ${{ github.workspace }}/app/release.keystore
          echo "${{ secrets.PLAY_JSON_KEY_BASE64 }}" | base64 -d > ${{ github.workspace }}/play-service-account.json

      - name: Deploy with Fastlane
        env:
          KEYSTORE_PATH: ${{ github.workspace }}/app/release.keystore
          KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
          PLAY_JSON_KEY_PATH: ${{ github.workspace }}/play-service-account.json
        run: fastlane deploy_internal

Gradle Play Publisher — Lighter Alternative

If you don’t need Fastlane’s full feature set, the Gradle Play Publisher plugin is a simpler option:

plugins {
    id("com.github.triplet.play") version "3.11.0"
}

play {
    serviceAccountCredentials.set(file("play-service-account.json"))
    track.set("internal")
    defaultToAppBundles.set(true)
}

Then deploy with:

./gradlew publishBundle

AAB Size Monitoring

Track APK size in CI to prevent bloat:

      - name: Check AAB size
        run: |
          AAB_SIZE=$(stat -c%s app/build/outputs/bundle/release/app-release.aab)
          MAX_SIZE=52428800  # 50 MB
          if [ "$AAB_SIZE" -gt "$MAX_SIZE" ]; then
            echo "AAB size ($AAB_SIZE bytes) exceeds limit ($MAX_SIZE bytes)"
            exit 1
          fi
          echo "AAB size: $(($AAB_SIZE / 1048576)) MB"

Branch Protection

Set up branch protection rules for main:

  1. Go to repository Settings → Branches
  2. Add a rule for main
  3. Enable:
    • Require status checks to pass before merging
    • Require branches to be up to date
    • Select your CI workflow as a required check

Now nobody can merge to main without passing tests.


Complete Pipeline Example

Here is a complete workflow that handles PR checks and release deployment:

# .github/workflows/android.yml
name: Android CI/CD

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]
    tags:
      - 'v*'

jobs:
  # Run on every PR and push to main
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - uses: gradle/actions/setup-gradle@v4

      - run: ./gradlew testDebugUnitTest lintDebug

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: reports
          path: |
            app/build/reports/tests/
            app/build/reports/lint-results-debug.html

  # Run only on version tags
  deploy:
    if: startsWith(github.ref, 'refs/tags/v')
    needs: test
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - uses: gradle/actions/setup-gradle@v4

      - name: Decode keystore
        run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/release.keystore

      - name: Build release AAB
        env:
          KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
        run: ./gradlew bundleRelease

      - name: Deploy to Play Store
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
          packageName: com.example.myapp
          releaseFiles: app/build/outputs/bundle/release/app-release.aab
          track: internal
          mappingFile: app/build/outputs/mapping/release/mapping.txt

Release Process

# Tag a release
git tag -a v1.0.42 -m "Release 1.0.42: bug fixes and performance"
git push origin v1.0.42
# CI runs → tests pass → AAB uploaded to internal track

Cost Comparison

PlatformFree MinutesCost After Free
GitHub Actions2,000 min/month$0.008/min (Linux)
CircleCI6,000 min/month$0.006/min
Bitrise300 min/month~$0.02/min
GitLab CI400 min/month$0.005/min

GitHub Actions is the best choice for most projects. The free tier is generous, and integration with GitHub is seamless.


Common Mistakes

1. Not Caching Gradle Dependencies

Without caching, every CI run downloads all dependencies from scratch. Use the gradle/actions/setup-gradle action or manually cache ~/.gradle/caches.

2. Storing Keystores in the Repository

Never commit your keystore file. Use secrets to store it as a base64-encoded string and decode it during the build.

3. Running All Tests on Every PR

If your test suite takes 30 minutes, developers stop waiting for CI. Split into fast (unit tests) and slow (instrumented tests) jobs. Run instrumented tests only on main branch pushes.

4. Not Uploading Mapping Files

Without the mapping file, crash reports in Play Console and Crashlytics show obfuscated class names. Always upload mapping.txt alongside your AAB.

5. Hardcoding Version Codes

If two developers build with the same version code, the upload fails. Use the CI build number to ensure uniqueness.


What’s Next?

In this tutorial, you learned:

  • Setting up GitHub Actions for Android CI
  • Running tests and lint checks automatically
  • Building signed AABs in CI
  • Deploying to Play Store with Fastlane and GitHub Actions
  • Automated versioning with build numbers
  • Monitoring AAB size and enforcing branch protection

Next up: Android Tutorial #19: Play Store Publishing — Signing, Screenshots, ASO — where you will learn how to publish your app and optimize your store listing.



This is part 18 of the Android Development Tutorial series.