๐Ÿงช Testing, Debugging, and Performance

CI/CD for Android with GitHub Actions

Continuous integration catches broken builds and failing tests before they reach main. Continuous delivery turns a tag into a signed release on Google Play without anyone's laptop. This guide builds both with GitHub Actions, step by step and securely.

โœ๏ธ Yunok Tech ๐Ÿ“… Updated โฑ 3 min read ๐ŸŽฏ Intermediate
On this page
  1. The pipeline
  2. CI workflow for pull requests
  3. Signing in CI without leaking keys
  4. CD workflow on tags
  5. Versioning
  6. Keeping CI fast
  7. Pipeline stages by team size
  8. Security checklist
  9. Frequently asked questions
  10. Conclusion

"It builds on my machine" isn't a release process. With CI, every pull request is built, linted and tested on a clean machine. With CD, releases are reproducible, signed with protected credentials, and uploaded consistently.

The pipeline

Android CI/CD pipeline with GitHub Actions On every pull request: checkout, set up JDK and Gradle cache, run lint, unit tests and assemble debug, then report results. On a version tag: build a signed release App Bundle using secrets, upload the mapping file and bundle, and publish to the Google Play internal testing track. Pull request (CI) checkout + JDK Gradle cache lint ยท test ยท assemble โœ“ status check on PR Tag v1.4.0 (CD) signed bundleRelease mapping + artifacts upload to Play internal track
CI on every change, CD on tags. Production promotion stays a deliberate human decision.

CI workflow for pull requests

.github/workflows/android-ci.ymlYAML
name: Android CI
on:
  pull_request:
  push:
    branches: [ main ]

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true          # stop outdated runs when new commits arrive

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 17          # match what your AGP version requires
      - uses: gradle/actions/setup-gradle@v4   # Gradle caching + build scans support
      - name: Lint, unit tests, debug build
        run: ./gradlew lintDebug testDebugUnitTest assembleDebug --stacktrace
      - name: Upload test reports
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: test-reports
          path: '**/build/reports/**'

Pin action versions you trust, and update them deliberately. Make the workflow a required status check in branch protection so failing PRs can't merge.

Signing in CI without leaking keys

Store the upload keystore as a base64-encoded secret, plus its passwords, in GitHub Actions secrets. With Play App Signing, Google holds the app signing key, and your CI only needs the upload key.

app/build.gradle.kts (signing from environment)Gradle KTS
android {
    signingConfigs {
        create("release") {
            val ksPath = System.getenv("UPLOAD_KEYSTORE_PATH")
            if (ksPath != null) {
                storeFile = file(ksPath)
                storePassword = System.getenv("UPLOAD_KEYSTORE_PASSWORD")
                keyAlias = System.getenv("UPLOAD_KEY_ALIAS")
                keyPassword = System.getenv("UPLOAD_KEY_PASSWORD")
            }
        }
    }
    buildTypes {
        release { signingConfig = signingConfigs.getByName("release") }
    }
}

CD workflow on tags

.github/workflows/android-release.ymlYAML
name: Android Release
on:
  push:
    tags: [ 'v*' ]

jobs:
  release:
    runs-on: ubuntu-latest
    environment: production        # optional: require approval for this environment
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: 17 }
      - uses: gradle/actions/setup-gradle@v4
      - name: Decode upload keystore
        run: echo "${{ secrets.UPLOAD_KEYSTORE_BASE64 }}" | base64 --decode > "$RUNNER_TEMP/upload.jks"
      - name: Build signed bundle
        env:
          UPLOAD_KEYSTORE_PATH: ${{ runner.temp }}/upload.jks
          UPLOAD_KEYSTORE_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_PASSWORD }}
          UPLOAD_KEY_ALIAS: ${{ secrets.UPLOAD_KEY_ALIAS }}
          UPLOAD_KEY_PASSWORD: ${{ secrets.UPLOAD_KEY_PASSWORD }}
        run: ./gradlew bundleRelease
      - uses: actions/upload-artifact@v4
        with:
          name: release-${{ github.ref_name }}
          path: |
            app/build/outputs/bundle/release/*.aab
            app/build/outputs/mapping/release/mapping.txt
      # Upload to Play (internal track) using the Play Developer API via your chosen tool,
      # e.g. a Gradle Play Publisher task or fastlane supply, authenticated with a
      # service account JSON stored as a secret.

Versioning

Version code from CIGradle KTS
android {
    defaultConfig {
        versionCode = System.getenv("GITHUB_RUN_NUMBER")?.toInt() ?: 1   // always increasing
        versionName = System.getenv("GITHUB_REF_NAME")?.removePrefix("v") ?: "dev"
    }
}

Keeping CI fast

  • Use Gradle caching (setup-gradle) and configuration cache where possible.
  • Run only affected modules' tests in large multi-module projects.
  • Run instrumented tests and benchmarks on a schedule or merge queue, rather than on every push, with Gradle Managed Devices or a device cloud.

Pipeline stages by team size

Solo developer

A CI workflow on PRs, with manual releases from CI artifacts.

Small team

CI plus automated internal-track uploads on tags, with manual promotion to production.

Large team

A merge queue, sharded tests, screenshot tests, nightly benchmarks and a release train with staged rollouts.

Security checklist

  • โ˜ Keystore and service account only in encrypted secrets; never in the repository
  • โ˜ Release workflow limited to protected tags or branches; environment approvals for production
  • โ˜ Workflows from forks can't access secrets (GitHub's default for pull_request)
  • โ˜ Minimal permissions for the Play service account (specific app, release manager role)

Frequently asked questions

Should CI build release or debug?

Build debug and run tests on every PR for speed. Also build the release variant regularly (at least on main) to catch R8 and signing issues early.

Fastlane or Gradle plugin for Play uploads?

Both work. Fastlane has a broad ecosystem, and Gradle-based publishing keeps everything in the build. Choose one and document it.

Can I run emulator tests on GitHub Actions?

Yes, with hardware-accelerated runners or Gradle Managed Devices, though they're slower and costlier than unit tests. Many teams run them less frequently.

Conclusion

Android CI/CD with GitHub Actions means lint, tests and builds on every PR, Gradle caching for speed, signing from encrypted secrets, and tag-based release bundles uploaded to a Play testing track, with human approval for production.

Next step: explore AI in Android apps, starting with ML Kit GenAI APIs Explained.

Official references

๐Ÿ“ฑ Apps built by Yunok Tech

Real Android apps on Google Play, free to download.

See all our apps โ†’

Ready to build real Android applications?

Performance work starts with understanding how Android apps are built and how they behave at runtime. Strengthen those fundamentals and the profiling tools start to make sense.

Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.

Explore the Android course and take your next step as an Android developer.

Share: LinkedIn X / Twitter WhatsApp