ANDROID-ARCHITECT

AI-powered Android development assistant.

SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC07:37:56
UP00:01
MOD · ARTICLE · CI/CDS/N · AX-GITHUBPUBLISHED
CI/CDFeb 20, 202615 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 hours of developer time per 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 eliminates these risks. Every push triggers tests, every merge to main produces a signed build, and releases to Google Play happen through a controlled, repeatable process -- whether directly via the Play Console API or through Firebase App Distribution for internal testing. The investment in setup pays for itself within the first month.

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 CI Workflow: Pull Requests

The CI workflow runs on every pull request. Its job is to validate code quality and test correctness before merging. Keep this workflow fast (under 10 minutes) to avoid blocking developer velocity.
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
    steps:
      - uses: actions/checkout@v4

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

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

      - name: Run lint
        run: ./gradlew lintDebug

      - name: Run detekt
        run: ./gradlew detekt

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

  test:
    runs-on: ubuntu-latest
    needs: validate
    steps:
      - uses: actions/checkout@v4

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

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

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

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

The Release Workflow: Production Builds

The release workflow triggers on tags or manual dispatch. It produces a signed App Bundle, runs the full test suite (including instrumented tests), and optionally uploads to the Play Console. The workflow uses actions/setup-java for JDK configuration.
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
    steps:
      - uses: actions/checkout@v4

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

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

      - 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@v4
        with:
          name: release-aab
          path: app/build/outputs/bundle/release/*.aab

  deploy:
    runs-on: ubuntu-latest
    needs: build-release
    if: startsWith(github.ref, 'refs/tags/v')
    steps:
      - name: Download AAB
        uses: actions/download-artifact@v4
        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"
          track: ${{ inputs.track || 'internal' }}
          status: completed

Secrets Management

Never commit signing keys or service account credentials to your repository. GitHub Actions provides encrypted secrets that are injected as environment variables at runtime. For the Android signing keystore, base64-encode the .jks file and store it as a secret. Decode it during the build step. For the Play Console service account, store the JSON key as a secret. Your `build.gradle.kts` should read signing configuration from environment variables with fallback to local `key.properties` for developer machines.
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. A clean release build can take 5-15 minutes. Caching cuts this dramatically. The `gradle/actions/setup-gradle` action handles Gradle caching automatically, but you can optimize further. Key optimizations: - Enable Gradle build cache in `gradle.properties` - Use configuration cache for faster configuration phase - Cache the Android SDK directory between runs - Use `concurrency` to cancel redundant runs when new commits push to the same branch
properties
# gradle.properties
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.parallel=true
org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1A CI/CD pipeline has four stages: validate, test, build, deploy.
  2. 2Pull request workflows should run in under 10 minutes for developer velocity.
  3. 3Never commit signing keys -- use GitHub Actions encrypted secrets.
  4. 4Base64-encode your keystore and decode it during CI builds.
  5. 5Gradle caching, configuration cache, and parallel builds cut CI times significantly.
  6. 6Use concurrency groups to cancel redundant workflow runs.
MOD · FAQ3 ENTRIESANSWERED

Frequently Asked

How long should an Android pull-request workflow take?

Under 10 minutes. Past that, developers stop waiting for the result and the gate stops functioning. Gradle caching, the configuration cache, and parallel builds are what keep it there.

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 into the runner at build time. Signing keys never enter the repository.

How do I stop redundant CI runs from piling up?

Use concurrency groups. A new push to the same ref cancels the in-flight run instead of queueing another one behind it.

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