From 02ea81e31a71ca6206ba675dcf297226289d9b2c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 02:44:41 +0000 Subject: [PATCH 1/2] ci: add a signed release-APK workflow Add .github/workflows/release.yml that builds a signed release APK and attaches it to a GitHub Release on a v* tag (or uploads it as an artifact via manual dispatch). Signing uses the KEYSTORE_BASE64/PASSWORD/ALIAS secrets when present (stable signature for in-place updates), else generates an ephemeral keystore so the build still yields an installable, signed APK. Wire app/build.gradle.kts release buildType to an env-driven signing config (KEYSTORE_FILE/PASSWORD, KEY_ALIAS/PASSWORD); when unset (e.g. local assembleDebug) no signing config is attached. Document the release flow in docs/ANDROID_APP.md. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VbHpkJ1MdVfGz9bi6Ekb1i --- .github/workflows/release.yml | 93 +++++++++++++++++++++++++++++++++++ app/build.gradle.kts | 18 +++++++ docs/ANDROID_APP.md | 34 +++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2f0a1bf --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,93 @@ +name: Release APK + +# Build a signed release APK and attach it to a GitHub Release. +# +# Triggers: +# - pushing a tag like v0.1.0 -> builds and publishes a GitHub Release +# - manual "Run workflow" -> builds and uploads the APK as an artifact +# +# Signing: +# If the repo defines the KEYSTORE_BASE64 / KEYSTORE_PASSWORD / KEY_ALIAS / +# KEY_PASSWORD secrets, the APK is signed with that keystore (stable signature +# across releases — required for in-place app updates). Otherwise an ephemeral +# keystore is generated so the build still yields an installable, signed APK +# (note: an ephemeral signature changes every run, so such builds can't update +# a previously installed copy — reinstall instead). + +on: + push: + tags: [ "v*" ] + workflow_dispatch: + +permissions: + contents: write # needed to create the GitHub Release + +jobs: + release-apk: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: "8.7" + + - name: Resolve signing keystore + id: keystore + env: + KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/ks" + KS="$RUNNER_TEMP/ks/release.jks" + if [ -n "${KEYSTORE_BASE64:-}" ]; then + echo "Using keystore from repository secrets." + echo "$KEYSTORE_BASE64" | base64 -d > "$KS" + echo "store_pass=${KEYSTORE_PASSWORD}" >> "$GITHUB_OUTPUT" + echo "key_alias=${KEY_ALIAS}" >> "$GITHUB_OUTPUT" + echo "key_pass=${KEY_PASSWORD}" >> "$GITHUB_OUTPUT" + else + echo "No KEYSTORE_BASE64 secret; generating an ephemeral keystore." + keytool -genkeypair -dname "CN=IO Tracer, O=cacheMon" \ + -alias iotracer -keyalg RSA -keysize 2048 -validity 10000 \ + -keystore "$KS" -storepass android -keypass android + echo "store_pass=android" >> "$GITHUB_OUTPUT" + echo "key_alias=iotracer" >> "$GITHUB_OUTPUT" + echo "key_pass=android" >> "$GITHUB_OUTPUT" + fi + echo "path=$KS" >> "$GITHUB_OUTPUT" + + - name: Build signed release APK + env: + KEYSTORE_FILE: ${{ steps.keystore.outputs.path }} + KEYSTORE_PASSWORD: ${{ steps.keystore.outputs.store_pass }} + KEY_ALIAS: ${{ steps.keystore.outputs.key_alias }} + KEY_PASSWORD: ${{ steps.keystore.outputs.key_pass }} + run: gradle :app:assembleRelease --no-daemon --stacktrace + + - name: Upload APK artifact + uses: actions/upload-artifact@v4 + with: + name: io-tracer-release-apk + path: app/build/outputs/apk/release/*.apk + if-no-files-found: error + + - name: Publish GitHub Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + files: app/build/outputs/apk/release/*.apk + generate_release_notes: true diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1f20493..61522fe 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,6 +15,21 @@ android { versionName = "0.1.0" } + // Release signing is driven by environment variables (set by the release + // workflow). When KEYSTORE_FILE is absent — e.g. a plain local `assembleDebug` + // — no signing config is attached and the release variant is left unsigned. + val keystoreFile = System.getenv("KEYSTORE_FILE") + signingConfigs { + create("release") { + if (keystoreFile != null) { + storeFile = file(keystoreFile) + storePassword = System.getenv("KEYSTORE_PASSWORD") + keyAlias = System.getenv("KEY_ALIAS") + keyPassword = System.getenv("KEY_PASSWORD") + } + } + } + buildTypes { release { isMinifyEnabled = false @@ -22,6 +37,9 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) + if (keystoreFile != null) { + signingConfig = signingConfigs.getByName("release") + } } } diff --git a/docs/ANDROID_APP.md b/docs/ANDROID_APP.md index 6bacfb6..72f91be 100644 --- a/docs/ANDROID_APP.md +++ b/docs/ANDROID_APP.md @@ -66,6 +66,40 @@ gradle :app:testDebugUnitTest # run the parser/pairer unit tests CI (`.github/workflows/android.yml`) runs the unit tests and assembles the debug APK on every push/PR and uploads the APK as an artifact. +## Releases (signed release APK) + +A signed **release** APK is produced by `.github/workflows/release.yml`: + +- **Push a tag** `vX.Y.Z` (e.g. `git tag v0.1.0 && git push origin v0.1.0`) — the + workflow builds, signs, and publishes a **GitHub Release** with the APK attached. +- Or trigger it manually from **Actions ▸ Release APK ▸ Run workflow**; the signed + APK is uploaded as a workflow artifact. + +**Signing.** If the repo defines `KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, +`KEY_ALIAS`, and `KEY_PASSWORD` secrets, the APK is signed with that keystore — +use this so updates install in place over previous versions. Without those +secrets the workflow generates an **ephemeral** keystore so the build still yields +an installable, signed APK; note an ephemeral signature changes every run, so +those builds can't update an already-installed copy (uninstall first). + +To create the keystore for the secrets path: + +```bash +keytool -genkeypair -v -keystore release.jks -alias iotracer \ + -keyalg RSA -keysize 2048 -validity 10000 +base64 -w0 release.jks # paste into the KEYSTORE_BASE64 repo secret +``` + +Build a signed release APK locally: + +```bash +KEYSTORE_FILE=$PWD/release.jks KEYSTORE_PASSWORD=… KEY_ALIAS=iotracer KEY_PASSWORD=… \ + gradle :app:assembleRelease # APK -> app/build/outputs/apk/release/ +``` + +> Release builds use the same code as debug (`isMinifyEnabled = false`); only +> signing differs. The app still requires **root** at runtime. + ## Run 1. Install the debug APK on a rooted device (`adb install app-debug.apk`). From d69fa1c084afde0233aae4b2a884ddd6311a738f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 02:48:25 +0000 Subject: [PATCH 2/2] ci: harden release signing config and use portable base64 in docs Address PR #5 review: - Gate release signing on all four env vars being present and non-empty (isNullOrEmpty, not just != null), and resolve the keystore path with rootProject.file so relative paths work from the repo root, not app/. - Keep a loud failure when KEYSTORE_FILE is set but the rest are missing, so a half-configured keystore errors instead of silently shipping an unsigned release. - Docs: use `base64 release.jks | tr -d '\n'` (portable; macOS base64 has no -w0). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VbHpkJ1MdVfGz9bi6Ekb1i --- app/build.gradle.kts | 32 +++++++++++++++++++++++--------- docs/ANDROID_APP.md | 2 +- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 61522fe..760d2f8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,16 +16,30 @@ android { } // Release signing is driven by environment variables (set by the release - // workflow). When KEYSTORE_FILE is absent — e.g. a plain local `assembleDebug` - // — no signing config is attached and the release variant is left unsigned. - val keystoreFile = System.getenv("KEYSTORE_FILE") + // workflow). When they are absent — e.g. a plain local `assembleDebug` — no + // signing config is attached and the release variant is left unsigned. + val envKeystoreFile = System.getenv("KEYSTORE_FILE") + val envKeystorePassword = System.getenv("KEYSTORE_PASSWORD") + val envKeyAlias = System.getenv("KEY_ALIAS") + val envKeyPassword = System.getenv("KEY_PASSWORD") + val hasSigningEnv = !envKeystoreFile.isNullOrEmpty() && + !envKeystorePassword.isNullOrEmpty() && + !envKeyAlias.isNullOrEmpty() && + !envKeyPassword.isNullOrEmpty() + // Fail loudly on a half-configured keystore rather than silently publishing an + // unsigned release: if a keystore is named, the rest must be present too. + require(envKeystoreFile.isNullOrEmpty() || hasSigningEnv) { + "KEYSTORE_FILE is set, but one or more of KEYSTORE_PASSWORD, KEY_ALIAS, " + + "KEY_PASSWORD is missing or empty." + } signingConfigs { create("release") { - if (keystoreFile != null) { - storeFile = file(keystoreFile) - storePassword = System.getenv("KEYSTORE_PASSWORD") - keyAlias = System.getenv("KEY_ALIAS") - keyPassword = System.getenv("KEY_PASSWORD") + if (hasSigningEnv) { + // Resolve a relative path against the repo root, not the app module. + storeFile = rootProject.file(envKeystoreFile!!) + storePassword = envKeystorePassword + keyAlias = envKeyAlias + keyPassword = envKeyPassword } } } @@ -37,7 +51,7 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) - if (keystoreFile != null) { + if (hasSigningEnv) { signingConfig = signingConfigs.getByName("release") } } diff --git a/docs/ANDROID_APP.md b/docs/ANDROID_APP.md index 72f91be..6ab8c13 100644 --- a/docs/ANDROID_APP.md +++ b/docs/ANDROID_APP.md @@ -87,7 +87,7 @@ To create the keystore for the secrets path: ```bash keytool -genkeypair -v -keystore release.jks -alias iotracer \ -keyalg RSA -keysize 2048 -validity 10000 -base64 -w0 release.jks # paste into the KEYSTORE_BASE64 repo secret +base64 release.jks | tr -d '\n' # paste into the KEYSTORE_BASE64 repo secret ``` Build a signed release APK locally: