From 295f4869c180255178f21358cc9a9739c55c8d67 Mon Sep 17 00:00:00 2001 From: ml Date: Sun, 20 Sep 2026 17:47:43 +0200 Subject: [PATCH] Sign release APKs with a fixed key from CI secrets --- .gitea/workflows/build.yml | 18 +++++++++++++++ .github/workflows/release.yml | 22 ++++++++++++++++++ .gitignore | 4 ++++ AGENTS.md | 22 ++++++++++++++---- README.md | 43 ++++++++++++++++++++++++++++------- android/app/build.gradle | 19 ++++++++++++++++ build-image/build-apk.sh | 27 ++++++++++++++++++---- 7 files changed, 137 insertions(+), 18 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 3b20d06..06eedba 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -69,7 +69,25 @@ jobs: dist/shockolate-*-linux-*.tar.gz dist/shockolate-*-windows-*.zip + # The release key is stored base64-encoded in ANDROID_KEYSTORE_BASE64 + # (see README section 4.3). Without it the APK is a debug build. + - name: Prepare release signing + env: + KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + run: | + set -euo pipefail + if [ -z "$KEYSTORE_BASE64" ]; then + echo "No signing key: the APK will be a debug build" + exit 0 + fi + echo "$KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP:-/tmp}/questshock-release.jks" + echo "QUESTSHOCK_KEYSTORE=${RUNNER_TEMP:-/tmp}/questshock-release.jks" >> "$GITHUB_ENV" + - name: Build Quest APK + env: + QUESTSHOCK_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + QUESTSHOCK_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + QUESTSHOCK_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} # QUESTSHOCK_BUILD_IMAGE is already set (see Preflight above), so # this runs build-apk.sh directly instead of trying to docker-run # the image again - same reasoning as `make package` above. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ad8b977..2c80b8d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -83,8 +83,30 @@ jobs: - name: Build the Linux and Windows packages run: make package + # The release key is stored base64-encoded in ANDROID_KEYSTORE_BASE64; see + # README section 4.3. A tag build must be signed with it. + - name: Prepare release signing + env: + KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + run: | + set -euo pipefail + if [ -z "$KEYSTORE_BASE64" ]; then + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + echo "::error::ANDROID_KEYSTORE_BASE64 is not set: a release APK must be signed" + exit 1 + fi + echo "No signing key: the APK will be a debug build" + exit 0 + fi + echo "$KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP:-/tmp}/questshock-release.jks" + echo "QUESTSHOCK_KEYSTORE=${RUNNER_TEMP:-/tmp}/questshock-release.jks" >> "$GITHUB_ENV" + # Needs network access for Gradle's own dependency resolution. - name: Build the Quest APK + env: + QUESTSHOCK_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + QUESTSHOCK_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + QUESTSHOCK_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: make apk - uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 0110ac3..7ca3e49 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,7 @@ # Local registry credentials (see registry.env.example) /registry.env + +# Signing keys +*.jks +*.keystore diff --git a/AGENTS.md b/AGENTS.md index c139621..7841b7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ These come from the maintainer. Follow them without being asked again. `make apk` and `make android-studio` overwrite `android/engine.properties` with container-only paths and break an open Android Studio project. What you can do is dry-run patches (section 6) and run static consistency checks - (section 12). Say plainly in your report that the change was not built or + (section 13). Say plainly in your report that the change was not built or tested. 3. **`engine/` and `android/gl4es-src/` are never edited.** They are vendored snapshots. Every change to them is a numbered patch under @@ -141,7 +141,7 @@ not print it or commit it. `qs_config_frame_tick()`) is called from `xr_input.c`. - JNI symbol names must match the Java `native` declarations exactly (`Java_de_ladkau_questshock__`). A mismatch only fails at - runtime, so check it (section 12). + runtime, so check it (section 13). ### 4.5 Off-screen views (`OverlayPanel`, `MenuOverlay`, `KeyboardOverlay`) @@ -337,7 +337,19 @@ or directory must also be declared as `inputs.file(...)`/`inputs.dir(...)` of The proprietary game data (`res/data/`, `res/sound/`) is never bundled or committed. The app refuses to start the engine until it is present. -## 11. Code and documentation style +## 11. Signing + +Distributed APKs are release builds signed with one fixed key, so users can +update in place. `build-image/build-apk.sh` reads the key from +`QUESTSHOCK_KEYSTORE`, `QUESTSHOCK_KEYSTORE_PASSWORD`, `QUESTSHOCK_KEY_ALIAS` +and `QUESTSHOCK_KEY_PASSWORD`; `android/app/build.gradle` applies it to the +`release` build type only when `QUESTSHOCK_KEYSTORE` is set. Both CI workflows +decode it from the `ANDROID_KEYSTORE_BASE64` secret. Without a key the build is +a debug APK (`...-debug.apk`), which is only for testing. Never commit a +keystore or a password, print them, or put them in a workflow file (README +section 4.3 lists the secrets). + +## 12. Code and documentation style - Match the surrounding code: naming, comment density, idiom. C is plain C with `static` helpers and `//` comments; Java targets Android API 34 and uses only the SDK plus @@ -353,7 +365,7 @@ committed. The app refuses to start the engine until it is present. - English, French and German strings live in `Strings.java`; keep the three entries of each key on the same `put(...)`. -## 12. Verifying a change without building +## 13. Verifying a change without building Because you may not build (rule 2), check statically and say so in the report: @@ -375,7 +387,7 @@ Then report what was checked, what was not (build, on-device behaviour), and any device check the maintainer should do (for example a new patch that may need `android/force-rebuild.sh`). -## 13. Commit messages +## 14. Commit messages Draft only (rule 1). Use a short imperative subject (about 70 characters), a blank line, then a body that explains what the change does and why: the diff --git a/README.md b/README.md index cfed678..c542397 100644 --- a/README.md +++ b/README.md @@ -69,9 +69,11 @@ sideloaded Android app, and needs your own copy of the game data. proprietary game assets (ships `res/GET_ASSETS.txt` in their place). See "4.1. Packaging a distributable build" below. - `apk` - builds `dist/questshock--android-arm64.apk` for - Android-based OpenXR headsets. Unlike every other target, this needs - network access at build time (Gradle/AGP's own dependency - resolution). See "5. Android / Quest build" below. + Android-based OpenXR headsets (a debug build, `...-arm64-debug.apk`, + when there is no signing key - see "4.3. Releases and signing" below). + Unlike every other target, this needs network access at build time + (Gradle/AGP's own dependency resolution). See "5. Android / Quest + build" below. - `android-studio` - stages `engine/`/`android/gl4es-src/` (scratch, patched copies) and the Android prebuilts with host-resolvable paths, so Android Studio can compile and deploy `android/` natively instead @@ -215,7 +217,7 @@ package on every push, using the build-image as its container (so no extra setup is needed in CI beyond the image itself), and publishes the resulting tarball to dl.ladkau.de. -### 4.3. GitHub releases +### 4.3. Releases and signing The repository is developed on Gitea, and the GitHub repository is a mirror of it. `.github/workflows/release.yml` is GitHub's own workflow (the Gitea build @@ -230,9 +232,33 @@ is `.gitea/workflows/build.yml`): `docker-entrypoint.sh` and `build-engine.sh`, so a change to any of them builds a new image (`build-image/VERSION` is not used). The first run is slow because it builds the image. -- The APK is built with `assembleDebug`, so it is signed with the debug key of - the machine that built it. Android only updates an installed app from an APK - with the same signing key. +- The APK is signed with the release key described below. + +**Signing.** Android only updates an installed app from an APK signed with the +same key, so every distributed APK is signed with one fixed key. `make apk` +(`build-image/build-apk.sh`) builds and signs the release APK when +`QUESTSHOCK_KEYSTORE` is set, together with `QUESTSHOCK_KEYSTORE_PASSWORD` and +optionally `QUESTSHOCK_KEY_ALIAS` (default `questshock`) and +`QUESTSHOCK_KEY_PASSWORD` (default: the keystore password). Without a keystore +it builds a debug APK, `questshock--android-arm64-debug.apk`, signed +with a per-machine debug key. That is what Android Studio's Run uses too; an +installed build has to be uninstalled before one signed with another key can be +installed. + +Create the key once and keep the file and its password safe: without them, +users have to uninstall the app to get an update. + +```sh +keytool -genkeypair -v -storetype PKCS12 -keystore questshock-release.jks \ + -alias questshock -keyalg RSA -keysize 4096 -validity 10000 +base64 -w0 questshock-release.jks # the value of ANDROID_KEYSTORE_BASE64 +``` + +Both CI systems read the key from these secrets: `ANDROID_KEYSTORE_BASE64`, +`ANDROID_KEYSTORE_PASSWORD`, and optionally `ANDROID_KEY_ALIAS` and +`ANDROID_KEY_PASSWORD`. Without `ANDROID_KEYSTORE_BASE64`, the Gitea build +produces a debug APK and the GitHub build fails on a tag. `*.jks` and +`*.keystore` are git-ignored. ## 5. Android / Quest build @@ -248,7 +274,8 @@ is `.gitea/workflows/build.yml`): apk` needs its own separate toolchain - see "5.2. Building natively in Android Studio" below. -`make apk` builds `dist/questshock--android-arm64.apk` - an +`make apk` builds `dist/questshock--android-arm64.apk` (or +`...-arm64-debug.apk`, see "4.3. Releases and signing" above) - an immersive OpenXR app (see `android/app/src/main/cpp/xr_session.c`) that can be sideloaded onto any Android-based VR headset with OpenXR support (Meta Quest, Pico, etc. - see "1. Design principles" above), not just one diff --git a/android/app/build.gradle b/android/app/build.gradle index 9598859..6a1f591 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -212,9 +212,28 @@ android { } } + // The release key comes from the environment (build-image/build-apk.sh, + // CI secrets). Without it there is no release signing, and the release + // build type is left unsigned. + def releaseKeystore = System.getenv("QUESTSHOCK_KEYSTORE") + def releaseStorePassword = System.getenv("QUESTSHOCK_KEYSTORE_PASSWORD") + signingConfigs { + release { + if (releaseKeystore) { + storeFile file(releaseKeystore) + storePassword releaseStorePassword + keyAlias System.getenv("QUESTSHOCK_KEY_ALIAS") ?: "questshock" + keyPassword System.getenv("QUESTSHOCK_KEY_PASSWORD") ?: releaseStorePassword + } + } + } + buildTypes { release { minifyEnabled false + if (releaseKeystore) { + signingConfig signingConfigs.release + } } } diff --git a/build-image/build-apk.sh b/build-image/build-apk.sh index c3728f4..7de9aba 100755 --- a/build-image/build-apk.sh +++ b/build-image/build-apk.sh @@ -24,16 +24,33 @@ VERSION="$("$REPO_ROOT/build-image/version.sh")" VERSION_CODE="$(git -C "$REPO_ROOT" rev-list --count HEAD)" echo "Version: $VERSION (versionCode $VERSION_CODE)" -echo "== Building the APK (gradlew assembleDebug) ==" +# A release APK is built and signed when QUESTSHOCK_KEYSTORE is set (with +# QUESTSHOCK_KEYSTORE_PASSWORD, and optionally QUESTSHOCK_KEY_ALIAS, +# default "questshock", and QUESTSHOCK_KEY_PASSWORD, default the keystore +# password). Without it the APK is a debug build, signed with a per-machine +# debug key. +if [ -n "${QUESTSHOCK_KEYSTORE:-}" ]; then + [ -f "$QUESTSHOCK_KEYSTORE" ] || { echo "BUILD FAIL: QUESTSHOCK_KEYSTORE ($QUESTSHOCK_KEYSTORE) does not exist" >&2; exit 1; } + [ -n "${QUESTSHOCK_KEYSTORE_PASSWORD:-}" ] || { echo "BUILD FAIL: QUESTSHOCK_KEYSTORE_PASSWORD is not set" >&2; exit 1; } + GRADLE_TASK=assembleRelease + APK="$ANDROID_DIR/app/build/outputs/apk/release/app-release.apk" + PKG_SUFFIX="" +else + echo "== QUESTSHOCK_KEYSTORE is not set: building a debug APK, not for distribution ==" + GRADLE_TASK=assembleDebug + APK="$ANDROID_DIR/app/build/outputs/apk/debug/app-debug.apk" + PKG_SUFFIX="-debug" +fi + +echo "== Building the APK (gradlew $GRADLE_TASK) ==" cd "$ANDROID_DIR" -./gradlew --no-daemon assembleDebug \ +./gradlew --no-daemon "$GRADLE_TASK" \ "-PquestshockVersionName=$VERSION" "-PquestshockVersionCode=$VERSION_CODE" -APK="$ANDROID_DIR/app/build/outputs/apk/debug/app-debug.apk" -[ -f "$APK" ] || { echo "BUILD FAIL: $APK not found after assembleDebug" >&2; exit 1; } +[ -f "$APK" ] || { echo "BUILD FAIL: $APK not found after $GRADLE_TASK" >&2; exit 1; } mkdir -p "$REPO_ROOT/dist" -PKG_NAME="questshock-$VERSION-android-arm64.apk" +PKG_NAME="questshock-$VERSION-android-arm64$PKG_SUFFIX.apk" cp "$APK" "$REPO_ROOT/dist/$PKG_NAME" echo "== Done =="