From 25009144982bd7d70d03ab7d61189980e78240f3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 11 May 2026 14:22:32 -0400 Subject: [PATCH] fix(flutter): persistent release signing key via CI secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update installs were failing at the system scanner step because every release APK was signed with a different signature: build.gradle.kts fell back to signingConfigs.getByName("debug"), and the debug keystore is generated per-machine — so every CI runner had a fresh random key. Android refuses to upgrade an installed app when the signature doesn't match, hence the "App not installed" failure after the scan. Two-part fix: 1. build.gradle.kts now defines a real "release" signing config when ANDROID_KEYSTORE_PATH is exported (with the matching password + alias env vars). Without those, falls through to the debug keystore so local `flutter run --release` still works. 2. flutter.yml decodes ANDROID_KEYSTORE_B64 (CI secret, base64 of the real release keystore) into a tmp path before the release-APK build step, then exports ANDROID_KEYSTORE_PATH + the three password/alias secrets as env vars. CI now hard-errors if the keystore secret is missing on a tagged build — we don't want another silent debug-keystore release. OPERATOR ACTION REQUIRED before the next tag: 1. Generate a release keystore (one-time): keytool -genkey -v -keystore minstrel-release.keystore \ -alias minstrel -keyalg RSA -keysize 2048 -validity 10000 2. Base64 it: base64 -w0 minstrel-release.keystore > keystore.b64 3. In Forgejo, add four repo secrets: ANDROID_KEYSTORE_B64 = contents of keystore.b64 ANDROID_KEY_ALIAS = minstrel ANDROID_STORE_PASSWORD = the password you set ANDROID_KEY_PASSWORD = same password (or different alias pwd) 4. Keep minstrel-release.keystore offline and backed up — losing it means you can never sign an upgrade for any existing install. 5. Existing installs from prior debug-keyed releases must be uninstalled before installing the first key-signed release. This is a one-time transition; future tagged builds will upgrade cleanly. --- .forgejo/workflows/flutter.yml | 27 +++++++++++++++++ flutter_client/android/app/build.gradle.kts | 33 +++++++++++++++++++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/.forgejo/workflows/flutter.yml b/.forgejo/workflows/flutter.yml index 0ff1066f..b677f62b 100644 --- a/.forgejo/workflows/flutter.yml +++ b/.forgejo/workflows/flutter.yml @@ -72,6 +72,25 @@ jobs: if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/') run: flutter build apk --debug + - name: Decode signing keystore + if: startsWith(github.ref, 'refs/tags/v') + # Reconstructs the release keystore from a base64-encoded + # CI secret so every tagged build is signed with the same + # key. Without this, each CI runner would generate its own + # debug keystore and Android would refuse to upgrade an + # existing install (signature mismatch). + shell: bash + env: + ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }} + run: | + if [ -z "${ANDROID_KEYSTORE_B64}" ]; then + echo "::error::ANDROID_KEYSTORE_B64 secret is missing — release builds need a consistent signing key" + exit 1 + fi + KEYSTORE_PATH="${RUNNER_TEMP}/minstrel-release.keystore" + echo "${ANDROID_KEYSTORE_B64}" | base64 -d > "${KEYSTORE_PATH}" + echo "ANDROID_KEYSTORE_PATH=${KEYSTORE_PATH}" >> "${GITHUB_ENV}" + - name: Build release APK if: startsWith(github.ref, 'refs/tags/v') # Inject the tag (sans leading 'v') as the build's --build-name @@ -79,7 +98,15 @@ jobs: # reports — otherwise the in-app update banner would always # think the user is behind because pubspec.yaml's static # version drifts from the actual release tag. + # + # ANDROID_KEYSTORE_PATH is set by the previous step; + # build.gradle.kts picks it up and signs the release APK with + # the operator's persistent keystore. shell: bash + env: + ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | TAG="${GITHUB_REF#refs/tags/v}" flutter build apk --release --build-name="${TAG}" diff --git a/flutter_client/android/app/build.gradle.kts b/flutter_client/android/app/build.gradle.kts index afad756c..54f8d478 100644 --- a/flutter_client/android/app/build.gradle.kts +++ b/flutter_client/android/app/build.gradle.kts @@ -30,11 +30,38 @@ android { versionName = flutter.versionName } + // Real release signing config — populated only when CI exports + // ANDROID_KEYSTORE_PATH (decoded from a base64 secret) plus the + // matching password/alias env vars. Without these (local + // `flutter build apk --release` runs), the release build falls + // through to the debug keystore so local builds still work. + // + // Why this matters: Flutter's default `flutter run --release` and + // CI's earlier setup both signed with the per-machine debug + // keystore (~/.android/debug.keystore). Every CI runner generated + // its own debug key, so consecutive release APKs had different + // signatures and Android refused to upgrade an existing install. + val keystorePath: String? = System.getenv("ANDROID_KEYSTORE_PATH") + if (keystorePath != null && keystorePath.isNotEmpty()) { + signingConfigs { + create("release") { + storeFile = file(keystorePath) + storePassword = System.getenv("ANDROID_STORE_PASSWORD") + keyAlias = System.getenv("ANDROID_KEY_ALIAS") + keyPassword = System.getenv("ANDROID_KEY_PASSWORD") + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + signingConfig = if (keystorePath != null && keystorePath.isNotEmpty()) { + signingConfigs.getByName("release") + } else { + // Local-only fallback so `flutter run --release` works + // without the keystore. CI must export ANDROID_KEYSTORE_PATH. + signingConfigs.getByName("debug") + } } } }