On this page
"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
CI workflow for pull requests
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.
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
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
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.
Walzi4K wallpapers for lock and home screenGet it โธ
NotiVaultNotification history: recover dismissed notificationsGet it โธ
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.