Sign release APKs with a fixed key from CI secrets
build / build (push) Successful in 2m48s

This commit is contained in:
ml
2026-09-20 17:47:43 +02:00
parent f0a83df667
commit 295f4869c1
7 changed files with 137 additions and 18 deletions
+18
View File
@@ -69,7 +69,25 @@ jobs:
dist/shockolate-*-linux-*.tar.gz dist/shockolate-*-linux-*.tar.gz
dist/shockolate-*-windows-*.zip 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 - 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 # QUESTSHOCK_BUILD_IMAGE is already set (see Preflight above), so
# this runs build-apk.sh directly instead of trying to docker-run # this runs build-apk.sh directly instead of trying to docker-run
# the image again - same reasoning as `make package` above. # the image again - same reasoning as `make package` above.
+22
View File
@@ -83,8 +83,30 @@ jobs:
- name: Build the Linux and Windows packages - name: Build the Linux and Windows packages
run: make package 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. # Needs network access for Gradle's own dependency resolution.
- name: Build the Quest APK - 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 run: make apk
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
+4
View File
@@ -38,3 +38,7 @@
# Local registry credentials (see registry.env.example) # Local registry credentials (see registry.env.example)
/registry.env /registry.env
# Signing keys
*.jks
*.keystore
+17 -5
View File
@@ -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` `make apk` and `make android-studio` overwrite `android/engine.properties`
with container-only paths and break an open Android Studio project. What you 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 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. tested.
3. **`engine/` and `android/gl4es-src/` are never edited.** They are vendored 3. **`engine/` and `android/gl4es-src/` are never edited.** They are vendored
snapshots. Every change to them is a numbered patch under 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`. `qs_config_frame_tick()`) is called from `xr_input.c`.
- JNI symbol names must match the Java `native` declarations exactly - JNI symbol names must match the Java `native` declarations exactly
(`Java_de_ladkau_questshock_<Class>_<method>`). A mismatch only fails at (`Java_de_ladkau_questshock_<Class>_<method>`). 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`) ### 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 The proprietary game data (`res/data/`, `res/sound/`) is never bundled or
committed. The app refuses to start the engine until it is present. 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 - 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 `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 - English, French and German strings live in `Strings.java`; keep the three
entries of each key on the same `put(...)`. 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: 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 any device check the maintainer should do (for example a new patch that may
need `android/force-rebuild.sh`). need `android/force-rebuild.sh`).
## 13. Commit messages ## 14. Commit messages
Draft only (rule 1). Use a short imperative subject (about 70 characters), a 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 blank line, then a body that explains what the change does and why: the
+35 -8
View File
@@ -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). proprietary game assets (ships `res/GET_ASSETS.txt` in their place).
See "4.1. Packaging a distributable build" below. See "4.1. Packaging a distributable build" below.
- `apk` - builds `dist/questshock-<version>-android-arm64.apk` for - `apk` - builds `dist/questshock-<version>-android-arm64.apk` for
Android-based OpenXR headsets. Unlike every other target, this needs Android-based OpenXR headsets (a debug build, `...-arm64-debug.apk`,
network access at build time (Gradle/AGP's own dependency when there is no signing key - see "4.3. Releases and signing" below).
resolution). See "5. Android / Quest build" 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, - `android-studio` - stages `engine/`/`android/gl4es-src/` (scratch,
patched copies) and the Android prebuilts with host-resolvable paths, patched copies) and the Android prebuilts with host-resolvable paths,
so Android Studio can compile and deploy `android/` natively instead 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 extra setup is needed in CI beyond the image itself), and publishes the
resulting tarball to dl.ladkau.de. 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 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 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 `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 builds a new image (`build-image/VERSION` is not used). The first run is slow
because it builds the image. because it builds the image.
- The APK is built with `assembleDebug`, so it is signed with the debug key of - The APK is signed with the release key described below.
the machine that built it. Android only updates an installed app from an APK
with the same signing key. **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-<version>-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 ## 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 apk` needs its own separate toolchain - see "5.2. Building natively in
Android Studio" below. Android Studio" below.
`make apk` builds `dist/questshock-<version>-android-arm64.apk` - an `make apk` builds `dist/questshock-<version>-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 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 can be sideloaded onto any Android-based VR headset with OpenXR support
(Meta Quest, Pico, etc. - see "1. Design principles" above), not just one (Meta Quest, Pico, etc. - see "1. Design principles" above), not just one
+19
View File
@@ -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 { buildTypes {
release { release {
minifyEnabled false minifyEnabled false
if (releaseKeystore) {
signingConfig signingConfigs.release
}
} }
} }
+22 -5
View File
@@ -24,16 +24,33 @@ VERSION="$("$REPO_ROOT/build-image/version.sh")"
VERSION_CODE="$(git -C "$REPO_ROOT" rev-list --count HEAD)" VERSION_CODE="$(git -C "$REPO_ROOT" rev-list --count HEAD)"
echo "Version: $VERSION (versionCode $VERSION_CODE)" 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" cd "$ANDROID_DIR"
./gradlew --no-daemon assembleDebug \ ./gradlew --no-daemon "$GRADLE_TASK" \
"-PquestshockVersionName=$VERSION" "-PquestshockVersionCode=$VERSION_CODE" "-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 $GRADLE_TASK" >&2; exit 1; }
[ -f "$APK" ] || { echo "BUILD FAIL: $APK not found after assembleDebug" >&2; exit 1; }
mkdir -p "$REPO_ROOT/dist" 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" cp "$APK" "$REPO_ROOT/dist/$PKG_NAME"
echo "== Done ==" echo "== Done =="