Lesson 47 of 83 intermediate

CI/CD for Android: GitHub Actions, Fastlane & Bitrise

Automate your entire Android delivery pipeline — the skill that makes you invaluable on any team

Open interactive version (quiz + challenge)

Real-world analogy

A CI/CD pipeline is like a fully automated car assembly line. When a new design (code change) arrives, the line automatically inspects the parts (lint), tests every component (unit tests), assembles the car (build), and ships it to the showroom (deploy). No human stands on the factory floor watching each car roll by — the line runs 24/7 and stops itself if it detects a defect.

What is it?

CI/CD (Continuous Integration / Continuous Delivery) for Android is the practice of automating code quality checks, building signed APKs/AABs, running tests, and deploying to the Play Store or distribution platforms on every code change. GitHub Actions workflows, Fastlane lanes, and platforms like Bitrise provide the infrastructure to implement this pipeline.

Real-world relevance

A SaaS school management platform runs a GitHub Actions pipeline that: on every PR lint + unit tests complete in 4 minutes; on every merge to main a prodRelease AAB is built, signed, and uploaded to the Play Console Internal track in 12 minutes; on git tag v*.*.* the release is promoted to the 1% staged rollout automatically. The team of 6 ships updates to 50,000+ school users daily without any manual upload steps.

Key points

Code example

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

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

jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - name: Cache Gradle
        uses: actions/cache@v4
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: gradle-${{ hashFiles('gradle/libs.versions.toml') }}
          restore-keys: gradle-

      - name: Run lint
        run: ./gradlew lintProdRelease

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

  build-and-deploy:
    needs: lint-and-test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { java-version: '17', distribution: 'temurin' }

      - name: Restore Gradle cache
        uses: actions/cache@v4
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: gradle-${{ hashFiles('gradle/libs.versions.toml') }}

      - name: Decode keystore
        run: |
          echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > release.jks

      - name: Build release AAB
        env:
          KEYSTORE_PATH: release.jks
          KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
          GITHUB_RUN_NUMBER: ${{ github.run_number }}
        run: ./gradlew :app:bundleProdRelease

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

      - name: Deploy to Play Internal track (Fastlane)
        env:
          PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
        run: |
          bundle exec fastlane supply \
            --aab app/build/outputs/bundle/prodRelease/app-prod-release.aab \
            --track internal \
            --json_key_data "$PLAY_SERVICE_ACCOUNT_JSON"

Line-by-line walkthrough

  1. 1. on: push: branches: [main] and tags: [v*.*.*] — the workflow triggers on pushes to main and on version tags; pull_request trigger runs lighter jobs on every PR without deploying.
  2. 2. actions/setup-java@v4 with java-version: '17' — GitHub-hosted ubuntu-latest runners do not have a JDK pre-configured; this action installs Temurin JDK 17 which is required by AGP 8.x.
  3. 3. actions/cache@v4 path: ~/.gradle/caches key: gradle-${{ hashFiles('libs.versions.toml') }} — the cache key changes only when the version catalog changes; unchanged dependencies are restored from cache in seconds.
  4. 4. echo '${{ secrets.KEYSTORE_BASE64 }}' | base64 --decode > release.jks — the keystore binary is stored in GitHub Secrets as a base64 string (since Secrets support only text); this step decodes it back to a binary .jks file.
  5. 5. env: KEYSTORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD — injected as environment variables read by build.gradle.kts via System.getenv(); they are masked in GitHub Actions logs automatically.
  6. 6. GITHUB_RUN_NUMBER: ${{ github.run_number }} — maps the GitHub Actions run number to the env var that Gradle reads for versionCode; guarantees unique, monotonically increasing versionCode per build.
  7. 7. ./gradlew :app:bundleProdRelease — the Gradle task builds a signed AAB for the prod flavor + release build type; the variant selection ensures only production code is bundled, not dev or staging.
  8. 8. actions/upload-artifact with retention-days: 30 — stores the AAB so QA can download it directly from the GitHub Actions run page via a browser; no separate distribution service needed for early QA builds.
  9. 9. bundle exec fastlane supply --track internal — uses the Fastlane supply action to upload the AAB to the Play Console Internal testing track; PLAY_SERVICE_ACCOUNT_JSON is a service account key with Play Developer API access.
  10. 10. needs: lint-and-test in the build-and-deploy job — enforces the dependency chain; if lint or unit tests fail, the build and deploy job never starts, preventing a broken build from reaching QA.

