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
|
- name: Unit tests
|
||||||
run: ./gradlew testDebugUnitTest
|
run: ./gradlew testDebugUnitTest
|
||||||
|
|
||||||
- name: Assemble debug
|
# No debug APK is built or uploaded here. Main used to upload a
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
# debug-signed app-debug.apk: a build signed by a key regenerated in
|
||||||
run: ./gradlew assembleDebug
|
# every container, which no install can update (family idea #5103,
|
||||||
|
# practice 2). Phones get builds from android-release, signed with
|
||||||
- name: Upload debug APK
|
# the one release key, on dev and on tags.
|
||||||
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
|
|
||||||
|
|
||||||
# Known vulnerabilities in the Go code and the standard library it is built
|
# 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
|
# 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_NAME=${{ steps.ver.outputs.name }} \
|
||||||
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
|
-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
|
- name: Upload APK as workflow artifact
|
||||||
# Stock action (snippet #2271) — never @v3, which uploads something Gitea
|
# Stock action (snippet #2271) — never @v3, which uploads something Gitea
|
||||||
# will never serve back. This is the producing half of a pair:
|
# will never serve back. This is the producing half of a pair:
|
||||||
|
|||||||
@@ -72,9 +72,13 @@ android {
|
|||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro",
|
"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 =
|
signingConfig =
|
||||||
if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) {
|
if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) {
|
||||||
signingConfigs.getByName("debug")
|
null
|
||||||
} else {
|
} else {
|
||||||
signingConfigs.getByName("release")
|
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
|
// check must decide on. Held in state rather than read inline so a test
|
||||||
// can drive the comparison without a BuildConfig.
|
// can drive the comparison without a BuildConfig.
|
||||||
val installedCode: Long = BuildConfig.VERSION_CODE.toLong(),
|
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 isChecking: Boolean = false,
|
||||||
val installStage: InstallStage = InstallStage.IDLE,
|
val installStage: InstallStage = InstallStage.IDLE,
|
||||||
val installMessage: String? = null,
|
val installMessage: String? = null,
|
||||||
@@ -67,7 +71,7 @@ class AboutCardViewModel @Inject constructor(
|
|||||||
val state: StateFlow<AboutUiState> = internal.asStateFlow()
|
val state: StateFlow<AboutUiState> = internal.asStateFlow()
|
||||||
|
|
||||||
fun checkForUpdates() {
|
fun checkForUpdates() {
|
||||||
if (internal.value.isChecking) return
|
if (internal.value.isChecking || !internal.value.selfUpdates) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
internal.update { it.copy(isChecking = true, installMessage = null) }
|
internal.update { it.copy(isChecking = true, installMessage = null) }
|
||||||
val installed = internal.value.installedVersion
|
val installed = internal.value.installedVersion
|
||||||
|
|||||||
@@ -383,6 +383,14 @@ private fun AboutCard(viewModel: AboutCardViewModel = hiltViewModel()) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
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)
|
UpdateCheckLine(result = state.result)
|
||||||
Button(
|
Button(
|
||||||
onClick = viewModel::checkForUpdates,
|
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
|
* restart re-shows it, which is acceptable nudging for v1 (matches
|
||||||
* Flutter). Server 404 / network errors stay silent. Constructed at
|
* Flutter). Server 404 / network errors stay silent. Constructed at
|
||||||
* launch via the construct-the-singleton trick in `MinstrelApplication`.
|
* 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
|
@Singleton
|
||||||
class UpdateBannerController @Inject constructor(
|
class UpdateBannerController @Inject constructor(
|
||||||
@@ -45,6 +48,10 @@ class UpdateBannerController @Inject constructor(
|
|||||||
}.stateIn(scope, SharingStarted.Eagerly, null)
|
}.stateIn(scope, SharingStarted.Eagerly, null)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
if (!BuildConfig.DEBUG) startPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPolling() {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
while (true) {
|
while (true) {
|
||||||
runOnce()
|
runOnce()
|
||||||
|
|||||||
Reference in New Issue
Block a user