fix(android): sign releases with a stable upload key, not the debug keystore

Published APKs could never be updated over - users hit "App not installed" or
"unable to parse the package" and had to uninstall first. The cause was not the
version code (verified: local.properties carries flutter.versionCode=23 and
gha.ps1 bumps pubspec's build number every release, so it increments correctly).
It was the signing key: app/build.gradle.kts still had the stock Flutter
template TODO and pointed the release build type at signingConfigs["debug"].
Android identifies an app by applicationId + signing key and refuses to update
across a key change, and the debug keystore is generated per machine - on
ephemeral CI runners that means every single published build was signed with a
different random key.

Release builds now take their key from android/key.properties or the
OSTP_KEYSTORE_* environment variables, falling back to debug (with a loud
warning) only so local `flutter build apk --release` keeps working. CI
materialises the keystore from repository secrets, refuses to build at all if
the secret is absent, and re-verifies the finished APK is not debug-signed
rather than ever shipping an un-updatable build again.

NOTE: existing installs are signed with a now-unreproducible random key, so
users must uninstall once more for THIS release. Every update after it works.
This commit is contained in:
ospab 2026-07-30 19:54:31 +03:00
parent a8aba8f4b8
commit 32c36afc3b
2 changed files with 90 additions and 10 deletions

View File

@ -593,21 +593,50 @@ jobs:
- name: Build Android APK - name: Build Android APK
shell: bash shell: bash
working-directory: ostp-flutter working-directory: ostp-flutter
env:
OSTP_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
OSTP_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
OSTP_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
OSTP_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: | run: |
# 1. Compile JNI set -euo pipefail
# 1. Materialise the upload keystore from secrets. Android keys an app
# by applicationId + signing key and refuses to update across a key
# change, so every published build MUST use this one key. Releases
# used to fall through to the per-machine debug keystore, which on
# ephemeral CI runners meant a different random key every build -
# hence "App not installed" on upgrade.
if [ -z "${OSTP_KEYSTORE_B64:-}" ]; then
echo "::error::ANDROID_KEYSTORE_BASE64 secret is not set. Refusing to publish a"
echo "::error::debug-signed APK: users could not update over it and the key is"
echo "::error::not reproducible. See docs for the one-time keystore setup."
exit 1
fi
export OSTP_KEYSTORE_PATH="$RUNNER_TEMP/ostp-upload.jks"
echo "$OSTP_KEYSTORE_B64" | base64 -d > "$OSTP_KEYSTORE_PATH"
# 2. Compile JNI
mkdir -p android/app/src/main/jniLibs/${{ matrix.arch }} mkdir -p android/app/src/main/jniLibs/${{ matrix.arch }}
cd ../ostp-jni cd ../ostp-jni
cargo ndk -t ${{ matrix.arch }} -o "../ostp-flutter/android/app/src/main/jniLibs" build --release cargo ndk -t ${{ matrix.arch }} -o "../ostp-flutter/android/app/src/main/jniLibs" build --release
cd ../ostp-flutter cd ../ostp-flutter
# 3. Build Flutter APK # 3. Build Flutter APK
flutter build apk --release --target-platform ${{ matrix.flutter_target }} flutter build apk --release --target-platform ${{ matrix.flutter_target }}
# 4. Copy to output # 4. Fail loudly if the APK somehow still came out debug-signed, rather
cp build/app/outputs/flutter-apk/app-release.apk ostp-android-${{ matrix.arch }}.apk # than shipping another un-updatable build.
APK=build/app/outputs/flutter-apk/app-release.apk
if "$ANDROID_HOME"/build-tools/*/apksigner verify --print-certs "$APK" 2>/dev/null \
| grep -qi "CN=Android Debug"; then
echo "::error::APK is signed with the Android debug certificate - aborting."
exit 1
fi
# 5. Copy to output
cp "$APK" ostp-android-${{ matrix.arch }}.apk
- name: Upload to GitHub Release - name: Upload to GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2

View File

@ -1,3 +1,6 @@
import java.io.FileInputStream
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android") id("kotlin-android")
@ -5,6 +8,30 @@ plugins {
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }
// ── Release signing material ────────────────────────────────────────────────
// Supplied out-of-band and never committed: either an `android/key.properties`
// file (local release builds) or OSTP_KEYSTORE_* environment variables (CI).
//
// This exists because the release build used to be signed with the DEBUG
// keystore (the stock Flutter template TODO). Android identifies an app by
// applicationId + signing key, and refuses to update across a key change. The
// debug keystore is auto-generated per machine, and CI runners are ephemeral,
// so every published build carried a different random key — which is why
// updating on top of a previous install failed with "App not installed" /
// "unable to parse the package" and only a full uninstall+reinstall worked.
val keystoreProperties = Properties().apply {
val propsFile = rootProject.file("key.properties")
if (propsFile.exists()) {
FileInputStream(propsFile).use { load(it) }
}
}
fun signingSetting(propKey: String, envKey: String): String? =
keystoreProperties.getProperty(propKey) ?: System.getenv(envKey)
val releaseStorePath: String? = signingSetting("storeFile", "OSTP_KEYSTORE_PATH")
val hasReleaseSigning: Boolean = !releaseStorePath.isNullOrBlank()
android { android {
namespace = "com.ospab.ostp_client" namespace = "com.ospab.ostp_client"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
@ -34,11 +61,35 @@ android {
} }
} }
signingConfigs {
create("release") {
if (hasReleaseSigning) {
storeFile = file(releaseStorePath!!)
storePassword = signingSetting("storePassword", "OSTP_KEYSTORE_PASSWORD")
keyAlias = signingSetting("keyAlias", "OSTP_KEY_ALIAS")
keyPassword = signingSetting("keyPassword", "OSTP_KEY_PASSWORD")
}
}
}
buildTypes { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. // Use the real upload key when one was supplied; otherwise fall back to
// Signing with the debug keys for now, so `flutter run --release` works. // the debug keystore so a plain local `flutter build apk --release`
signingConfig = signingConfigs.getByName("debug") // still works for development. Anything PUBLISHED must take the first
// branch — a debug-signed build cannot be updated over, and its key is
// machine-local, so it also can't be reproduced later.
if (hasReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
} else {
logger.warn(
"OSTP: no release keystore configured (android/key.properties or " +
"OSTP_KEYSTORE_PATH) - falling back to the DEBUG keystore. This APK " +
"is for local use only: users cannot update over it, and the key is " +
"not reproducible on another machine."
)
signingConfig = signingConfigs.getByName("debug")
}
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
} }
} }