Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,11 @@
local.properties
CircuitQueest.zip
.kotlin

# Signing material lives in ~/.config/circuitqueest/ — never commit it
*.jks
*.keystore
*.p12
keystore.properties
*.lineage
*.idsig
7 changes: 7 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
# Unreleased

### Engineering
- **Release signing key.** Releases are now signed with a dedicated release key instead of the Android debug key, using APK Signature Scheme v3.1 key rotation so the next version still installs as an update over v2.4 and earlier (no uninstall, progress kept). See `docs/DEPLOYMENT.md` → Code Signing.

---

# CircuitQuEEst v2.4 — Fair Progression & Launch-Crash Fix

**Release Date:** October 4, 2026
Expand Down
64 changes: 34 additions & 30 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@ Before releasing to production:
### Release Build
```bash
./gradlew assembleRelease
# Output: app/build/outputs/apk/release/app-release.apk
# Output: app/build/outputs/apk/release/app-release-unsigned.apk — sign it (see Code Signing)
```

**Release builds include:**
- ✅ ProGuard R8 code obfuscation
- ✅ Resource shrinking
- ✅ Minification (20-40% smaller APK)
- ✅ Release signing (configured locally)
- ✅ Release signing via `scripts/sign-release.sh` (key rotation; see Code Signing)

## Version Management

Expand All @@ -55,39 +55,43 @@ defaultConfig {

## Code Signing

### Local Signing Setup
Gradle builds the release APK **unsigned**; `scripts/sign-release.sh` signs it. No signing
material is in the repo.

### Keys and key rotation
- Releases up to **v2.4** were signed with the Android debug key (`~/.android/debug.keystore`,
cert SHA-256 `6554e4a2…d3b12b`).
- From **v2.5** on, the release key `~/.config/circuitqueest/release.jks` (alias `circuitqueest`,
cert SHA-256 `0fcd73f2…893bb7`, valid to 2054) takes over via **APK Signature Scheme v3.1 key
rotation**: `~/.config/circuitqueest/rotation.lineage` records the old key vouching for the new
one, so new releases install as updates over old ones and players keep their progress.
Android 13+ verifies against the new key; Android 8–12 against the old one.
- Every release therefore needs **all four files**: `release.jks`, `keystore.properties`
(passwords), `rotation.lineage`, and `~/.android/debug.keystore`. Backups:
`hamptonserver:~/backups/circuitqueest-signing/`. Losing them means future releases can't
update existing installs.

### Signing a release
```bash
# Create keystore (one-time)
keytool -genkey -v -keystore ~/.android/keystore.jks \
-keyalg RSA -keysize 2048 -validity 10000 -alias circuitqueest

# Store credentials in local.properties
RELEASE_STORE_FILE=~/.android/keystore.jks
RELEASE_STORE_PASSWORD=your_password
RELEASE_KEY_ALIAS=circuitqueest
RELEASE_KEY_PASSWORD=your_key_password
./gradlew assembleRelease
scripts/sign-release.sh app/build/outputs/apk/release/app-release-unsigned.apk CircuitQueest-vX.Y.apk
```
The script prints the verification: expect v3/v3.1 `true`, a `minSdkVersion=33` signer with the
release cert, and a `minSdkVersion=24` signer with the debug cert.

### Configure Gradle
```kotlin
// app/build.gradle.kts
android {
signingConfigs {
release {
storeFile = file(RELEASE_STORE_FILE)
storePassword = RELEASE_STORE_PASSWORD
keyAlias = RELEASE_KEY_ALIAS
keyPassword = RELEASE_KEY_PASSWORD
}
}
buildTypes {
release {
signingConfig = signingConfigs.release
}
}
}
### Testing an update before publishing
Re-sign a staging build and install it over the existing `CQ Staging` (separate package, so the
real app is untouched):
```bash
./gradlew assembleStaging
scripts/sign-release.sh app/build/outputs/apk/staging/app-staging.apk staging-signed.apk
adb install -r staging-signed.apk # must succeed as an update, not require an uninstall
```

### Google Play
Play App Signing uses its own app-signing key; upload with the release key as the upload key. The
rotation lineage is for sideloaded APKs (GitHub releases).

## Play Store Submission

### Prepare Store Listing
Expand Down
51 changes: 51 additions & 0 deletions scripts/sign-release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Sign a release APK with key rotation (APK Signature Scheme v3.1).
#
# Releases up to v2.4 were signed with the Android debug key. From v2.5 on, the
# release key takes over; the rotation lineage (old key vouching for the new one)
# lets the new APK install as an update over old ones, keeping players' progress.
#
# Usage: scripts/sign-release.sh <unsigned.apk> <signed-output.apk>
# e.g. ./gradlew assembleRelease
# scripts/sign-release.sh app/build/outputs/apk/release/app-release-unsigned.apk CircuitQueest-v2.5.apk
#
# Needs (never committed; backed up on hamptonserver:~/backups/circuitqueest-signing/):
# ~/.config/circuitqueest/keystore.properties storeFile, storePassword, keyAlias, keyPassword
# ~/.config/circuitqueest/rotation.lineage debug key -> release key
# ~/.android/debug.keystore the original signer (still required)
# Override the directory with CQ_SIGNING_DIR, the SDK with ANDROID_HOME.
set -euo pipefail

in_apk=${1:?usage: $0 <unsigned.apk> <signed-output.apk>}
out_apk=${2:?usage: $0 <unsigned.apk> <signed-output.apk>}

signing_dir=${CQ_SIGNING_DIR:-$HOME/.config/circuitqueest}
props="$signing_dir/keystore.properties"
lineage="$signing_dir/rotation.lineage"
old_ks="$HOME/.android/debug.keystore"
sdk=${ANDROID_HOME:-$HOME/.local/android-sdk}
apksigner=$(ls -d "$sdk"/build-tools/*/apksigner | sort -V | tail -1)

for f in "$in_apk" "$props" "$lineage" "$old_ks" "$apksigner"; do
[[ -e $f ]] || { echo "missing: $f" >&2; exit 1; }
done

prop() { grep -E "^$1=" "$props" | head -1 | cut -d= -f2-; }
new_ks=$(prop storeFile)
new_alias=$(prop keyAlias)
export CQ_NEW_STORE_PW CQ_NEW_KEY_PW
CQ_NEW_STORE_PW=$(prop storePassword)
CQ_NEW_KEY_PW=$(prop keyPassword)

"$apksigner" sign \
--ks "$old_ks" --ks-key-alias androiddebugkey --ks-pass pass:android \
--next-signer \
--ks "$new_ks" --ks-key-alias "$new_alias" \
--ks-pass env:CQ_NEW_STORE_PW --key-pass env:CQ_NEW_KEY_PW \
--lineage "$lineage" \
--v4-signing-enabled false \
--out "$out_apk" "$in_apk"

"$apksigner" verify --verbose --print-certs "$out_apk" |
grep -E "^Verified using|^Signer|rotation|lineage" || true
echo "signed: $out_apk"
Loading