Deploy Android APKs

How to build signed Android APKs via GitHub Actions on the self-hosted runner — no Android Studio required.


Overview

PSI mobile apps use Capacitor to wrap React web apps in a native Android shell. This enables native device features (NFC, camera, etc.) that browsers/PWAs can’t access. The APK is built by a GitHub Actions workflow running on the PS-PROXY self-hosted runner.

PropertyValue
Runner[self-hosted, psi-internal] (PS-PROXY)
TriggerManual dispatch (workflow_dispatch)
OutputSigned release APK uploaded as GitHub Actions artifact + published to Managed Google Play
Target devicesZebra TC52 handhelds (Android 8.0+, minSdk 26)

Apps using this pattern

AppRepositoryWorkflow
PS MESPRGJSMESbuild-android.yml

Architecture

React App (web)
    ↓  npm run build
Static build output (build/)
    ↓  npx cap sync android
Android project (android/) with Capacitor plugins
    ↓  ./gradlew assembleRelease
Signed APK (app-release.apk)
    ↓  upload-artifact
GitHub Actions artifact (downloadable)
    ↓  adb install
Android device

The APK is a thin native shell — it loads the web UI from the production server via a WebView (server.url in capacitor.config.ts). Capacitor plugins (NFC, etc.) are compiled into the APK and bridge native APIs to JavaScript.


Prerequisites

Runner requirements

The self-hosted runner does not need Java or Android SDK pre-installed. The workflow uses setup actions to install them on demand:

ToolSetup ActionVersion
Java JDKactions/setup-java@v421 (Temurin) — Capacitor Android requires JDK 21+
Android SDKandroid-actions/setup-android@v3Auto (cmdline-tools, build-tools, platforms)
Node.jsactions/setup-node@v422.x (Capacitor 8+ requires >= 22)

The runner needs: git, npm, bash, base64.

Capacitor project setup

The React app must have Capacitor initialized:

cd your-web-app
npm install @capacitor/core @capacitor/cli @capacitor/android
npx cap init "App Name" com.progressivesurface.yourapp --web-dir build
npx cap add android

This creates the android/ directory with the native project.


Signing Keystore

Every Android APK must be signed to install on a device. Create a keystore once per app — the same keystore is reused for all future versions.

How to generate a keystore

keytool ships with any JDK installation. Options for getting it:

MethodWhere to find keytool
Android Studio (if installed)<Android Studio>/jbr/bin/keytool
Any JDK (Temurin, OpenJDK, etc.)<JDK>/bin/keytool
Docker (no local JDK needed)docker run --rm -v $(pwd):/work eclipse-temurin:21 keytool ...
GitHub Actions (one-time)Run the command in a workflow step after setup-java

Generate the keystore:

keytool -genkeypair -v \
  -keystore yourapp.jks \
  -alias yourapp \
  -keyalg RSA -keysize 2048 -validity 10000 \
  -storepass YourPassword -keypass YourPassword \
  -dname "CN=Your App, O=Progressive Surface, L=Grand Rapids, ST=MI, C=US"

Rules:

  • No special characters (!, @, #) in passwords — the shell mangles them during Gradle builds
  • Use the same store and key password for simplicity
  • RSA 2048-bit, 10,000 day validity is the standard for PSI apps
  • Back up the .jks file securely — if lost, users must uninstall and reinstall the app (Android rejects APK updates signed with a different key)

If you don’t have a JDK installed locally

Use Docker to run keytool without installing Java:

docker run --rm -v "$(pwd):/work" -w /work eclipse-temurin:21 \
  keytool -genkeypair -v \
  -keystore yourapp.jks -alias yourapp \
  -keyalg RSA -keysize 2048 -validity 10000 \
  -storepass YourPassword -keypass YourPassword \
  -dname "CN=Your App, O=Progressive Surface, L=Grand Rapids, ST=MI, C=US"

Or download a JDK from adoptium.net — the keytool binary is in the bin/ directory, no installation required.

Storage

Place the keystore in android/ and add it to .gitignore. The base64-encoded version is stored as a GitHub secret for CI builds.


GitHub Secrets

Add these secrets to the repository before first build:

SecretPurposeHow to generate
ANDROID_KEYSTORE_BASE64Signing keystore (base64)base64 -w0 android/yourapp.jks
ANDROID_KEYSTORE_PASSWORDKeystore store passwordFrom keytool command
ANDROID_KEY_ALIASKey aliasFrom keytool command
ANDROID_KEY_PASSWORDKey passwordFrom keytool command
# Set secrets via gh CLI
base64 -w0 android/yourapp.jks | gh secret set ANDROID_KEYSTORE_BASE64
echo -n "YourPassword" | gh secret set ANDROID_KEYSTORE_PASSWORD
echo -n "yourapp" | gh secret set ANDROID_KEY_ALIAS
echo -n "YourPassword" | gh secret set ANDROID_KEY_PASSWORD

Workflow Template

Create .github/workflows/build-android.yml:

name: Build Android APK
 
on:
  workflow_dispatch:
    inputs:
      version_name:
        description: 'Version name (e.g. 1.0.1). Defaults to 1.0.{run_number}'
        required: false
        type: string
 
env:
  NODE_VERSION: '22.x'
  WEB_DIR: your-web-app          # Directory containing package.json and android/
 
jobs:
  build-apk:
    runs-on: [self-hosted, psi-internal]
 
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup Java JDK 17
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 21
 
      - name: Setup Android SDK
        uses: android-actions/setup-android@v3
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
          cache-dependency-path: ${{ env.WEB_DIR }}/package-lock.json
 
      - name: Install frontend dependencies
        run: npm install
        working-directory: ${{ env.WEB_DIR }}
 
      - name: Build React frontend
        run: npx react-scripts build
        working-directory: ${{ env.WEB_DIR }}
        env:
          CI: false
          # Set your app-specific env vars here:
          # REACT_APP_API_URL: https://yourapp.progressivesurface.com/api
          # REACT_APP_AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
          # REACT_APP_AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
          REACT_APP_VERSION: ${{ inputs.version_name || format('1.0.{0}', github.run_number) }}
 
      - name: Capacitor sync
        run: npx cap sync android
        working-directory: ${{ env.WEB_DIR }}
 
      - name: Decode keystore
        run: |
          echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d \
            > ${{ env.WEB_DIR }}/android/app/keystore.jks
 
      - name: Build signed APK
        run: ./gradlew assembleRelease
        working-directory: ${{ env.WEB_DIR }}/android
        env:
          SIGNING_KEYSTORE_FILE: keystore.jks
          SIGNING_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
          SIGNING_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
          SIGNING_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
          APP_VERSION_CODE: ${{ github.run_number }}
          APP_VERSION_NAME: ${{ inputs.version_name || format('1.0.{0}', github.run_number) }}
 
      - name: Upload APK artifact
        uses: actions/upload-artifact@v4
        with:
          name: apk-v${{ inputs.version_name || format('1.0.{0}', github.run_number) }}
          path: ${{ env.WEB_DIR }}/android/app/build/outputs/apk/release/app-release.apk
          if-no-files-found: error

Gradle Configuration

Modify android/app/build.gradle to support CI signing and dynamic versioning. The env var names should match what the workflow passes.

Dynamic versioning

Replace hardcoded version values with env var lookups:

defaultConfig {
    applicationId "com.progressivesurface.yourapp"
    // ...
    versionCode (System.getenv("APP_VERSION_CODE") ?: "1").toInteger()
    versionName System.getenv("APP_VERSION_NAME") ?: "1.0-dev"
}

Local builds default to versionCode 1 and versionName "1.0-dev". CI builds get auto-incrementing values from github.run_number.

Signing config

Add a signingConfigs block that reads credentials from environment variables:

android {
    // ...
    signingConfigs {
        release {
            storeFile file(System.getenv("SIGNING_KEYSTORE_FILE") ?: "keystore.jks")
            storePassword System.getenv("SIGNING_KEYSTORE_PASSWORD") ?: ""
            keyAlias System.getenv("SIGNING_KEY_ALIAS") ?: "yourapp"
            keyPassword System.getenv("SIGNING_KEY_PASSWORD") ?: ""
        }
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
            signingConfig signingConfigs.release
        }
    }
}

This produces a signed APK in CI and still allows unsigned local builds (the empty password fallback means Gradle skips signing when env vars are absent).


Running a Build

# Trigger with default version (1.0.{run_number})
gh workflow run build-android.yml
 
# Trigger with custom version
gh workflow run build-android.yml -f version_name=2.0.0
 
# Watch the run
gh run watch

After completion, download the APK artifact from the GitHub Actions run page or via CLI:

gh run download <run-id> -n apk-v1.0.42

Installing on Devices

Via ADB (developer)

# Check device is connected
adb devices
 
# Install (first time)
adb install app-release.apk
 
# Reinstall (update)
adb install -r app-release.apk

Via file transfer (non-developer)

  1. Download APK from GitHub Actions
  2. Transfer to device via USB, email, or shared drive
  3. Open the APK file on the device and tap Install
  4. If prompted, enable “Install from unknown sources” in device settings

