ci(android): never ship a debug-signed APK; check the signer (#5116)
release / govulncheck (push) Successful in 26s
release / go (push) Successful in 2m5s
release / web (push) Successful in 1m24s
release / integration (push) Successful in 5m19s
release / android (push) Successful in 5m57s
release / Build signed APK (releases and dev) (push) Successful in 5m54s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m26s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 26s
release / go (push) Successful in 2m5s
release / web (push) Successful in 1m24s
release / integration (push) Successful in 5m19s
release / android (push) Successful in 5m57s
release / Build signed APK (releases and dev) (push) Successful in 5m54s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m26s
release / Verify release artifacts (tag releases only) (push) Skipped
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
+5
-1
@@ -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<AboutUiState> = 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+7
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user