SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC22:22:52
UP00:01
MOD · ARTICLE · CI/CDS/N · AX-GITHUBPUBLISHED
All Articles
CI/CD · Updated 15 MIN

Automated CI/CD for Android: GitHub Actions from Zero to Production

Set up a complete CI/CD pipeline with automated testing, signing, and staged rollouts to Google Play using GitHub Actions.

By Rocky Elsalaymeh · Founder & Principal Consultant, Strategia-X

The Cost of Manual Releases

Manual release processes consume developer time on every release and introduce human error at every step. Forgetting to bump a version code, signing with the wrong keystore, or skipping a test suite before uploading to Play Console are mistakes that happen regularly on teams without automation. A well-configured CI/CD pipeline takes those steps out of human hands. Every push triggers tests, every merge to main produces a signed build, and releases to Google Play go through a controlled, repeatable process, whether directly through the Play Developer API or through Firebase App Distribution for internal testing. The compute is the cheap part. A standard Linux runner bills at $0.006 a minute, so a ten-minute pull-request run costs six cents. The expensive part is the release nobody can reproduce.

Pipeline Architecture

A production Android CI/CD pipeline has four stages: 1. Validate: Lint, static analysis, dependency vulnerability scanning 2. Test: Unit tests, integration tests, UI tests on emulator 3. Build: Compile, sign, and produce APK/AAB artifacts 4. Deploy: Upload to Play Console (internal, alpha, beta, or production track) Each stage gates the next. If lint fails, tests don't run. If tests fail, no artifact is built. This fast-fail approach saves compute minutes and surfaces problems early. The test stage is where a Compose testing strategy earns its keep, and the deploy stage is where a staged rollout belongs.

How Should the Pull-Request Workflow Run?

The CI workflow runs on every pull request. Its job is to validate code quality and test correctness before merging. Aim to keep it under 10 minutes: a gate developers stop waiting for is a gate they route around. Two settings do most of the work. A concurrency group allows at most one running job or workflow in the group at any time, and cancel-in-progress: true cancels the run already in flight when a new commit arrives, so a second push does not leave two builds racing. timeout-minutes caps a hung job. Without it, a job on a GitHub-hosted runner can run for up to 6 hours, and a stuck emulator will use all of it.
yaml
# .github/workflows/ci.yml
name: CI

on:
  pull_request:
    branches: [main, develop]

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  validate:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v7

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

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

      - name: Run lint
        run: ./gradlew lintDebug

      - name: Run detekt
        run: ./gradlew detekt

      - name: Check dependency vulnerabilities
        run: ./gradlew dependencyCheckAnalyze

  test:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    needs: validate
    steps:
      - uses: actions/checkout@v7

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

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

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

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

The Release Workflow: Production Builds

The release workflow triggers on tags or manual dispatch. It runs the release unit tests, produces a signed App Bundle, and uploads it to the Play Console. Android Gradle Plugin 8.x requires JDK 17, which is what actions/setup-java installs here; check the plugin's release notes before moving to a newer major. The upload step uses r0adkll/upload-google-play, and two details in it have changed since most tutorials were written. The track input is deprecated in favour of tracks, which takes a comma-separated list of Play tracks. And status accepts completed, inProgress, halted or draft: pair inProgress with a userFraction between 0.0 and 1.0 to release to a share of users instead of all of them.
yaml
# .github/workflows/release.yml
name: Release

on:
  push:
    tags: ['v*']
  workflow_dispatch:
    inputs:
      track:
        description: 'Play Console track'
        required: true
        default: 'internal'
        type: choice
        options: [internal, alpha, beta, production]

jobs:
  build-release:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v7

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

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

      - name: Run all tests
        run: ./gradlew testReleaseUnitTest

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

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

  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    needs: build-release
    if: startsWith(github.ref, 'refs/tags/v')
    steps:
      - name: Download AAB
        uses: actions/download-artifact@v8
        with:
          name: release-aab

      - name: Upload to Play Console
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT }}
          packageName: com.example.app
          releaseFiles: "*.aab"
          tracks: ${{ inputs.track || 'internal' }}
          status: completed

Secrets Management

