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

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:
2026-10-06 21:33:22 -04:00
co-authored by Claude Opus 5.5
parent 13a7a3629a
commit a1e9de2c84
5 changed files with 50 additions and 18 deletions
+25 -16
View File
@@ -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: