feat(android): sign release builds with the persistent keystore

build_android now requires the keystore generated by the previous commit,
prompts for its passwords at build time, and signs the APK with it instead
of an ephemeral debug key auto-generated per container run — that was the
actual cause of every release needing a manual uninstall to update. Also
derives versionCode from <Version> instead of leaving it fixed at 1, so
version ordering stays monotonic across releases.

Docs (docker/README.md, CLAUDE.md) updated to match the new signing flow.
This commit is contained in:
2026-07-02 21:54:15 +02:00
parent d0037747b5
commit cb3ff714d9
3 changed files with 62 additions and 12 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ by `MainWindow` on desktop and as the single-view root on Android.
- Tests (headless, the primary verification layer): `dotnet test` — single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s - Tests (headless, the primary verification layer): `dotnet test` — single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s
- GUI hot reload: `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install) - GUI hot reload: `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
- CLI: `dotnet run --project src/Cli -- <command>` (no args → usage) - CLI: `dotnet run --project src/Cli -- <command>` (no args → usage)
- **Release binaries (all 3 targets): `./docker/build.sh [windows|linux|android|all]`** — reproducible builds in Docker (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj. See `docker/README.md`. Gotchas already encoded there: single-file desktop publishes need `-p:IncludeNativeLibrariesForSelfExtract=true` (without it Avalonia's native libs — Skia/HarfBuzz/ANGLE — stay outside the exe, which then silently fails to start); the android workload dictates the SDK API level (error XA5207 → bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`). - **Release binaries (all 3 targets): `./docker/build.sh [windows|linux|android|all]`** — reproducible builds in Docker (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj. See `docker/README.md`. Gotchas already encoded there: single-file desktop publishes need `-p:IncludeNativeLibrariesForSelfExtract=true` (without it Avalonia's native libs — Skia/HarfBuzz/ANGLE — stay outside the exe, which then silently fails to start); the android workload dictates the SDK API level (error XA5207 → bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`). Android release builds need a persistent signing keystore, generated once with `docker/keystore/generate-keystore.sh` (never committed — see `docker/keystore/README.md`): without it every build gets a different random signature and users must uninstall the old app to receive an update instead of updating in place.
- Manual Windows publish: `dotnet publish src/App.Desktop -r win-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained` - Manual Windows publish: `dotnet publish src/App.Desktop -r win-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained`
- Manual Linux publish: same with `-r linux-x64` (single-file binary; AppImage via PupNet Deploy is a future step, no pupnet.conf yet) - Manual Linux publish: same with `-r linux-x64` (single-file binary; AppImage via PupNet Deploy is a future step, no pupnet.conf yet)
+25 -9
View File
@@ -46,6 +46,16 @@ automatically on first use — a few minutes for desktop, 1020 minutes for
Android (large downloads). Subsequent builds reuse the cached images and take Android (large downloads). Subsequent builds reuse the cached images and take
well under a minute (desktop) / a few minutes (Android). well under a minute (desktop) / a few minutes (Android).
**Android release signing:** before the first `android` build, generate the
persistent signing keystore once — see [`keystore/README.md`](keystore/README.md):
```bash
./docker/keystore/generate-keystore.sh
```
Without it, `build_android` refuses to run — every APK must be signed with
the same key so future releases can update a previous install in place.
--- ---
## Quick start ## Quick start
@@ -64,7 +74,7 @@ Running without arguments shows an interactive menu — pick a single target or
Targets: Targets:
windows Win x64 single-file executable (native libs embedded) windows Win x64 single-file executable (native libs embedded)
linux Linux x64 single-file binary (runs as-is, nothing to install) linux Linux x64 single-file binary (runs as-is, nothing to install)
android Android APK (debug-signed) android Android APK (release-signed, prompts for keystore passwords)
all All three targets all All three targets
Options: Options:
@@ -109,16 +119,16 @@ effectively all of them); no .NET or other packages to install. If you
transfer it through a channel that strips permissions (e.g. a web download), transfer it through a channel that strips permissions (e.g. a web download),
restore the execute bit with `chmod +x`. restore the execute bit with `chmod +x`.
**Android** — a debug-signed APK for sideloading: transfer it to the phone **Android** — a release-signed APK for sideloading: transfer it to the phone
and open it (enable "install from unknown sources" if prompted), or install and open it (enable "install from unknown sources" if prompted), or install
via `adb install dist/android/PalladiumWallet-*.apk`. Supports Android 6.0+ via `adb install dist/android/PalladiumWallet-*.apk`. Supports Android 6.0+
(API 23), arm64 phones and x86_64 emulators. (API 23), arm64 phones and x86_64 emulators.
> **Signature caveat:** debug-signed APKs built on different machines carry > **Signature:** every APK is signed with the persistent keystore in
> different keys. If a previous build is already installed, Android refuses > `docker/keystore/` (see [Prerequisites](#prerequisites)), so installing a
> the update (`INSTALL_FAILED_UPDATE_INCOMPATIBLE`) — uninstall the old app > newer build over an existing one updates it in place — no uninstall, no
> first. **Uninstalling deletes the app's data: back up the wallet seed > data loss. This only holds as long as every release keeps using that same
> before doing this.** Release signing with a stable key is not set up yet. > keystore file; see `docker/keystore/README.md` for the backup story.
--- ---
@@ -166,8 +176,14 @@ docker volume rm plm-nuget-cache
android workload moved to a newer API level. Bump android workload moved to a newer API level. Bump
`ANDROID_SDK_PLATFORM` (and `ANDROID_SDK_BUILD_TOOLS`) in `ANDROID_SDK_PLATFORM` (and `ANDROID_SDK_BUILD_TOOLS`) in
`Dockerfile.android` to the level the error names, then `--rebuild`. `Dockerfile.android` to the level the error names, then `--rebuild`.
- **APK won't install over an existing app** — signature mismatch between - **APK won't install over an existing app
debug keys; see the signature caveat above. (`INSTALL_FAILED_UPDATE_INCOMPATIBLE`)** — the new build wasn't signed with
the same keystore as the installed one. Make sure `docker/keystore/release.keystore`
hasn't changed since the installed build; if it's genuinely a different key,
the user must uninstall the old app first (this deletes app data — back up
the wallet seed before doing this).
- **`build_android` refuses to run, asks to generate a keystore** — run
`./docker/keystore/generate-keystore.sh` once (see `docker/keystore/README.md`).
- **Everything is broken / start from scratch** — - **Everything is broken / start from scratch** —
`docker system prune -a && docker volume rm plm-nuget-cache`, then rerun `docker system prune -a && docker volume rm plm-nuget-cache`, then rerun
the script (images and packages are re-downloaded). the script (images and packages are re-downloaded).
+36 -2
View File
@@ -29,7 +29,7 @@ $(bold "Usage:") $(basename "$0") [TARGET] [OPTIONS]
$(bold "Targets:") $(bold "Targets:")
windows Win x64 single-file executable → dist/windows/ windows Win x64 single-file executable → dist/windows/
linux Linux x64 single-file binary → dist/linux/ linux Linux x64 single-file binary → dist/linux/
android Android APK (debug-signed) → dist/android/ android Android APK (release-signed) → dist/android/
all All three targets all All three targets
$(bold "Options:") $(bold "Options:")
@@ -112,6 +112,10 @@ ensure_android_image() {
# $1 = image name, $2 = inline bash commands to execute inside the container. # $1 = image name, $2 = inline bash commands to execute inside the container.
# Source is copied to /tmp/build inside the container so bin/obj never pollute # Source is copied to /tmp/build inside the container so bin/obj never pollute
# the repo. Output is written to /output (→ host DIST_DIR sub-folder). # the repo. Output is written to /output (→ host DIST_DIR sub-folder).
# Optional extra `docker run` args (e.g. keystore mount, signing passwords via
# -e) can be set by the caller in the EXTRA_DOCKER_ARGS array beforehand.
EXTRA_DOCKER_ARGS=()
run_build() { run_build() {
local image="$1" local image="$1"
local commands="$2" local commands="$2"
@@ -123,6 +127,7 @@ run_build() {
--volume "${PROJECT_ROOT}:/src:ro" \ --volume "${PROJECT_ROOT}:/src:ro" \
--volume "${out_dir}:/output" \ --volume "${out_dir}:/output" \
--volume "${NUGET_VOLUME}:/root/.nuget/packages" \ --volume "${NUGET_VOLUME}:/root/.nuget/packages" \
"${EXTRA_DOCKER_ARGS[@]}" \
"$image" \ "$image" \
bash -euo pipefail -c " bash -euo pipefail -c "
cp -r /src/. /tmp/build cp -r /src/. /tmp/build
@@ -169,16 +174,45 @@ build_linux() {
build_android() { build_android() {
ensure_android_image ensure_android_image
local keystore="${SCRIPT_DIR}/keystore/release.keystore"
[[ -f "$keystore" ]] || die "No release keystore found at docker/keystore/release.keystore. Run ./docker/keystore/generate-keystore.sh once first (see docker/keystore/README.md)."
info "Building Android APK …" info "Building Android APK …"
read -r -s -p "Keystore password: " ANDROID_KS_PASS
echo
read -r -s -p "Key password (press Enter to reuse the keystore password): " ANDROID_KEY_PASS
echo
ANDROID_KEY_PASS="${ANDROID_KEY_PASS:-$ANDROID_KS_PASS}"
# versionCode derived from <Version> (MAJOR.MINOR.PATCH, pre-release suffix
# stripped) so it always increases with releases — required for some
# installers to accept an in-place update even when the signature matches.
local semver="${VERSION%%-*}"
IFS='.' read -r VMAJOR VMINOR VPATCH <<< "$semver"
local VERSION_CODE=$(( ${VMAJOR:-0} * 10000 + ${VMINOR:-0} * 100 + ${VPATCH:-0} ))
info "versionCode: ${VERSION_CODE}"
EXTRA_DOCKER_ARGS=(
--volume "${keystore}:/keystore/release.keystore:ro"
--env "ANDROID_KS_PASS=${ANDROID_KS_PASS}"
--env "ANDROID_KEY_PASS=${ANDROID_KEY_PASS}"
)
run_build "$IMAGE_ANDROID" \ run_build "$IMAGE_ANDROID" \
"JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 \ "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 \
dotnet build src/App.Android \ dotnet build src/App.Android \
-c Release \ -c Release \
-t:SignAndroidPackage \ -t:SignAndroidPackage \
-p:AndroidSdkDirectory=\${ANDROID_HOME} -p:AndroidSdkDirectory=\${ANDROID_HOME} \
-p:ApplicationVersion=${VERSION_CODE} \
-p:AndroidSigningKeyStore=/keystore/release.keystore \
-p:AndroidSigningKeyAlias=palladiumwallet \
-p:AndroidSigningStorePass=\${ANDROID_KS_PASS} \
-p:AndroidSigningKeyPass=\${ANDROID_KEY_PASS}
cp src/App.Android/bin/Release/net10.0-android/*-Signed.apk \ cp src/App.Android/bin/Release/net10.0-android/*-Signed.apk \
\"/output/PalladiumWallet-${VERSION}.apk\"" \ \"/output/PalladiumWallet-${VERSION}.apk\"" \
"${DIST_DIR}/android" "${DIST_DIR}/android"
EXTRA_DOCKER_ARGS=()
ok "Android → dist/android/PalladiumWallet-${VERSION}.apk" ok "Android → dist/android/PalladiumWallet-${VERSION}.apk"
} }