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.
| Property | Value |
|---|---|
| Runner | [self-hosted, psi-internal] (PS-PROXY) |
| Trigger | Manual dispatch (workflow_dispatch) |
| Output | Signed release APK uploaded as GitHub Actions artifact + published to Managed Google Play |
| Target devices | Zebra TC52 handhelds (Android 8.0+, minSdk 26) |
Apps using this pattern
| App | Repository | Workflow |
|---|---|---|
| PS MES | PRGJSMES | build-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:
| Tool | Setup Action | Version |
|---|---|---|
| Java JDK | actions/setup-java@v4 | 21 (Temurin) — Capacitor Android requires JDK 21+ |
| Android SDK | android-actions/setup-android@v3 | Auto (cmdline-tools, build-tools, platforms) |
| Node.js | actions/setup-node@v4 | 22.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 androidThis 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:
| Method | Where 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
.jksfile 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:
| Secret | Purpose | How to generate |
|---|---|---|
ANDROID_KEYSTORE_BASE64 | Signing keystore (base64) | base64 -w0 android/yourapp.jks |
ANDROID_KEYSTORE_PASSWORD | Keystore store password | From keytool command |
ANDROID_KEY_ALIAS | Key alias | From keytool command |
ANDROID_KEY_PASSWORD | Key password | From 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_PASSWORDWorkflow 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: errorGradle 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 watchAfter completion, download the APK artifact from the GitHub Actions run page or via CLI:
gh run download <run-id> -n apk-v1.0.42Installing 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.apkVia file transfer (non-developer)
- Download APK from GitHub Actions
- Transfer to device via USB, email, or shared drive
- Open the APK file on the device and tap Install
- 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)
- GCP project: Create a GCP project and enable the Google Play Android Developer API (
androidpublisher.googleapis.com) - Service account: Create a service account in the GCP project with a JSON key
- 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)
- App permissions: Grant “Release to production” permission on the specific app
- 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: completedImportant: 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_OUTPUTThen 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:
- Go to Intune admin center → Apps → Android → Managed Google Play app
- Search for your app by package name or title
- Approve the app (accept permissions)
- Sync to pull the app into Intune’s app catalog
- Assign the app to a device group (e.g., “TC52 Handhelds”) with install type Required for automatic deployment
- 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
- Initialize Capacitor in the web app (
cap init+cap add android) - Generate a signing keystore with
keytool - Add keystore to
.gitignore(android/*.jks) - Set GitHub secrets (base64 keystore + passwords + Google Play service account JSON)
- Modify
android/app/build.gradlewith env-var signing config and dynamic versioning - Create
build-android.ymlworkflow (copy template, updateWEB_DIRand env vars) - Add Google Play publish step with
r0adkll/upload-google-play@v1 - Trigger first build with
gh workflow run build-android.yml - Approve app in Intune (Managed Google Play app → Approve → Assign to device group)
Related Pages
- Deploy to Azure — Web app deployment guide
- Deploy ProApps — Desktop application CI/CD
- PRGJSMES — First app using this pattern
- Azure Resource Map — Runner and secret inventory
Last updated: March 2026