Via Managed Google Play + Intune {#managed-google-play—intune}

For managed device fleets, the CI/CD workflow publishes APKs directly to Managed Google Play, which Intune syncs as a managed app.

Google Play Setup (one-time)

  1. GCP project: Create a GCP project and enable the Google Play Android Developer API (androidpublisher.googleapis.com)
  2. Service account: Create a service account in the GCP project with a JSON key
  3. Google Play Console: Add the service account email via Users and permissions > Invite new users (not “API access” — Managed Google Play enterprise accounts don’t have that page)
  4. App permissions: Grant “Release to production” permission on the specific app
  5. GitHub secret: Store the service account JSON key as GOOGLE_PLAY_SERVICE_ACCOUNT_JSON

Workflow publishing step

Add this step after the APK upload in build-android.yml:

- name: Publish to Managed Google Play
  if: ${{ inputs.publish != false }}
  uses: r0adkll/upload-google-play@v1
  with:
    serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
    packageName: com.progressivesurface.yourapp
    releaseFiles: your-web-app/android/app/build/outputs/apk/release/app-release.apk
    track: ${{ inputs.track || 'production' }}
    releaseName: v${{ inputs.version_name || format('1.0.{0}', github.run_number) }}
    status: completed

Important: Use the APK path, not AAB. AAB requires Google Play App Signing enrollment, which Managed Google Play enterprise accounts may not support.

Version code conflicts

If the workflow’s version code is lower than an existing published APK, Google Play rejects the upload with “does not allow any existing users to upgrade.” Use an offset to ensure the CI version code always exceeds any manually uploaded versions:

- name: Compute version code
  id: version
  run: echo "code=$(( ${{ github.run_number }} + 100 ))" >> $GITHUB_OUTPUT

Then reference ${{ steps.version.outputs.code }} in the Gradle build step.

Intune integration (one-time)

After the app is published to Managed Google Play, it must be approved and assigned in Intune:

  1. Go to Intune admin centerAppsAndroidManaged Google Play app
  2. Search for your app by package name or title
  3. Approve the app (accept permissions)
  4. Sync to pull the app into Intune’s app catalog
  5. Assign the app to a device group (e.g., “TC52 Handhelds”) with install type Required for automatic deployment
  6. Devices in the group receive the app on their next Intune check-in (typically within 24 hours, or force sync from device settings)

After initial setup, subsequent CI/CD publishes to Google Play are automatically picked up by Intune — no manual approval needed for updates.

Known issue — auto-update delay: Even with the Intune device restrictions “App auto-updates” set to “Always,” Managed Google Play private apps may not auto-install updates on devices. In testing, updates sat for days despite the policy. The Play Store shows the “Update” button but does not install automatically. This appears to be a Managed Google Play behavior limitation with private/enterprise apps, not an Intune policy issue. Workaround: trigger an update check on-device by opening the app’s Play Store page, or via ADB:

adb shell am start -a android.intent.action.VIEW \
  -d "market://details?id=com.progressivesurface.yourapp"

Troubleshooting

ANDROID_HOME not set

The android-actions/setup-android@v3 action sets this automatically. If building locally, set ANDROID_HOME to your Android SDK path.

Gradle build fails with “keystore password was incorrect”

The keystore password likely contains special characters that the shell mangled. Recreate the keystore with alphanumeric-only password.

APK installs but shows “took too long to respond”

The device can’t reach the production server. Ensure the device is on a network with routing to the Azure VNet private endpoint.

cap sync fails with “cannot find platform android”

Run npx cap add android first to initialize the native project.

AGP version requires higher JDK

Android Gradle Plugin (AGP) 8.x requires JDK 17+. Update the java-version in the workflow if you upgrade AGP.

Google Play publish: “The caller does not have permission”

The service account isn’t added to Google Play Console, or doesn’t have “Release to production” permission for the app. Go to Users and permissions > Invite new users and add the service account email with app-level permissions.

Google Play publish: “must be enrolled in Play Signing”

You’re uploading an AAB (App Bundle). Managed Google Play enterprise accounts may not support Play App Signing. Switch releaseFiles to the APK path instead.

Google Play publish: “does not allow any existing users to upgrade”

The version code in the APK is lower than the currently published version. Add an offset to run_number (e.g., run_number + 100) to ensure the CI version code always exceeds manually uploaded versions.


Checklist: Adding Android CI/CD to a New App

  1. Initialize Capacitor in the web app (cap init + cap add android)
  2. Generate a signing keystore with keytool
  3. Add keystore to .gitignore (android/*.jks)
  4. Set GitHub secrets (base64 keystore + passwords + Google Play service account JSON)
  5. Modify android/app/build.gradle with env-var signing config and dynamic versioning
  6. Create build-android.yml workflow (copy template, update WEB_DIR and env vars)
  7. Add Google Play publish step with r0adkll/upload-google-play@v1
  8. Trigger first build with gh workflow run build-android.yml
  9. Approve app in Intune (Managed Google Play app → Approve → Assign to device group)


Last updated: March 2026