Spot the bug

# .github/workflows/android-ci.yml — school management SaaS
name: Android CI

on:
  push:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build release
        run: ./gradlew :app:bundleProdRelease  # Bug 1

      - name: Deploy to Play Store
        run: |
          echo "$KEYSTORE_B64" | base64 --decode > release.jks  # Bug 2
          ./gradlew :app:bundleProdRelease
        env:
          KEYSTORE_B64: ${{ secrets.KEYSTORE_BASE64 }}
          KEYSTORE_PASSWORD: MyHardcodedPass  # Bug 3

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: release
          path: app/build/outputs/bundle/prodRelease/
          retention-days: 365  # Bug 4

  # No lint or unit test job — Bug 5
Need a hint?
Look at job ordering, secret handling, artifact retention, missing test jobs, and the duplicate build command.
Show answer
Bug 1: ./gradlew :app:bundleProdRelease runs before the Java/JDK is set up. There is no actions/setup-java step in the workflow — the ubuntu-latest runner may have a JDK but not necessarily JDK 17 required by AGP 8.x. Fix: add 'uses: actions/setup-java@v4 with java-version: 17, distribution: temurin' before any Gradle command. Also missing: there is no Gradle cache step, so every build downloads all dependencies fresh (~3 minutes). Bug 2: echo '$KEYSTORE_B64' | base64 --decode > release.jks runs in the Deploy step, but Build release step (Bug 1) already ran bundleProdRelease without the keystore being present. The release build in the first step will fail signing (or use debug signing, which Play Store rejects). The steps are in the wrong order. Fix: decode the keystore BEFORE building, then build once, then deploy. Do not build twice. Bug 3: KEYSTORE_PASSWORD: MyHardcodedPass — the password is hardcoded directly in the workflow YAML file committed to git. Anyone with repository read access (current or future) can see this password in git history. Fix: KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} — store it in GitHub repository Secrets. Bug 4: retention-days: 365 — storing AABs for a full year will exhaust GitHub Actions storage quota rapidly (each AAB can be 30-100MB; 365 builds × 30MB = 10GB+ per year just for one workflow). The default retention limit for GitHub Actions artifacts is 90 days for private repos. Fix: retention-days: 30 is sufficient for QA access. Bug 5: There is no lint or unit test job — the pipeline goes straight from checkout to building a release AAB without any code quality checks. A broken build with a crash on startup would be deployed directly to the Play Console internal track. Fix: add a separate job (lint-and-test) that runs lintProdRelease and testProdReleaseUnitTest, then make the build job 'needs: lint-and-test'.

Explain like I'm 5

Imagine every time you finish drawing a page of a comic book, a robot automatically checks your spelling, tests that the story makes sense, prints it, and mails it to the comic book store — all in 10 minutes while you start the next page. CI/CD is that robot for your Android app. You push code, the robot does all the tedious deployment work while you keep coding.

Fun fact

GitHub Actions was originally designed as a generic automation platform, not a CI/CD tool. The Android community reverse-engineered its capabilities for mobile builds faster than GitHub anticipated. By 2022 GitHub Actions had overtaken Jenkins as the most-used CI system for Android open-source projects on GitHub, despite being only 3 years old at the time.

Hands-on challenge

Design a GitHub Actions pipeline for a SaaS school management Android app with these requirements: (1) on every PR: run lint and unit tests in under 5 minutes; (2) on merge to main: build prodRelease AAB, sign it, upload to Play Internal track via Fastlane; (3) on git tag v*.*.*: promote to 10% staged rollout; (4) keystore stored as base64 secret, passwords as separate secrets. Write the complete .github/workflows/android-cd.yml YAML file with all jobs, steps, and secrets references.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery