AppForShare

Build and sign an Android app on GitHub Actions and upload it to Google Play

· 10 min read

Build and sign an Android app on GitHub Actions and upload it to Google Play

Building a release Android app on your laptop works until the person with the keystore is on holiday. Moving the build to GitHub Actions fixes that, but it brings its own questions: where the keystore lives, which file goes to Google Play, how the version code goes up on every run, and how the bundle gets onto a track without anyone opening Play Console. This guide walks through a setup that answers each of them, then the failures you are most likely to see.

The runner and Java

Android builds run fine on GitHub's Linux runners. ubuntu-24.04 (also what ubuntu-latest points to) ships with the Android SDK command line tools, recent platforms and build tools, and several Java versions. Pin the image name rather than ubuntu-latest so a change of default image does not surprise you in the middle of a release week. In January 2026 GitHub removed SDK platforms and build tools older than 34 from the images, so a project that targets an old SDK now downloads it during the build; Gradle does that by itself as long as the licenses are accepted, which they are on the runner.

Set Java explicitly with actions/setup-java. Android Gradle plugin 8.x needs Java 17 at least, and Java 21 works with recent plugin versions. Leaving it to the runner default means your build changes when GitHub changes the default.

Gradle cache

A cold Gradle build downloads the wrapper distribution, every dependency and the build cache from scratch. gradle/actions/setup-gradle saves and restores the Gradle user home between runs, which usually cuts minutes off each build. It only writes the cache from the default branch by default, so feature branches read it but do not fill it with their own entries. If you use it, do not also turn on the cache: gradle option of setup-java, or you cache the same files twice.

Signing with an upload keystore from secrets

Since Play App Signing became the default, the key on your side is the upload key. Google re-signs what users download with the app signing key it holds. That matters for CI: if the upload key leaks, you can ask Google to reset it, which is not possible with an app signing key you manage yourself. The guide to Android signing, APK vs AAB and sideloading covers the difference in detail.

GitHub secrets hold text, so the keystore file goes in as base64. On a Linux machine base64 -w0 upload.jks prints it on one line; on macOS use base64 -i upload.jks. Store four secrets: the encoded keystore, the store password, the key alias and the key password. For a PKCS12 keystore, which is what keytool makes by default now, the key password is the same as the store password.

You do not need to change build.gradle to read them. The Android Gradle plugin accepts the same signing properties Android Studio passes when you use Build, Generate Signed Bundle: android.injected.signing.store.file, android.injected.signing.store.password, android.injected.signing.key.alias and android.injected.signing.key.password. Given on the command line with -P, they sign the release variant with that key, and the Gradle files in the repository stay free of passwords and file paths.

name: Android release
on:
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-java@v5
        with:
          distribution: temurin
          java-version: 17
      - uses: gradle/actions/setup-gradle@v5

      - name: Restore upload keystore
        env:
          KEYSTORE_B64: ${{ secrets.UPLOAD_KEYSTORE_B64 }}
        run: printf '%s' "$KEYSTORE_B64" | base64 --decode > "$RUNNER_TEMP/upload.jks"

      - name: Build signed bundle
        env:
          STORE_PASSWORD: ${{ secrets.UPLOAD_STORE_PASSWORD }}
          KEY_ALIAS: ${{ secrets.UPLOAD_KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.UPLOAD_KEY_PASSWORD }}
        run: |
          ./gradlew :app:bundleRelease \
            -Pandroid.injected.signing.store.file="$RUNNER_TEMP/upload.jks" \
            -Pandroid.injected.signing.store.password="$STORE_PASSWORD" \
            -Pandroid.injected.signing.key.alias="$KEY_ALIAS" \
            -Pandroid.injected.signing.key.password="$KEY_PASSWORD"

      - name: Remove keystore
        if: always()
        run: rm -f "$RUNNER_TEMP/upload.jks"

Two details in that file are deliberate. The secrets reach the shell through environment variables instead of being written inline, so they are never part of the script GitHub prints, and the keystore lands in $RUNNER_TEMP, outside the checkout, so no later step can commit or upload it by mistake. GitHub hosted runners are thrown away after the job, but the clean up step still costs nothing.

APK or AAB

Use bundleRelease when the file goes to Google Play. New apps have had to publish bundles since 2021, and Play builds device specific APKs from the bundle. Use assembleRelease when the file goes to testers outside Play: an .aab does not install on a phone. Many teams build both in the same workflow, an APK for the QA round and a bundle for the Play track, signed with the same upload key so the version codes and signatures line up. With product flavors the task names include the flavor, for example bundleProdRelease.

Version code strategies

Google Play refuses any upload whose versionCode it has already seen for that app, on any track, even one that was never rolled out. The code must be a whole number up to 2,100,000,000. There are three common ways to keep it moving:

  • From the project. The code lives in build.gradle and someone bumps it in a commit before each release. Simple and easy to trace, but every forgotten bump is a failed upload.
  • From the run number. github.run_number goes up by one for every run of that workflow file. It never repeats within the workflow, but it starts again at 1 if you rename the file or create a new workflow, and it starts below the codes an existing app already used. Add a fixed offset when you switch, for example 1000 plus the run number.
  • From the time. A timestamp such as yymmddHH grows on its own, but leaves little room before the ceiling and gives two runs in the same hour the same code.

Whatever you choose, set it for the build rather than editing a file on the runner. The plugin's variant API (androidComponents.onVariants) can set versionCode and versionName on each output from an init script, which leaves the project untouched. Keep a way to pass a number by hand as a workflow input, because the day you need to skip past a code that was burned by a failed release always comes.

Uploading to a Play track

The Google Play Developer API works with edits. You open an edit, which copies the app's current state, upload the bundle into it, set a release on a track that lists the new version code, then commit the edit. Nothing changes in Play until the commit, and a commit validates the whole edit at once. The tracks are internal, alpha (closed testing), beta (open testing) and production, plus form factor tracks such as wear:production.

Calls are made by a service account that has been invited in Play Console with release permissions on the app. Most do it yourself setups store that service account's JSON key as another GitHub secret and use a community action or Fastlane's supply to drive the edit. That works, but it means a key that can release your app now sits in every repository that ships it.

Two behaviors of edits are worth knowing. Each account can have only one open edit per app, and committing an edit or changing the app in Play Console invalidates every other open edit. So two workflows releasing the same app at the same moment will fail one of them, and so will a colleague saving in Play Console halfway through a run. Keep one release workflow per app, with a concurrency group so runs queue instead of racing.

Why production should stay a draft

A release on a track has a status: draft, inProgress (a staged rollout to a fraction of users), halted or completed. It is tempting to have CI send a completed release to production. Resist it. Internal and closed testing are cheap to get wrong; production is not, and rolling back an Android release means shipping a new, higher version code, because users who installed the bad one keep it. A draft puts a person between the pipeline and every user: they read the release notes, check the pre-launch report and pick the rollout percentage in Play Console. The pipeline still saves all the slow work.

An app that has never been published only accepts draft releases through the API, whatever track you name, so the first production release goes through Play Console in any case.

Common failures

Version code has already been used

Play says the bundle's version code was already used. That includes codes of uploads that never left a draft or a failed commit. Pick a higher number and run again; there is no way to reuse a code.

The runner runs out of disk

React Native builds compile native code for several CPU architectures and keep a lot in node_modules and Gradle's caches. On a standard Linux runner that can fill the free space and fail with "No space left on device". Delete large preinstalled tools the build does not use, such as .NET, GHC, Swift and the CodeQL bundle, at the start of the job, and build only the architectures you ship.

Keystore password mismatch

"Keystore was tampered with, or password was incorrect" means the store password does not open the file, often because the base64 secret was cut or picked up a newline. "Cannot recover key" means the key password is wrong. Decode the secret on your own machine and open it with keytool -list to rule out the file. If Play then says the bundle is signed with the wrong key, the build used a different keystore than the upload key Play has on file.

Package not found in the Play account

The package name in the bundle has to belong to an app that already exists in that Play developer account, and the service account needs access to it. The API cannot create the app. Create it in Play Console first, and check that the variant you built has the application ID you expect: flavors and applicationIdSuffix change it.

How AppForShare sets this up with one pull request

Builds on GitHub Actions in AppForShare produces the workflow above for you. From the dashboard you connect the repository through the AppForShare Builds GitHub App, give the Gradle folder, the app module and the variant, pick the Linux runner (ubuntu-24.04 by default) and Java 17 or 21, and choose where the build goes. AppForShare opens a pull request with .github/workflows/appforshare-android.yml. It never pushes to your other branches and never merges; you read the file and merge it yourself. The build runs on your own GitHub Actions minutes, not on AppForShare's servers.

  • Signing. You add the four keystore secrets in GitHub yourself, and the workflow signs with the android.injected.signing.* properties, so your Gradle files stay as they are. AppForShare never sees the keystore or its passwords. If your release build already signs through Gradle, pick that instead.
  • Version code. The project's own or the run number, with a number you can give when you start a build. The version name can be set too, both for that build only.
  • To testers. The run builds an .apk and uploads it to AppForShare for an install link, and can put it into a Beta or Live channel whose link always opens the newest build.
  • To Google Play. The run builds an .aab. AppForShare opens the Play edit with your workspace's Google Play connection, and the runner uploads the bundle straight to Google, so it never passes through AppForShare. AppForShare then checks the version code and the file's SHA-256 in the edit, puts the release on the track you picked and commits. The Play key is never copied into the repository.
  • Production stays a draft. A production release is always saved as a draft for you to roll out in Play Console, and AppForShare never cancels changes already in review at Google.

The GitHub builds guide lists every field and secret. Builds on GitHub Actions are part of Pro, for up to 2 repositories, and Team, with no limit; iOS and Android in the same repository count as one. Sending to Google Play needs a Google Play connection, which both plans include. See pricing for the rest of each plan.

Keep reading

App Distribution Platform · · 9 min read

AppForShare.io vs Competitors - A Comparative Analysis

For small teams and startups, selecting a mobile app testing platform is a critical crossroad. This choice is more than a mere selection: it’s a strategic decision that directly impacts the...