diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c72a1e..6a80342 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -593,21 +593,50 @@ jobs: - name: Build Android APK shell: bash 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: | - # 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 }} - + cd ../ostp-jni cargo ndk -t ${{ matrix.arch }} -o "../ostp-flutter/android/app/src/main/jniLibs" build --release cd ../ostp-flutter - - # 3. Build Flutter APK flutter build apk --release --target-platform ${{ matrix.flutter_target }} - - # 4. Copy to output - cp build/app/outputs/flutter-apk/app-release.apk ostp-android-${{ matrix.arch }}.apk + + # 4. Fail loudly if the APK somehow still came out debug-signed, rather + # 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 uses: softprops/action-gh-release@v2 diff --git a/ostp-flutter/android/app/build.gradle.kts b/ostp-flutter/android/app/build.gradle.kts index 268ee60..b495813 100644 --- a/ostp-flutter/android/app/build.gradle.kts +++ b/ostp-flutter/android/app/build.gradle.kts @@ -1,3 +1,6 @@ +import java.io.FileInputStream +import java.util.Properties + plugins { id("com.android.application") id("kotlin-android") @@ -5,6 +8,30 @@ plugins { 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 { namespace = "com.ospab.ostp_client" 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 { 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") + // Use the real upload key when one was supplied; otherwise fall back to + // the debug keystore so a plain local `flutter build apk --release` + // 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") } }