Never commit signing keys or service account credentials to your repository. GitHub Actions secrets are encrypted and injected as environment variables at runtime. A keystore is a binary file, so base64-encode it first. The pattern in GitHub's guide to storing Base64 binary blobs as secrets is base64 -w 0 release.jks > release.base64 on Linux, then gh secret set KEYSTORE_BASE64 < release.base64. Secrets are limited to 48 KB each, so check the encoded size if your keystore holds several keys. Store the Play Console service account JSON as a second secret. Your build.gradle.kts should read signing configuration from environment variables, with a fallback to a local key.properties for developer machines. The rest of the hardening checklist is in our Android security guide.
kotlin
// app/build.gradle.kts
android {
    signingConfigs {
        create("release") {
            // CI: read from environment variables
            // Local: read from key.properties file
            val keystoreFile = System.getenv("KEYSTORE_FILE")
            if (keystoreFile != null) {
                // Decode base64 keystore from CI secret
                val decoded = Base64.getDecoder().decode(keystoreFile)
                val tempFile = File.createTempFile("keystore", ".jks")
                tempFile.writeBytes(decoded)
                storeFile = tempFile
                storePassword = System.getenv("KEYSTORE_PASSWORD")
                keyAlias = System.getenv("KEY_ALIAS")
                keyPassword = System.getenv("KEY_PASSWORD")
            } else {
                // Local development: key.properties
                val props = Properties().apply {
                    load(rootProject.file("key.properties")
                        .inputStream())
                }
                storeFile = file(props["storeFile"] as String)
                storePassword = props["storePassword"] as String
                keyAlias = props["keyAlias"] as String
                keyPassword = props["keyPassword"] as String
            }
        }
    }
}

Gradle Caching for Faster Builds

Android builds are slow, and on CI every minute is billed. The gradle/actions/setup-gradle action caches Gradle state between runs without configuration; the settings below go further. - Enable the build cache in gradle.properties so task outputs are reused - Enable the configuration cache, which stores the result of the configuration phase and lets Gradle skip that phase when its inputs have not changed - Use concurrency to cancel redundant runs when new commits push to the same branch Our Gradle build optimization guide covers the local side of the same problem.
properties
# gradle.properties
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.parallel=true
org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC

What Does the Pipeline Cost?

GitHub bills hosted runners by the minute, rounded up per job, and the rate depends on the operating system:
RunnerPer-minute rateA 10-minute job
Linux 2-core (x64)$0.006$0.06
Windows 2-core (x64)$0.010$0.10
macOS 3-core or 4-core$0.062$0.62
The rates are from GitHub's runner pricing reference; the last column is arithmetic. An Android build has no reason to run on macOS, and at roughly ten times the Linux rate it should not. Because each job is rounded up on its own, splitting one job into several also adds billed minutes. Split for fast failure, not out of habit.
MOD · TAKEAWAYS7 POINTSSUMMARY

Key Takeaways

  1. 1A CI/CD pipeline has four stages: validate, test, build, deploy.
  2. 2Aim for a pull-request workflow under 10 minutes, and set timeout-minutes: a hosted job can otherwise run for up to 6 hours.
  3. 3Never commit signing keys. Use GitHub Actions encrypted secrets, which are limited to 48 KB each.
  4. 4Base64-encode your keystore and decode it during CI builds.
  5. 5Upload with tracks, not the deprecated track input; status inProgress with a userFraction makes it a staged rollout.
  6. 6Linux runners bill at $0.006 a minute against $0.062 for macOS. Build Android on Linux.
  7. 7Use concurrency groups to cancel redundant workflow runs.
MOD · FAQ4 ENTRIESANSWERED

Frequently Asked

How long should an Android pull-request workflow take?

Aim for under 10 minutes. Past that, developers stop waiting for the result and the gate stops functioning. Gradle's build cache, the configuration cache and parallel execution keep it there, and timeout-minutes stops a hung emulator from running to GitHub's 6-hour job limit while everyone assumes the build is still working.

How do I sign a release build in CI without committing the keystore?

Base64-encode the keystore, store it as a GitHub Actions encrypted secret, and decode it on the runner at build time. Secrets are limited to 48 KB each, so check the encoded size. Read the passwords and the key alias from secrets too, and keep a local key.properties fallback so developer machines never need the CI values.

How do I stop redundant CI runs from piling up?

Use a concurrency group keyed on the branch or pull request. GitHub allows at most one running job or workflow per group, and cancel-in-progress: true cancels the run already in flight when a new commit arrives. You stop paying for builds of commits that are no longer the head of the branch.

How much does GitHub Actions cost for Android builds?

Hosted runners bill by the minute, rounded up per job. A standard Linux 2-core runner is $0.006 a minute, so a 10-minute build costs about six cents. macOS runners are $0.062 a minute, roughly ten times more, which is why an Android build, unlike an iOS build, should run on Linux.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

Ready to architect your next Android app?

ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA