PCSalt
YouTube GitHub
Back to Android
Android · 3 min read

CI/CD for Android with GitHub Actions

Set up automated builds, tests, signing, and Play Store deployment for your Android project using GitHub Actions.


Every time you push code, you want to know two things: does it build, and do the tests pass? GitHub Actions makes this free for public repos and easy for private ones. This guide walks you through setting up a complete CI/CD pipeline for an Android project — from basic builds to Play Store deployment.

Prerequisites

  • An Android project with Gradle
  • A GitHub repository
  • Basic familiarity with YAML

Step 1: Basic Build and Test

Create .github/workflows/android.yml in your repository:

name: Android CI

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

jobs:
  build:
    runs-on: ubuntu-latest

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

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

      - name: Grant execute permission for Gradle
        run: chmod +x gradlew

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

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

This runs on every push to main and on every pull request. It checks out the code, sets up JDK 21, builds the debug APK, and runs unit tests.

Push this file and check the Actions tab in your GitHub repository — you should see the workflow running.

Step 2: Gradle Caching

Android builds are slow. Caching the Gradle dependencies and build outputs cuts build time significantly — often by 50% or more.

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

Add this step after the JDK setup and before the build step. It caches Gradle dependencies and build outputs. The cache-read-only option ensures that only builds on main write to the cache — PR builds read from it but don’t pollute it.

Remove the “Grant execute permission” step — setup-gradle handles that automatically.

Updated workflow so far

name: Android CI

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

jobs:
  build:
    runs-on: ubuntu-latest

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

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

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

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

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

Step 3: Build Variants and Product Flavors

If your project has multiple build variants (debug/release) or product flavors, you can build them selectively:

      # Build specific variant
      - name: Build release APK
        run: ./gradlew assembleRelease

      # Build all variants
      - name: Build all variants
        run: ./gradlew assemble

      # Build specific flavor
      - name: Build production release
        run: ./gradlew assembleProductionRelease

For most CI setups, building debug on PRs and release on main is a good balance:

      - name: Build
        run: |
          if [ "${{ github.event_name }}" == "pull_request" ]; then
            ./gradlew assembleDebug
          else
            ./gradlew assembleRelease
          fi

Step 4: Upload Artifacts

Save the built APK/AAB so you can download it from the GitHub Actions UI — useful for QA testing or sharing builds without a full Play Store deploy.

      - name: Upload APK
        uses: actions/upload-artifact@v4
        with:
          name: debug-apk
          path: app/build/outputs/apk/debug/app-debug.apk
          retention-days: 14

For AAB (Android App Bundle):

      - name: Build release AAB
        run: ./gradlew bundleRelease

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

After the workflow runs, go to the workflow run page on GitHub and download the artifact from the Artifacts section at the bottom.

Step 5: Release Signing

Debug builds are signed automatically with a debug keystore. Release builds need your signing configuration. Never commit your keystore or passwords to the repository.

Store secrets in GitHub

Go to your repository → SettingsSecrets and variablesActions and add these secrets:

Secret NameValue
KEYSTORE_BASE64Base64-encoded keystore file
KEYSTORE_PASSWORDKeystore password
KEY_ALIASKey alias
KEY_PASSWORDKey password

To encode your keystore as Base64:

base64 -i your-keystore.jks | pbcopy  # macOS — copies to clipboard
base64 -w 0 your-keystore.jks         # Linux — prints to stdout

Decode keystore in the workflow

      - name: Decode keystore
        if: github.event_name != 'pull_request'
        run: |
          echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > app/release-keystore.jks

      - name: Build signed release APK
        if: github.event_name != 'pull_request'
        run: ./gradlew assembleRelease
        env:
          SIGNING_STORE_FILE: release-keystore.jks
          SIGNING_STORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
          SIGNING_KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          SIGNING_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}

Configure Gradle to read environment variables

In your app/build.gradle.kts:

android {
  signingConfigs {
    create("release") {
      storeFile = file(System.getenv("SIGNING_STORE_FILE") ?: "release-keystore.jks")
      storePassword = System.getenv("SIGNING_STORE_PASSWORD") ?: ""
      keyAlias = System.getenv("SIGNING_KEY_ALIAS") ?: ""
      keyPassword = System.getenv("SIGNING_KEY_PASSWORD") ?: ""
    }
  }

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

This way, signing works in CI (reads from environment variables) and locally (you can set the same env vars or use a local keystore.properties file).

Step 6: Play Store Deployment

For automated Play Store uploads, use the r0adkll/upload-google-play action.

Prerequisites

  1. Create a Google Play service account in the Google Cloud Console
  2. Grant it access in Google Play Console → SetupAPI access
  3. Download the service account JSON key
  4. Store it as a GitHub secret: PLAY_STORE_SERVICE_ACCOUNT_JSON

Workflow step

      - name: Deploy to Play Store (Internal Testing)
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT_JSON }}
          packageName: com.example.yourapp
          releaseFiles: app/build/outputs/bundle/release/app-release.aab
          track: internal
          status: completed

Available tracks: internal, alpha, beta, production. Start with internal for testing, then promote manually in Play Console when ready.

Step 7: Build Status Badge

Add a build status badge to your README.md:

![Android CI](https://github.com/YOUR_USERNAME/YOUR_REPO/actions/workflows/android.yml/badge.svg)

Replace YOUR_USERNAME and YOUR_REPO with your actual values. This shows a green/red badge based on the latest workflow run on the default branch.

Complete Workflow

Here’s the full workflow combining everything:

name: Android CI

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

jobs:
  build:
    runs-on: ubuntu-latest

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

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

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

      # Debug build + tests (always)
      - name: Build debug APK
        run: ./gradlew assembleDebug

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

      - name: Upload debug APK
        if: github.event_name == 'pull_request'
        uses: actions/upload-artifact@v4
        with:
          name: debug-apk
          path: app/build/outputs/apk/debug/app-debug.apk
          retention-days: 14

      # Signed release build (main branch only)
      - name: Decode keystore
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: |
          echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > app/release-keystore.jks

      - name: Build signed release AAB
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: ./gradlew bundleRelease
        env:
          SIGNING_STORE_FILE: release-keystore.jks
          SIGNING_STORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
          SIGNING_KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          SIGNING_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}

      - name: Upload release AAB
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        uses: actions/upload-artifact@v4
        with:
          name: release-aab
          path: app/build/outputs/bundle/release/app-release.aab
          retention-days: 30

      # Deploy to Play Store (optional)
      - name: Deploy to Play Store
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT_JSON }}
          packageName: com.example.yourapp
          releaseFiles: app/build/outputs/bundle/release/app-release.aab
          track: internal
          status: completed

What this does

TriggerWhat runs
PR opened/updatedDebug build → Unit tests → Upload debug APK
Push to mainDebug build → Unit tests → Signed release AAB → Upload AAB → Deploy to Play Store

Tips

  1. Start simple — begin with just the build and test steps. Add signing and deployment later when you’re ready.
  2. Use branch protection — require the CI workflow to pass before merging PRs. Go to SettingsBranchesBranch protection rules → check “Require status checks to pass.”
  3. Run instrumented tests — unit tests run on the JVM, but UI/integration tests need an emulator. Use reactivecircus/android-emulator-runner for that.
  4. Lint checks — add ./gradlew lint to catch code quality issues early.
  5. Matrix builds — test against multiple API levels by using a strategy matrix. Useful for libraries, overkill for most apps.
  6. Keep secrets secret — never echo secrets in logs. GitHub masks them automatically, but be careful with custom scripts.