From a1e9de2c84ebe99d11f06c48ebde29e996bdb9f9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 21:33:22 -0400 Subject: [PATCH] ci(android): never ship a debug-signed APK; check the signer (#5116) Adopts the rest of family idea #5103 (distributing your own APK): - Practice 2: build.gradle.kts no longer falls back to the debug key when ANDROID_KEYSTORE_PATH is unset; the release build is signed with the release key or left unsigned. Main no longer builds and uploads a debug-signed app-debug.apk, which no install could ever update. - Practice 3: android-release runs apksigner on the built APK, prints the signer's DN and SHA-256 digest, and fails on a debug signer. An unsigned build fails the same step, since there is no app-release.apk to verify. - Practice 9: debug builds offer no server update. The banner does not poll and the About card says updates come from Android Studio, since the release-signed APK cannot install over a debug-signed app. Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/release.yml | 41 +++++++++++-------- android/app/build.gradle.kts | 6 ++- .../settings/ui/AboutCardViewModel.kt | 6 ++- .../minstrel/settings/ui/SettingsScreen.kt | 8 ++++ .../update/data/UpdateBannerController.kt | 7 ++++ 5 files changed, 50 insertions(+), 18 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 1378cde2..58622d41 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -356,22 +356,11 @@ jobs: - name: Unit tests run: ./gradlew testDebugUnitTest - - name: Assemble debug - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - run: ./gradlew assembleDebug - - - name: Upload debug APK - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - # Stock action: it works on this forge since the runner moved to - # gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal - # out of the action bundle (Scribe snippet #2271). Never @v3 — it reports - # success while Gitea serves artifacts back only through the v4 API, and - # it is what left 72 unreachable artifacts on this repo (Scribe 2270). - uses: actions/upload-artifact@v7 - with: - name: minstrel-android-debug-${{ github.sha }} - path: android/app/build/outputs/apk/debug/app-debug.apk - if-no-files-found: error + # No debug APK is built or uploaded here. Main used to upload a + # debug-signed app-debug.apk: a build signed by a key regenerated in + # every container, which no install can update (family idea #5103, + # practice 2). Phones get builds from android-release, signed with + # the one release key, on dev and on tags. # Known vulnerabilities in the Go code and the standard library it is built # with. Runs in the SAME image the Dockerfile's builder stage uses, so the @@ -534,6 +523,26 @@ jobs: -PMINSTREL_VERSION_NAME=${{ steps.ver.outputs.name }} \ -PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }} + # The APK every phone updates from must carry the release key: Android + # updates an app in place only when the signer matches. Gradle signs + # with the release key or leaves the APK unsigned, so this catches a + # wrong key, an unsigned build and a debug signer before anything + # publishes (family idea #5103, practice 3). apksigner, not keytool: + # keytool prints nothing for a v2-only APK. + - name: The APK carries the release key + shell: bash + run: | + set -euo pipefail + sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" + signer="$(ls "$sdk"/build-tools/*/apksigner 2>/dev/null | sort -V | tail -1 || true)" + test -n "$signer" || { echo "::error::no apksigner under '$sdk/build-tools'"; exit 1; } + certs="$("$signer" verify --print-certs app/build/outputs/apk/release/app-release.apk)" + printf '%s\n' "$certs" | grep -E 'Signer #1 certificate (DN|SHA-256 digest)' + if printf '%s' "$certs" | grep -q 'CN=Android Debug'; then + echo "::error::the release APK is signed with a debug key" + exit 1 + fi + - name: Upload APK as workflow artifact # Stock action (snippet #2271) — never @v3, which uploads something Gitea # will never serve back. This is the producing half of a pair: diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index dc6d264b..e185e97e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -72,9 +72,13 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) + // Signed with the release key or not at all. Falling back to the + // debug key made a missing secret into a published APK that no + // install could ever update (family idea #5103, practice 2). An + // unsigned build installs nowhere, so the gap shows at once. signingConfig = if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) { - signingConfigs.getByName("debug") + null } else { signingConfigs.getByName("release") } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/AboutCardViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/AboutCardViewModel.kt index 2ccd36ce..c05127dc 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/AboutCardViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/AboutCardViewModel.kt @@ -41,6 +41,10 @@ data class AboutUiState( // check must decide on. Held in state rather than read inline so a test // can drive the comparison without a BuildConfig. val installedCode: Long = BuildConfig.VERSION_CODE.toLong(), + // A debug build is signed with this machine's debug key, so the server's + // release-signed APK can never install over it (family idea #5103, + // practice 9). It updates from Android Studio instead. + val selfUpdates: Boolean = !BuildConfig.DEBUG, val isChecking: Boolean = false, val installStage: InstallStage = InstallStage.IDLE, val installMessage: String? = null, @@ -67,7 +71,7 @@ class AboutCardViewModel @Inject constructor( val state: StateFlow = internal.asStateFlow() fun checkForUpdates() { - if (internal.value.isChecking) return + if (internal.value.isChecking || !internal.value.selfUpdates) return viewModelScope.launch { internal.update { it.copy(isChecking = true, installMessage = null) } val installed = internal.value.installedVersion diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt index a09f93fa..41b11fe8 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt @@ -383,6 +383,14 @@ private fun AboutCard(viewModel: AboutCardViewModel = hiltViewModel()) { @Composable private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) { + if (!state.selfUpdates) { + Text( + text = "Debug build: updates install from Android Studio.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return + } UpdateCheckLine(result = state.result) Button( onClick = viewModel::checkForUpdates, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateBannerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateBannerController.kt index 9d92a247..4bd9bef7 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateBannerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/data/UpdateBannerController.kt @@ -29,6 +29,9 @@ private const val POLL_INTERVAL_MS = 24 * 60 * 60 * 1000L * restart re-shows it, which is acceptable nudging for v1 (matches * Flutter). Server 404 / network errors stay silent. Constructed at * launch via the construct-the-singleton trick in `MinstrelApplication`. + * + * A debug build never polls: it is signed with a local debug key, so the + * server's release-signed APK could never install over it (#5103). */ @Singleton class UpdateBannerController @Inject constructor( @@ -45,6 +48,10 @@ class UpdateBannerController @Inject constructor( }.stateIn(scope, SharingStarted.Eagerly, null) init { + if (!BuildConfig.DEBUG) startPolling() + } + + private fun startPolling() { scope.launch { while (true) { runOnce()