As developers, we want to spend our time building cool features, not manually running builds, tests, and deployments. This is where CI/CD (Continuous Integration/Continuous Deployment) comes in. It's not just a buzzword; it's a fundamental practice that separates professional development teams from hobbyists.

By setting up a CI/CD pipeline, you create a robot assistant that works for you 24/7. It ensures every code change is tested, that your app always builds, and that you can release new versions with a single click. This guide will walk you through setting up a production-grade CI/CD pipeline for your Android app using the powerful and free GitHub Actions.

Why Bother? The Real-World Benefits

  • Catch Bugs Instantly: The pipeline runs your tests automatically on every push. If a change breaks something, you and your team know immediately, not hours or days later.
  • Always Have a Buildable App: No more "it works on my machine!" The pipeline is the single source of truth. If it fails, the code is broken.
  • Faster Feedback Loop: When you open a pull request, your team can see that all checks have passed before they even start their review.
  • Effortless Releases: A fully mature pipeline can build, sign, and even upload your app to the Play Store automatically.

The Full Setup: From Zero to Signed APK

We're going to build a complete workflow from scratch. This workflow will run on every push to your `main` branch.

Step 1: Create the Workflow File

In your project's root directory, create a new folder structure: .github/workflows/. Inside that folder, create a new file named android-ci.yml.

Step 2: Define the Basic Workflow

Let's start with the basics: checking out the code, setting up Java, and running a simple build. This is the foundation of our pipeline.

`android-ci.yml` - The Foundation
name: Android CI/CD

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 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: 'gradle' # This is a game-changer! Caches Gradle dependencies.

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

      - name: Build with Gradle
        run: ./gradlew build
Pro Tip: Notice the cache: 'gradle' line in the `setup-java` action. This automatically caches your Gradle dependencies. The first build will be slow, but subsequent builds will be dramatically faster because it doesn't need to re-download everything.

Step 3: Add Unit Tests

A build isn't useful unless you know the code works. Let's add a step to run our unit tests.

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

Handling Secrets: API Keys and Signing Keys

This is the most critical part. We can't put our API keys or release signing keys in the YAML file. We need to use GitHub Secrets.

Go to your GitHub repository -> Settings -> Secrets and variables -> Actions. Here you will add all your sensitive information.

Step 4: Securely Inject Your API Key

Let's assume you need your Google Maps API key from our previous articles.

  1. Create a new repository secret named MAPS_API_KEY and paste your key as the value.
  2. Add a step in your workflow to create the local.properties file using this secret right before the build step.
      - name: Create local.properties file
        run: echo "MAPS_API_KEY=${{ secrets.MAPS_API_KEY }}" > local.properties

Step 5: Build a Signed Release APK

This is the final boss. To sign a release APK, you need your keystore file, the store password, the key alias, and the key password.

  1. Convert your keystore to Base64: You can't upload a binary file as a secret. Instead, convert it to a text string. On Linux/macOS, run: base64 my-release-key.keystore > keystore_b64.txt. Copy the entire content of the text file.
  2. Create four secrets in GitHub:
    • RELEASE_KEYSTORE_BASE64: The Base64 string from the step above.
    • RELEASE_STORE_PASSWORD: Your store password.
    • RELEASE_KEY_ALIAS: Your key alias.
    • RELEASE_KEY_PASSWORD: Your key password.
  3. Add the signing step to your workflow: This step decodes the Base64 string back into a file and then runs the `assembleRelease` command, passing the secrets as environment variables.
      - name: Build signed release APK
        env:
          RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
          RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }}
          RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
          RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
        run: |
          echo $RELEASE_KEYSTORE_BASE64 | base64 --decode > app/release.keystore
          ./gradlew assembleRelease \
            -Pandroid.injected.signing.store.file=$(pwd)/app/release.keystore \
            -Pandroid.injected.signing.store.password=$RELEASE_STORE_PASSWORD \
            -Pandroid.injected.signing.key.alias=$RELEASE_KEY_ALIAS \
            -Pandroid.injected.signing.key.password=$RELEASE_KEY_PASSWORD

Step 6: Upload the APK as an Artifact

Finally, let's save the generated APK so you can download it.

      - name: Upload release APK
        uses: actions/upload-artifact@v4
        with:
          name: release-apk
          path: app/build/outputs/apk/release/app-release.apk

The Complete, Production-Ready Workflow

Putting it all together, your final `android-ci.yml` file will look like this. It's a powerful, automated assembly line for your app.

name: Android CI/CD

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 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: 'gradle'

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

      - name: Create local.properties file
        run: echo "MAPS_API_KEY=${{ secrets.MAPS_API_KEY }}" > local.properties

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

      - name: Build signed release APK
        env:
          RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
          RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }}
          RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
          RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
        run: |
          echo $RELEASE_KEYSTORE_BASE64 | base64 --decode > app/release.keystore
          ./gradlew assembleRelease \
            -Pandroid.injected.signing.store.file=$(pwd)/app/release.keystore \
            -Pandroid.injected.signing.store.password=$RELEASE_STORE_PASSWORD \
            -Pandroid.injected.signing.key.alias=$RELEASE_KEY_ALIAS \
            -Pandroid.injected.signing.key.password=$RELEASE_KEY_PASSWORD

      - name: Upload release APK
        uses: actions/upload-artifact@v4
        with:
          name: release-apk
          path: app/build/outputs/apk/release/app-release.apk

With this workflow in place, you've taken a massive step forward. You've automated the tedious parts of development, improved your code quality, and secured your release process. Now you can get back to what you do best: building amazing features.