CI/CD with GitHub Actions
|
This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
Every other page in this section shows how to build, test and sign an Android app or library; this page covers the automation that runs those same steps on every push, pull request and release, using GitHub Actions as the CI/CD engine. It assumes the Gradle project layout from Project Structure, Gradle and Build Variants, the testing vocabulary from Unit Testing with JUnit, Mockito, MockK and Robolectric and Instrumented and UI Testing, the signing and bundle concepts from Building, Signing and Preparing a Release, the Play Console concepts from Publishing on Google Play and the Maven Central publishing setup from Building and Publishing Android Libraries to Maven Central; it does not repeat those, only how to trigger and automate them. It does not cover Git or GitHub itself (branches, pull requests, tags) — see Git & GitHub for that.
The worked examples are drawn from a real, currently running pipeline: com.irurueta:irurueta-android-glutils’s
own `.github/workflows/main.yml (CI) and .github/workflows/publish.yml (library release to Maven Central), at
https://github.com/albertoirurueta/irurueta-android-glutils/tree/main/.github/workflows. The YAML shown on this
page modernizes their action versions to the currently verified ones — actions/checkout v7,
actions/setup-java v6, gradle/actions/setup-gradle v6, reactivecircus/android-emulator-runner
v2, actions/upload-artifact v7, r0adkll/upload-google-play v1 — and adds matrix builds and
concurrency cancellation as improvements over the original files. The app-facing examples apply the same
mechanics to the TrailMate app (:app module) alongside the library mechanics on the :lib-style module used
by glutils.
The CI workflow: build, test and report on every change
A CI workflow’s job is to answer, automatically and consistently, "does this change still build, pass lint and
pass tests" — before a human reviews the pull request and before it merges. The workflow below runs on every push
to main, every pull request targeting main, and manually via workflow_dispatch; it checks out the code,
enables hardware-accelerated emulation, sets up the JDK, restores Gradle’s caches, runs lint/unit/instrumented
tests across a small matrix, publishes test results and coverage, scans the code with SonarCloud, generates and
deploys API documentation, and uploads build artifacts — cancelling any older run still in flight for the same
branch or pull request.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
workflow_dispatch:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
checks: write
pull-requests: write
contents: write
jobs:
build:
strategy:
fail-fast: false
matrix:
api-level: [ 30, 35 ]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Set up JDK 17
uses: actions/setup-java@v6
with:
distribution: 'temurin'
java-version: '17'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
- name: Lint, unit tests and assemble
run: ./gradlew clean lint test assembleDebug
- name: Instrumented tests (API ${{ matrix.api-level }})
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ matrix.api-level }}
target: google_apis
arch: x86_64
script: ./gradlew connectedDebugAndroidTest
- name: Publish test results
uses: EnricoMi/publish-unit-test-result-action@v2
if: (!cancelled())
with:
files: |
app/build/test-results/**/*.xml
app/build/outputs/androidTest-results/**/*.xml
- name: Download JaCoCo CLI
run: |
curl -sSfL -o jacoco.zip \
https://repo1.maven.org/maven2/org/jacoco/jacoco/0.8.13/jacoco-0.8.13.zip
unzip -q jacoco.zip -d jacoco-0.8.13
- name: Convert unit test coverage results
run: |
mkdir -p app/build/reports/coverage/test
java -jar jacoco-0.8.13/lib/jacococli.jar report \
app/build/outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec \
--classfiles app/build/tmp/kotlin-classes/debug --sourcefiles app/src/main/kotlin \
--html app/build/reports/coverage/test \
--xml app/build/reports/coverage/test/report.xml
- name: Upload build outputs
uses: actions/upload-artifact@v7
if: matrix.api-level == 35
with:
name: apk-debug
path: app/build/outputs/apk/debug/*.apk
- name: Send to SonarCloud
if: matrix.api-level == 35
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: ./gradlew :app:sonar
- name: Generate API docs
if: matrix.api-level == 35 && github.ref == 'refs/heads/main'
run: ./gradlew :app:dokkaGenerate
- name: Deploy docs to GitHub Pages
if: matrix.api-level == 35 && github.ref == 'refs/heads/main' && success()
uses: crazy-max/ghaction-github-pages@v2
with:
target_branch: gh-pages
build_dir: app/build/dokka/html
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Section by section:
-
concurrencygroups runs by workflow name and Git ref (branch or pull-request ref) and cancels any run already in progress for that same group when a new commit arrives. Without it, pushing three commits in a row to the same pull request runs three full CI passes back to back; with it, only the last one finishes, saving runner minutes and giving faster feedback. -
permissionsis set explicitly for the token GitHub injects assecrets.GITHUB_TOKEN, rather than left at the repository’s default:checks: writeandpull-requests: writelet the test-result action post its check run and comment, andcontents: writeis needed because the last step pushes the Dokka output to thegh-pagesbranch — withcontents: readthat push is rejected with HTTP 403 on every run onmain(glutils’s ownmain.ymlalso grantscontents: write). A stricter layout moves the deploy into a separate job that alone getscontents: write, leaving the build and test job atcontents: read. -
actions/checkout@v7clones the repository;fetch-depth: 0fetches full history, which some tools (changelog generation,git describe-based versioning) need but a plain build does not — omit it for a slightly faster checkout when nothing needs history. -
Enable KVM installs a udev rule that grants the GitHub-hosted runner’s default user access to
/dev/kvm. GitHub-hosted Linux runners have had the hardware itself available for accelerated Android emulation since April 2024, but the device node’s permissions still need this one-time rule beforereactivecircus/ android-emulator-runnercan boot a hardware-accelerated (rather than software-emulated, dramatically slower) emulator. -
actions/setup-java@v6installs a JDK — Temurin (Eclipse Adoptium) distribution, version 17, matching the JVM target used throughout this section — and configures it onPATH/JAVA_HOMEfor every later step. -
gradle/actions/setup-gradle@v6replaces manually caching~/.gradle/cachesand~/.gradle/wrapperwithactions/cache: it restores and saves the Gradle dependency cache, build cache and wrapper distribution keyed by the files that determine them (*.gradle.kts,gradle/wrapper/gradle-wrapper.properties, version catalogs), and on apull_requestevent skips saving a new cache entry (pull requests from forks cannot write to the base repository’s cache) while still reading from the closest match. It measurably shortens./gradlewinvocations after the first run on a branch. -
Lint, unit tests and assemble runs the fast, JVM-only checks first (
lint,test,assembleDebug) so a broken build or a failing unit test fails the job before the much slower emulator boot is attempted. -
reactivecircus/android-emulator-runner@v2boots an Android Virtual Device matchingapi-level/target/arch, waits for it to finish booting, runsscriptagainst it, and tears it down afterwards — handling the emulator lifecycle that would otherwise need severaladb/emulatorinvocations hand-rolled in the workflow. The matrix here runs the instrumented suite on both API 30 and API 35, catching platform-version regressions that a single emulator image would miss;fail-fast: falselets the other matrix leg keep running (and reporting) even if one leg fails. -
EnricoMi/publish-unit-test-result-actionparses the JUnit XML produced by both the unit-test and instrumented-test runs and posts a check run / pull-request comment summarizing pass/fail counts, so a reviewer sees test results without opening the raw logs;if: (!cancelled())still publishes results for a job that failed a test (as opposed to one that was cancelled). -
JaCoCo coverage conversion turns JaCoCo’s binary
.execfile into HTML (for humans, uploaded as an artifact or served from Pages) and XML (machine-readable, the format SonarCloud’s Gradle plugin and most coverage badges expect) against the Kotlin class files and sources Gradle already compiled. WithenableUnitTestCoverage = trueon thedebugbuild type, AGP writes the unit-test execution data tobuild/outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec, which is the path the step reads. Thejacococli.jarit runs does not exist on a fresh runner: glutils’smain.ymlcan calljacoco-0.8.13/lib/jacococli.jardirectly only because that repository keeps an unpacked JaCoCo 0.8.13 distribution (ajacoco-0.8.13/directory) committed at its root, so the workflow above downloads and unpacks the same distribution from Maven Central instead. Neither command ends in|| true(glutils’s does): a missing.execor JAR should fail the job, not silently hand SonarCloud an empty coverage report. When a hand-rolled conversion isn’t needed at all,./gradlew :app:createDebugUnitTestCoverageReportmakes AGP write the HTML and XML report underapp/build/reports/coverage/test/debug/itself; whichever path produces the XML must match thesonar.coverage.jacoco.xmlReportPathsproperty in the module’ssonar {}block. See Android Developers — View code coverage reports. -
SonarCloud (
./gradlew :app:sonar, or:lib:sonarfor a library module) runs static analysis and uploads the JaCoCo XML coverage report, usingSONAR_TOKENfrom the organization’s SonarCloud project and the automatically injectedGITHUB_TOKENto annotate pull requests with new issues. -
Dokka to GitHub Pages generates Kotlin API documentation and deploys the static HTML output to the
gh-pagesbranch withcrazy-max/ghaction-github-pages, gated to themainbranch so pull-request runs never publish a half-finished doc site. -
Steps guarded by
if: matrix.api-level == 35run once per workflow invocation rather than once per matrix leg — there is no reason to send duplicate SonarCloud reports or Pages deployments from every matrix combination.
Gradle’s own caching mechanism (org.gradle.caching=true in gradle.properties, discussed in
Project Structure, Gradle and Build Variants) complements
`setup-gradle’s cache restore: one caches task outputs across the whole team and every CI run, the other caches
the dependency/build-cache directories for this workflow’s own runners.
The app release (CD) workflow: signing and shipping to Google Play
Releasing the app itself is a separate workflow, triggered by publishing a GitHub release or pushing a tag rather than by every push, since it performs an irreversible action (uploading a new version to a Play Console track). It decodes a keystore from a repository secret, builds a signed release bundle, and uploads it to Google Play.
# .github/workflows/release-app.yml
name: Release app
on:
release:
types: [ released ]
concurrency:
group: release-app-${{ github.ref }}
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up JDK 17
uses: actions/setup-java@v6
with:
distribution: 'temurin'
java-version: '17'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
- name: Decode signing keystore
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > "$RUNNER_TEMP/release.keystore"
- name: Build signed release bundle
env:
KEYSTORE_PATH: ${{ runner.temp }}/release.keystore
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: ./gradlew :app:bundleRelease
- name: Upload to Google Play
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
packageName: com.example.trailmate
releaseFiles: app/build/outputs/bundle/release/app-release.aab
track: internal
status: completed
inAppUpdatePriority: 2
- name: Clean up keystore
if: always()
run: rm -f "$RUNNER_TEMP/release.keystore"
Never place base64-encoded key material, the raw keystore, passwords or a service-account JSON file in the
workflow itself or in a committed file — every one of them above is read from a repository or environment
secret (secrets.KEYSTORE_BASE64, secrets.KEYSTORE_PASSWORD, secrets.KEY_ALIAS, secrets.KEY_PASSWORD,
secrets.PLAY_SERVICE_ACCOUNT_JSON) and referenced only by name. The keystore itself is produced by
base64-encoding the .jks/.keystore file once, locally, and pasting that text into a GitHub secret through the
repository’s Settings UI or gh secret set — the workflow only ever decodes it back to a file in the
ephemeral runner’s temporary directory (runner.temp, outside the checkout), which the final step removes.
app/build.gradle.kts’s `signingConfigs block falls back to exactly these four environment variables — KEYSTORE_PATH (an absolute path here, resolved with rootProject.file(…)), KEYSTORE_PASSWORD, KEY_ALIAS
and KEY_PASSWORD — whenever keystore.properties is absent, as it is on a CI runner; see
Building, Signing and Preparing a Release. If the names
on the two sides ever drift apart, bundleRelease fails on CI with an incomplete signing configuration.
r0adkll/upload-google-play@v1 wraps the Play Developer API: serviceAccountJsonPlainText (or
serviceAccountJson, a path to a file) authenticates as a service account with API access to the Play Console
(configured once in Play Console → Setup → API access), packageName identifies the app, releaseFiles points
at the signed .aab and track selects which Play track receives it (internal, alpha, beta or
production). Promoting a release from one track to the next — for example internal to production after
manual QA — is a second run of the same action with a different track (and, optionally, userFraction for a
staged rollout), or the same manual promotion done from the Play Console UI; inAppUpdatePriority sets the
priority value the Play In-App Update API reports to installed clients, letting the app decide whether to nudge
users toward an update. The Gradle Play Publisher plugin (com.github.triplet.play) is a Gradle-native
alternative to this action — it drives the same Play Developer API through Gradle tasks
(./gradlew publishBundle, promoteArtifact) instead of a separate workflow step, which some teams prefer when
they want the publish step invokable identically from a developer’s machine and from CI.
The library publish workflow: releasing to Maven Central
Publishing a library, rather than an app, uses release-triggered publishing to the Central Publisher Portal
covered in Building and Publishing Android Libraries
to Maven Central. The workflow below is the real irurueta-android-glutils publish.yml, modernized to
checkout@v7/setup-java@v6:
# .github/workflows/publish.yml
name: Publish
on:
release:
types: [ released ]
jobs:
publish:
name: Release build and publish
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up JDK 17
uses: actions/setup-java@v6
with:
distribution: 'temurin'
java-version: '17'
- name: Release build
run: ./gradlew :lib:assembleRelease
- name: Publish to Maven Central
run: ./gradlew lib:publishAndReleaseToMavenCentral --no-configuration-cache
env:
ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_MEMORY_KEY }}
ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNING_MEMORY_KEY_ID }}
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_IN_MEMORY_KEY_PASSWORD }}
on.release.types: [ released ] fires only when a GitHub release is published (not on a draft or pre-release),
so publishing a new library version is an explicit, human-triggered action: cut a release on GitHub and this
workflow uploads it to Maven Central. ./gradlew :lib:assembleRelease builds the release AAR; ./gradlew
lib:publishAndReleaseToMavenCentral --no-configuration-cache (the vanniktech com.vanniktech.maven.publish
plugin task discussed in the Maven Central page) both uploads the artifacts and triggers their automatic release
from the Central Publisher Portal in one step — --no-configuration-cache works around a known incompatibility
between that plugin’s signing tasks and Gradle’s configuration cache. The five ORG_GRADLE_PROJECT_* environment
variables are Gradle’s convention for injecting project properties from the environment: ORG_GRADLE_PROJECT_foo
becomes the Gradle property foo, so ORG_GRADLE_PROJECT_mavenCentralUsername supplies the plugin’s
mavenCentralUsername property without that value ever appearing in a gradle.properties file that could be
committed by mistake. Each one is read here only from a matching repository secret
(secrets.MAVEN_CENTRAL_USERNAME, secrets.MAVEN_CENTRAL_PASSWORD, secrets.SIGNING_MEMORY_KEY,
secrets.SIGNING_MEMORY_KEY_ID, secrets.SIGNING_IN_MEMORY_KEY_PASSWORD) — never inlined.
Dependabot, secrets hygiene and further CI options
Dependabot opens automated pull requests when a dependency has a newer version available. A minimal configuration covering both Gradle dependencies and the GitHub Actions used above:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "gradle"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
The gradle ecosystem entry watches build.gradle.kts/version-catalog declarations and opens a pull request per
outdated dependency (or a grouped one, with groups:); the github-actions entry does the same for the uses:
references in every workflow file under .github/workflows/, which is how action versions such as the ones
listed at the top of this page stay current without a manual audit.
Secrets hygiene matters more on CI than almost anywhere else in the pipeline, because a workflow’s secrets
are the credentials capable of publishing a release, signing an app, or pushing to gh-pages:
-
Store secrets at the narrowest scope that needs them — repository secrets for values one repository uses, environment secrets (GitHub Environments, optionally gated behind required reviewers) for values that should only be readable by a deployment job, organization secrets only for values genuinely shared across many repositories.
-
Grant each workflow’s
GITHUB_TOKENthe minimumpermissions:it needs (as in the CI workflow above) rather than leaving the repository-wide default, which is often broader. -
Never
echoorrun:a secret in a way that prints its value to the log — GitHub Actions redacts a secret’s exact string once it is used through${{ secrets.NAME }}, but a transformed version of it (base64-decoded, concatenated, substring) is not automatically redacted, so avoid printing intermediate values derived from secrets. -
Restrict which workflows and branches can access a sensitive secret (environment protection rules, or simply not passing it to workflows triggered by
pull_requestfrom forks, which never receive repository secrets by default). -
Rotate a secret immediately if it is ever exposed — committed by mistake, printed in a log, or shared outside the team — since a leaked signing key, Play service-account key or Maven Central token can be used to publish under the project’s identity. See Git & GitHub for removing an accidentally committed secret from history.
Gradle Managed Devices are an alternative to reactivecircus/android-emulator-runner for instrumented tests
in CI: instead of the action booting and tearing down an emulator, a managedDevices block in build.gradle.kts
declares one or more device definitions and Gradle itself provisions, boots, runs connectedCheck against, and
tears down the corresponding virtual devices as part of the Gradle task graph, with results cached like any other
Gradle output. It trades the action’s simplicity for devices that behave identically whether run locally or in
CI and integrate with Gradle’s own caching, at the cost of managing device definitions in the build script rather
than the workflow YAML.
Firebase App Distribution and Firebase Test Lab are complementary Firebase-hosted CI services worth
knowing about even without a deep dive here: App Distribution pushes a build to a list of testers' devices
outside the Play Console’s testing tracks (useful for QA before a Play release even exists), and Test Lab — as
already introduced in Instrumented and UI Testing — runs the
instrumented test APK against a much larger matrix of real and virtual devices than a CI runner’s own emulators
can practically cover. Both are typically wired into a workflow as an additional job or step using their
respective GitHub Actions, gated the same way the release jobs above are gated (a tag, a release event, or a
manual workflow_dispatch).
The end-to-end pipeline
A pull request runs the CI workflow; once it merges to main, API documentation is republished and the change is
included in the next release. Publishing a GitHub release is the fork in the pipeline: an app release workflow
signs and uploads a bundle to Google Play (promoted from internal toward production over time), while a
library publish workflow builds and releases the artifact to Maven Central — the two downstream targets never
share a workflow because they sign, package and publish to entirely different destinations.