How do I set up CI for an Android app?
To build, lint, and test an Android app the same way locally and in CI, run the project’s Gradle wrapper with the same JDK and Android SDK in both places. Keep the Gradle caches between runs. Run Android Lint and the JVM unit tests on a variant that you choose. Build APKs and App Bundles, and keep the release keystore and its passwords out of the repository and the logs. The dagger.io/android Dagger module meets these requirements in one place. It runs ./gradlew in a pinned Android SDK container on your laptop and in Dagger Cloud Checks.
What a good Android CI setup needs
A good setup meets five requirements. Each one comes from the Android or Gradle docs:
- A pinned JDK and Android SDK with accepted licenses. Android Gradle plugin 8.0 and newer needs JDK 17 to run Gradle. On a CI server, you accept the SDK licenses with
sdkmanager --licenses. For scripts, the same page says to “choose a specific version instead to ensure stability.” - Persistent Gradle caches. Gradle keeps downloaded dependencies and wrapper distributions in the Gradle User Home. The official Gradle action for GitHub Actions saves that directory between jobs, so a build does not download everything again.
- Android Lint and JVM unit tests on a chosen variant. “Lint isn’t automatically run as part of your build,” and without a variant name, lint checks only the default variant. Local unit tests run on the JVM, not on a device.
- APK and AAB outputs.
assembleDebugbuilds an APK inmodule/build/outputs/apk/, and thebundle<Variant>task builds an Android App Bundle for the Play Console. - Release signing with the secrets kept out of the repository and the logs. Android’s docs say that passwords in the build file are “not a good security practice” and suggest that you read them from environment variables.
Meet the requirements by hand in a CI workflow
Most teams meet these requirements with setup steps in a CI workflow and a Gradle command. On GitHub Actions, android-actions/setup-android installs the SDK command-line tools and accepts the licenses, and its README pairs it with actions/setup-java for JDK 17. gradle/actions/setup-gradle caches the Gradle User Home:
jobs:
android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: '17'
- uses: android-actions/setup-android@v4
- uses: gradle/actions/setup-gradle@v6
- run: ./gradlew lintDebug testDebugUnitTest assembleDebug
This job meets the toolchain, cache, lint, test, and APK requirements. For a release, the build file reads the signing values from environment variables, as Android’s docs suggest. The keystore is a binary file. GitHub’s docs show how to store a small binary file as a Base64 secret and decode it to a file on the runner. The job then runs ./gradlew bundleRelease. On a laptop, developers install the JDK and SDK through Android Studio or sdkmanager, and run the same Gradle tasks.
Android setup lives only in the workflow file
The hand-built version keeps each requirement in a different place, and these places must stay in step:
- Toolchain. The JDK version is in the workflow file. Each laptop has its own JDK and SDK packages, and nothing checks that they match CI.
- Caches. The cache setup is a GitHub Actions step. A laptop or another CI system does not use it.
- Variants. The variant is part of each task name, such as
lintDebugandtestDebugUnitTest. When the variant changes, every task name in the workflow changes. - Debugging. The setup steps exist only in the workflow. A failure that occurs only in CI often needs a push for each attempt.
- Signing. The decoded keystore stays on the runner’s disk as a file until the job ends.
Meet the requirements with the Dagger Android module
The Android module is a Dagger module, a package of code that the Dagger engine loads and runs (glossary). It runs the project’s Gradle wrapper in an Android SDK container. You do not need a JDK, Gradle, or the Android SDK on the host.
Install the module from the project root:
dagger module install dagger.io/android
Created workspace config in /work
Installed module "android" in /work/dagger.toml
The command creates dagger.toml, the workspace configuration file. It also creates dagger.lock, which pins the module version. Commit both files. Then point the module at the directory that holds gradlew:
dagger module settings android source .
dagger.toml now holds the module and its setting:
# Dagger workspace configuration
# Install modules with: dagger module install <module>
# Example:
# dagger module install dagger.io/linux/wolfi
[modules]
[modules.android]
source = "dagger.io/android"
[modules.android.settings]
source = "."
The defaults expect an application module named app in app/. A standard project needs no module code of its own. List the checks, the functions that pass or fail the project (Checking your code):
dagger check -l
MODULE ANDROID-TEST-CLASS CHECK DESCRIPTION
android build Assemble checkVariant and, if configured, verify exact entries in every APK.
android lint Run Android Lint for checkVariant. Reports remain available through run.
android * test Run this class using Gradle's test filter, reusing compiled tests.
A pinned JDK and Android SDK with accepted licenses
The module builds in the Cirrus Labs Android SDK image, ghcr.io/cirruslabs/android-sdk:36 by default. The module’s README says that the image provides a JDK and the Android SDK with accepted licenses. The compileSdk setting selects the image tag, and baseImageAddress replaces the image. After the first run, dagger.lock also pins the image by its digest:
["","oci-sha",["ghcr.io/cirruslabs/android-sdk:36"],"sha256:f9b3ea9ed2b5fc9522adae82c7b4622ab7aa54207ef532c8e615a347dca08f31"]
Every laptop and every CI run uses that digest until you run dagger lock update. The container is linux/amd64 by default, which Google’s Linux build tools support. On an ARM laptop, such as an Apple silicon Mac, it runs under emulation.
Persistent Gradle caches
The module mounts Dagger cache volumes on the Gradle caches and wrapper directories and sets GRADLE_USER_HOME=/gradle. A cache volume is a directory whose contents persist across runs (API reference). The engine also caches the result of each operation, so a run with no changes does not run Gradle again. In this guide’s sample app, the first dagger check took 124 seconds, including the SDK image download. A rerun with no changes took less than one second. A rerun after an edit to one Java file took 72 seconds. These numbers come from one run of one small app on one machine.
Android Lint and JVM unit tests on a chosen variant
Run the three checks:
dagger check
CHECKS ✔ 3 passed
✔ dag://?check=android/build 13.3s OK
✔ dag://?check=android/lint 39.3s OK
✔ dag://?check=android/tests/test 51.5s OK
The build check runs assemble<Variant>, lint runs lint<Variant>, and test runs test<Variant>UnitTest. The checkVariant setting selects the variant for all three, and its default is debug. To check the release variant, change one value:
dagger module settings android checkVariant release
With that setting, the test check runs :app:testReleaseUnitTest. A flavored variant such as demoDebug works the same way. To run one check, select it by name:
dagger check --module android --check lint
Run one or several test classes
The test classes are a collection, a set of items with keys that the module finds at run time (Checking your code). The module finds the classes with Gradle’s test dry-run mode, so the project needs Gradle 8.3 or newer. List the classes:
dagger list android-test-classes -a
MODULE ANDROID-TEST-CLASS DESCRIPTION
android com.example.notes.ArithmeticTest Select one test class.
android com.example.notes.FormatTest Select one test class.
Run one class:
dagger check --android-test-class com.example.notes.FormatTest
CHECKS ✔ 1 passed
✔ dag://?check=test&android-test-class=com.example.notes.FormatTest 8.8s OK
Repeat the flag to run several classes in one Gradle invocation:
dagger check \
--android-test-class com.example.notes.ArithmeticTest \
--android-test-class com.example.notes.FormatTest
The test check runs JVM unit tests. Instrumented tests need an emulator or a device, and the caller supplies that setup. The baseCtr setting takes a custom SDK container, and the run function runs any Gradle task in it.
APK and AAB outputs
The assemble and bundle functions return Gradle’s output directory, with its file names and variant subdirectories. assemble builds debug by default, and bundle builds release. Export both:
dagger api call android assemble export --path ./dist/apk
dagger api call android bundle export --path ./dist/bundle
The sample app produced these files:
dist/apk/debug/app-debug.apk
dist/apk/debug/output-metadata.json
dist/bundle/release/app-release.aab
Add --variant demoDebug to either function to build another variant. These are functions, not checks, so dagger check does not export files.
Release signing with Dagger secrets
The signed-bundle function builds a signed App Bundle and verifies each AAB against the keystore and alias. It is a function that you call on purpose, not a check, so dagger check and Cloud Checks never need the signing credentials. The release build type must read four environment variables. Add this configuration to app/build.gradle:
android {
signingConfigs {
release {
if (System.getenv('ANDROID_KEYSTORE_FILE')) {
storeFile file(System.getenv('ANDROID_KEYSTORE_FILE'))
storePassword System.getenv('ANDROID_KEYSTORE_PASSWORD')
keyAlias System.getenv('ANDROID_KEY_ALIAS')
keyPassword System.getenv('ANDROID_KEY_PASSWORD')
}
}
}
buildTypes {
release {
if (System.getenv('ANDROID_KEYSTORE_FILE')) {
signingConfig signingConfigs.release
}
}
}
}
The keystore and both passwords are Dagger secrets. Dagger reads each secret from a provider, such as a file, a command, or 1Password. It redacts secret values from the logs and does not write them into container layers. Keep the files outside the repository:
dagger api call android signed-bundle \
--keystore file:~/.android-signing/upload-keystore.jks \
--keystore-password file:~/.android-signing/keystore-password \
--key-alias upload \
--key-password file:~/.android-signing/key-password \
export --path ./dist/signed-bundle
The sample app produced dist/signed-bundle/release/app-release.aab. The module mounts the keystore as a secret file and gives the passwords to Gradle as secret environment variables. It runs Gradle with --rerun-tasks --no-build-cache, so an earlier signed bundle cannot take the place of a new signature. With a wrong password, Gradle stops at :app:signReleaseBundle with this message:
> Failed to read key upload from store "/run/signing/keystore": keystore password was incorrect
Nested projects such as React Native or Capacitor
In a React Native or Capacitor app, the Gradle project is in a subdirectory and needs files from the directories next to it. Keep source at the repository root, and set projectPath to the Gradle root:
dagger module settings android projectPath android
If the application module is not app in app/, also set gradleModule and modulePath.
Run the same checks locally and in Cloud Checks
dagger check runs the same checks in the same container on a laptop and in CI. For CI, use Cloud Checks. A push or a pull request starts them, and they run your checks on Dagger’s Cloud Engines with no CI workflow file. Enable them from the project root of a GitHub repository:
dagger cloud checks on
Commit dagger.toml and dagger.lock, then push. If a check fails in Cloud Checks, run dagger check on your laptop, fix the failure, and push again. When you make a release, run signed-bundle yourself.
Bottom line: Run the Gradle wrapper with a pinned JDK and Android SDK, keep the Gradle caches, run lint and unit tests on one chosen variant, and keep the signing secrets out of the repository. A CI workflow can do this with setup actions. The dagger.io/android module does it in one container that dagger check runs the same way on a laptop and in Cloud Checks, and it signs bundles only when you call signed-bundle.
Related guides
- How do I run GitHub Actions locally?: makes CI steps portable so that a laptop runs what CI runs.
- How do I run Maven builds the same way locally and in CI?: the Maven module, for JVM projects that build with Maven.
- How do I test Swift packages on Linux in CI?: the same approach for Swift packages on Linux, with the Swift module.