Mobile Release Build (sideloadable signed APK)
How to produce a release-signed, sideloadable Android APK of the mobile app using a local keystore. This is the free, local, no-cloud path: no Expo cloud build, no Google Play account, no Store submission.
CI already attaches a debug-signed APK to every release
The Mobile APK workflow builds
bike-trip-planner.apk (expo prebuild + ./gradlew assembleRelease) and
attaches it to each published GitHub Release (or to an existing release tag via a
manual workflow_dispatch). It is signed with the Expo
template's deterministic debug keystore, so it needs no secret and is fine
for sideloading a beta build. Follow the procedure below when you need a build
signed with a real release keystore instead.
When to use
- You need a standalone APK to install on a real device or hand to a tester.
- The APK must be release-signed (a debug/dev-client build is not enough).
- You explicitly do not want Store distribution this sprint.
Diagnosis
The mobile app is an Expo CNG project: mobile/android is generated by
expo prebuild and is gitignored (see mobile/.gitignore). There is therefore no
committed android/app/build.gradle to edit in place — release signing is wired
through EAS local credentials (eas.json + credentials.json), which EAS injects
into the generated Gradle signingConfig at build time. A raw-Gradle alternative
is documented in the appendix for anyone who prefers ./gradlew assembleRelease.
Prerequisites (same as mobile/README.md):
- Node 20+ and npm, JDK 17
- Android SDK (
adbonPATH), a device with USB debugging or an emulator EXPO_PUBLIC_API_URLset to an HTTPS host reachable from the phone (the value is inlined into the JS bundle at build time; a non-dev build fails closed without it)
Procedure
1. Generate the local keystore (once)
Never commit this file. Keep it and its passwords somewhere safe (a password manager) — losing it means you can never ship an update that upgrades a previously installed APK in place.
cd mobile
keytool -genkeypair -v \
-keystore release.keystore \
-alias btp-release \
-keyalg RSA -keysize 2048 -validity 10000 \
-storepass '<STORE_PASSWORD>' -keypass '<KEY_PASSWORD>' \
-dname 'CN=Bike Trip Planner, O=les-tilleuls.coop, C=FR'
This writes mobile/release.keystore, which is gitignored (*.keystore).
2. Declare the keystore to EAS (credentials.json)
Create mobile/credentials.json (gitignored — it holds the passwords):
{
"android": {
"keystore": {
"keystorePath": "release.keystore",
"keystorePassword": "<STORE_PASSWORD>",
"keyAlias": "btp-release",
"keyPassword": "<KEY_PASSWORD>"
}
}
}
keystorePath is relative to mobile/. The release profile in
eas.json sets credentialsSource: local, so EAS reads
this file instead of prompting for or storing credentials in the cloud.
3. Build the signed release APK
cd mobile
EXPO_PUBLIC_API_URL=https://<api-host> \
npm run build:release
# -> npx eas-cli build --local --platform android --profile release
# -> writes a release-signed APK such as build-<timestamp>.apk in mobile/
The build runs entirely on your machine (--local). No cloud build is submitted.
4. Sideload onto a device
adb install -r build-<timestamp>.apk # USB debugging enabled, device authorised
# or copy the .apk to the phone and open it (allow "install unknown apps")
Verify the signing certificate if needed:
keytool -list -v -keystore release.keystore -alias btp-release | grep SHA256
Verification and follow-up
- Store
release.keystoreand its passwords in the team password manager. The same keystore MUST be reused for every future update of the samepackage(coop.lestilleuls.biketripplanner); a different key forces an uninstall/reinstall. - Bump
android.versionCode(andversionwhen user-facing) inmobile/app.jsonbefore each new build you distribute — Android refuses to upgrade in place over an equal-or-lowerversionCode.
Out of scope (deferred)
The following are intentionally not covered this sprint:
- Google Play / Store distribution — no Play account, no App Bundle upload, no
managed Play signing. The
productionprofile ineas.json(app-bundle) exists for that future path but is not exercised here. - App Links for a release-keystore build - the PWA serves
/.well-known/assetlinks.json(pwa/src/app/.well-known/assetlinks.json/route.ts) from theANDROID_APP_PACKAGEandANDROID_SHA256_CERT_FINGERPRINTSvariables of thepwaservice. The prod env file (env.j2) sets them fromandroid_app_package/android_sha256_cert_fingerprintsinansible/group_vars/all.yml, which default to the debug keystore fingerprint, so the CI APK verifies itsautoVerifyhttps links. An APK signed with a real release keystore does not, until its fingerprint (keytool -list -v -keystore release.keystore | grep SHA256) replaces or is appended (comma-separated) toandroid_sha256_cert_fingerprints, the playbook is re-run andbtp-compose up -drecreatespwaon the VM. No image rebuild is needed: the route isforce-dynamicand reads the variables at runtime.
Appendix - raw Gradle path (optional)
If you prefer building through Gradle directly instead of eas build --local, run
expo prebuild to generate mobile/android, then wire the same keystore into the
generated android/app/build.gradle. Note that expo prebuild --clean regenerates
android/ and discards these edits — the EAS path above avoids that.
Create mobile/android/keystore.properties (gitignored via keystore.properties):
storeFile=../../release.keystore
storePassword=<STORE_PASSWORD>
keyAlias=btp-release
keyPassword=<KEY_PASSWORD>
In android/app/build.gradle, load it and add a release signingConfig:
def keystorePropertiesFile = rootProject.file("keystore.properties")
def keystoreProperties = new Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
signingConfigs {
release {
if (keystorePropertiesFile.exists()) {
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
}
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
}
}
Then build and sideload:
cd mobile/android
./gradlew assembleRelease
# -> app/build/outputs/apk/release/app-release.apk
adb install -r app/build/outputs/apk/release/app-release.apk
References
mobile/README.md— preview builds, deep-link testing, assetlinks notemobile/eas.json— therelease(local-signed apk) build profile- ADR-053 — npm-workspaces monorepo (
core/pwa/mobile)