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:

  • concurrency groups 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.

  • permissions is set explicitly for the token GitHub injects as secrets.GITHUB_TOKEN, rather than left at the repository’s default: checks: write and pull-requests: write let the test-result action post its check run and comment, and contents: write is needed because the last step pushes the Dokka output to the gh-pages branch — with contents: read that push is rejected with HTTP 403 on every run on main (glutils’s own main.yml also grants contents: write). A stricter layout moves the deploy into a separate job that alone gets contents: write, leaving the build and test job at contents: read.

  • actions/checkout@v7 clones the repository; fetch-depth: 0 fetches 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 before reactivecircus/ android-emulator-runner can boot a hardware-accelerated (rather than software-emulated, dramatically slower) emulator.

  • actions/setup-java@v6 installs a JDK — Temurin (Eclipse Adoptium) distribution, version 17, matching the JVM target used throughout this section — and configures it on PATH/JAVA_HOME for every later step.

  • gradle/actions/setup-gradle@v6 replaces manually caching ~/.gradle/caches and ~/.gradle/wrapper with actions/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 a pull_request event 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 ./gradlew invocations 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@v2 boots an Android Virtual Device matching api-level/target/ arch, waits for it to finish booting, runs script against it, and tears it down afterwards — handling the emulator lifecycle that would otherwise need several adb/emulator invocations 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: false lets the other matrix leg keep running (and reporting) even if one leg fails.

  • EnricoMi/publish-unit-test-result-action parses 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 .exec file 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. With enableUnitTestCoverage = true on the debug build type, AGP writes the unit-test execution data to build/outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec, which is the path the step reads. The jacococli.jar it runs does not exist on a fresh runner: glutils’s main.yml can call jacoco-0.8.13/lib/jacococli.jar directly only because that repository keeps an unpacked JaCoCo 0.8.13 distribution (a jacoco-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 .exec or 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:createDebugUnitTestCoverageReport makes AGP write the HTML and XML report under app/build/reports/coverage/test/debug/ itself; whichever path produces the XML must match the sonar.coverage.jacoco.xmlReportPaths property in the module’s sonar {} block. See Android Developers — View code coverage reports.

  • SonarCloud (./gradlew :app:sonar, or :lib:sonar for a library module) runs static analysis and uploads the JaCoCo XML coverage report, using SONAR_TOKEN from the organization’s SonarCloud project and the automatically injected GITHUB_TOKEN to annotate pull requests with new issues.

  • Dokka to GitHub Pages generates Kotlin API documentation and deploys the static HTML output to the gh-pages branch with crazy-max/ghaction-github-pages, gated to the main branch so pull-request runs never publish a half-finished doc site.

  • Steps guarded by if: matrix.api-level == 35 run 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_TOKEN the minimum permissions: it needs (as in the CI workflow above) rather than leaving the repository-wide default, which is often broader.

  • Never echo or run: 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_request from 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

flowchart LR PR["Pull request"] --> CI["CI workflow\n(lint, tests, coverage, Sonar)"] CI --> Merge["Merge to main"] Merge --> Docs["Dokka -> GitHub Pages"] Merge --> Release["GitHub release / tag"] Release --> AppCD["Release-app workflow"] Release --> LibCD["Publish workflow"] AppCD --> Play["Google Play\n(internal -> production)"] LibCD --> Maven["Maven Central"]

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.