From ad9feada980677513ec7f12ba71594796f134a9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=93=20Xu=C3=A2n=20D=C5=A9ng?= Date: Tue, 13 Oct 2026 00:40:21 +0700 Subject: [PATCH 1/2] build(android): sign release when an upload keystore is configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release stays unsigned unless all four handlive.upload settings are set and the keystore file exists, so CI without secrets still succeeds. Signed-off-by: Hồ Xuân Dũng --- app/build.gradle.kts | 45 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 40ed024..52815b1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,6 +20,37 @@ fun buildSetting( /** A Kotlin string literal for `buildConfigField`. */ fun quoted(value: String): String = "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"" +/** + * Play upload key, same lookup as [buildSetting]. Release is signed only when all four values are set and the + * keystore file exists. CI sets none of them, so the unsigned release build still succeeds. The file check goes + * through a [ValueSource] so configuration cache fingerprints the keystore instead of reading the disk ad hoc. + * Nothing here is logged. + */ +abstract class UploadKeystoreExists : ValueSource { + interface Params : ValueSourceParameters { + val keystore: RegularFileProperty + } + + override fun obtain(): Boolean = + parameters.keystore.asFile + .get() + .isFile +} + +val uploadStoreFile = buildSetting("handlive.upload.storeFile", "HANDLIVE_UPLOAD_STORE_FILE") +val uploadStorePassword = buildSetting("handlive.upload.storePassword", "HANDLIVE_UPLOAD_STORE_PASSWORD") +val uploadKeyAlias = buildSetting("handlive.upload.keyAlias", "HANDLIVE_UPLOAD_KEY_ALIAS") +val uploadKeyPassword = buildSetting("handlive.upload.keyPassword", "HANDLIVE_UPLOAD_KEY_PASSWORD") +val uploadSigningReady = + uploadStoreFile.isNotEmpty() && + uploadStorePassword.isNotEmpty() && + uploadKeyAlias.isNotEmpty() && + uploadKeyPassword.isNotEmpty() && + providers + .of(UploadKeystoreExists::class.java) { + parameters.keystore.set(file(uploadStoreFile).absoluteFile) + }.get() + android { namespace = "app.handlive.android" compileSdk = 37 @@ -75,12 +106,26 @@ android { } } + if (uploadSigningReady) { + signingConfigs { + create("upload") { + storeFile = file(uploadStoreFile) + storePassword = uploadStorePassword + keyAlias = uploadKeyAlias + keyPassword = uploadKeyPassword + } + } + } + buildTypes { debug { // en-XA (long accented text) and ar-XB (right-to-left) catch clipping and hard-coded text (0.12.5). isPseudoLocalesEnabled = true } release { + if (uploadSigningReady) { + signingConfig = signingConfigs.getByName("upload") + } // Play Vitals: R8 obfuscates and shrinks the release DEX. Ship mapping.txt with the Play upload. isMinifyEnabled = true isShrinkResources = true From 9b16234f5aeccf2cf146966a80733c4a55a4edde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=93=20Xu=C3=A2n=20D=C5=A9ng?= Date: Tue, 13 Oct 2026 01:26:59 +0700 Subject: [PATCH 2/2] ci(android): build a signed Play AAB on main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release-android only publishes the foss APK. This dispatch workflow signs gmsRelease with the upload key in the release environment and keeps the AAB and mapping.txt for seven days. Signed-off-by: Hồ Xuân Dũng --- .github/workflows/play-bundle.yml | 188 ++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 .github/workflows/play-bundle.yml diff --git a/.github/workflows/play-bundle.yml b/.github/workflows/play-bundle.yml new file mode 100644 index 0000000..d405417 --- /dev/null +++ b/.github/workflows/play-bundle.yml @@ -0,0 +1,188 @@ +# Play bundle: on demand, from main only, builds the signed gmsRelease AAB for Play. +# Gradle signs it (signingConfig "upload" in app/build.gradle.kts reads HANDLIVE_UPLOAD_*). +# This job therefore holds the upload key during the Gradle step. The keystore is written +# only after the setup actions, mode 600, and removed in a final step that always runs. +# release-android.yml is unchanged: it still publishes the foss APK, not an AAB. +# Dispatch (after this file is on main): +# gh workflow run play-bundle.yml --repo HandLive/handlive-android --ref main +# Artifact "play-bundle" (retention 7 days): app-gms-release.aab and mapping.txt. +# Secrets live in the environment "release" (docs/deployment-guide.md, Release builds). +# Required: ANDROID_RELEASE_KEYSTORE_BASE64, ANDROID_RELEASE_KEYSTORE_PASSWORD, +# ANDROID_RELEASE_KEY_ALIAS, HANDLIVE_FCM_APPLICATION_ID, HANDLIVE_FCM_API_KEY, +# HANDLIVE_FCM_PROJECT_ID, HANDLIVE_FCM_SENDER_ID. A missing one fails the job by name. +# ANDROID_RELEASE_KEY_PASSWORD is optional (PKCS12 uses the store password). +# Repository variables HANDLIVE_RELAY_HOST and HANDLIVE_RELAY_EXTRA_PINS are passed through. +# Empty means the build has no relay, same as release-android. Nothing here prints a secret value. +name: play-bundle + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: play-bundle-${{ github.ref }} + cancel-in-progress: false + +jobs: + bundle: + name: bundle gmsRelease (signed) + runs-on: ubuntu-latest + timeout-minutes: 45 + if: github.ref == 'refs/heads/main' + environment: release + steps: + - name: Required signing and FCM secrets + env: + KEYSTORE: ${{ secrets.ANDROID_RELEASE_KEYSTORE_BASE64 }} + STORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }} + FCM_APPLICATION_ID: ${{ secrets.HANDLIVE_FCM_APPLICATION_ID }} + FCM_API_KEY: ${{ secrets.HANDLIVE_FCM_API_KEY }} + FCM_PROJECT_ID: ${{ secrets.HANDLIVE_FCM_PROJECT_ID }} + FCM_SENDER_ID: ${{ secrets.HANDLIVE_FCM_SENDER_ID }} + run: | + # Names only. An empty value (base64 of a file that was not there) counts as missing. + missing="" + [ -n "$KEYSTORE" ] || missing="$missing ANDROID_RELEASE_KEYSTORE_BASE64" + [ -n "$STORE_PASSWORD" ] || missing="$missing ANDROID_RELEASE_KEYSTORE_PASSWORD" + [ -n "$KEY_ALIAS" ] || missing="$missing ANDROID_RELEASE_KEY_ALIAS" + [ -n "$FCM_APPLICATION_ID" ] || missing="$missing HANDLIVE_FCM_APPLICATION_ID" + [ -n "$FCM_API_KEY" ] || missing="$missing HANDLIVE_FCM_API_KEY" + [ -n "$FCM_PROJECT_ID" ] || missing="$missing HANDLIVE_FCM_PROJECT_ID" + [ -n "$FCM_SENDER_ID" ] || missing="$missing HANDLIVE_FCM_SENDER_ID" + if [ -n "$missing" ]; then + echo "::error::Play bundle secrets missing or empty:$missing" + exit 1 + fi + + # Hub first: a checkout at the workspace root clears the directory. + - name: Kho hub handlive ở gốc workspace + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + repository: ${{ github.repository_owner }}/handlive + ref: main + token: ${{ secrets.HANDLIVE_REPOS_TOKEN || github.token }} + persist-credentials: false + + - name: Kho android trên main + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + path: android + persist-credentials: false + + - name: Kho handlive-shared cạnh android/ + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + repository: ${{ github.repository_owner }}/handlive-shared + path: shared + ref: main + token: ${{ secrets.HANDLIVE_REPOS_TOKEN || github.token }} + persist-credentials: false + + - name: JDK 21 (Temurin) + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: temurin + java-version: "21" + + - name: Android SDK platform 37 + uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3.2.2 + with: + packages: "platform-tools platforms;android-37.0" + + # A Play bundle is built from scratch: no Gradle cache from earlier runs. + - name: Gradle + uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 + with: + cache-disabled: true + + - name: Decode the upload keystore + env: + KEYSTORE: ${{ secrets.ANDROID_RELEASE_KEYSTORE_BASE64 }} + run: | + if [ -z "$RUNNER_TEMP" ]; then + echo "::error::RUNNER_TEMP is unset" + exit 1 + fi + umask 077 + keystore="$RUNNER_TEMP/upload.jks" + printf '%s' "$KEYSTORE" | base64 --decode > "$keystore" + if [ ! -s "$keystore" ]; then + echo "::error::ANDROID_RELEASE_KEYSTORE_BASE64 decoded to an empty file" + exit 1 + fi + mode="$(stat -c '%a' "$keystore")" + if [ "$mode" != 600 ]; then + echo "::error::upload keystore is not mode 600" + exit 1 + fi + + - name: ./gradlew :app:bundleGmsRelease + working-directory: android + env: + HANDLIVE_UPLOAD_STORE_FILE: ${{ runner.temp }}/upload.jks + HANDLIVE_UPLOAD_STORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }} + HANDLIVE_UPLOAD_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }} + # PKCS12: the key password is the store password when the separate secret is unset. + HANDLIVE_UPLOAD_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD || secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }} + HANDLIVE_RELAY_HOST: ${{ vars.HANDLIVE_RELAY_HOST }} + HANDLIVE_RELAY_EXTRA_PINS: ${{ vars.HANDLIVE_RELAY_EXTRA_PINS }} + HANDLIVE_FCM_APPLICATION_ID: ${{ secrets.HANDLIVE_FCM_APPLICATION_ID }} + HANDLIVE_FCM_API_KEY: ${{ secrets.HANDLIVE_FCM_API_KEY }} + HANDLIVE_FCM_PROJECT_ID: ${{ secrets.HANDLIVE_FCM_PROJECT_ID }} + HANDLIVE_FCM_SENDER_ID: ${{ secrets.HANDLIVE_FCM_SENDER_ID }} + # --no-configuration-cache: gradle.properties turns the cache on, and it would store the + # upload passwords and FCM values on disk. This job runs once; it does not need that cache. + run: ./gradlew :app:bundleGmsRelease --no-build-cache --no-configuration-cache --stacktrace + + - name: jarsigner -verify the AAB + working-directory: android + run: | + aab="app/build/outputs/bundle/gmsRelease/app-gms-release.aab" + mapping="app/build/outputs/mapping/gmsRelease/mapping.txt" + if [ ! -f "$aab" ]; then + echo "::error::Missing AAB app/build/outputs/bundle/gmsRelease/app-gms-release.aab" + exit 1 + fi + if [ ! -f "$mapping" ]; then + echo "::error::Missing mapping app/build/outputs/mapping/gmsRelease/mapping.txt" + exit 1 + fi + # An unsigned archive still makes jarsigner exit 0; it only prints "jar is unsigned." + log="$RUNNER_TEMP/jarsigner-verify.txt" + if ! jarsigner -verify "$aab" > "$log" 2>&1; then + echo "::error::AAB is not signed" + cat "$log" + exit 1 + fi + if ! grep -qx 'jar verified.' "$log"; then + echo "::error::AAB is not signed" + cat "$log" + exit 1 + fi + # Inside the workspace: upload-artifact rejects files outside it. Not the keystore. + dest="build/play-bundle" + mkdir -p "$dest" + cp "$aab" "$dest/app-gms-release.aab" + cp "$mapping" "$dest/mapping.txt" + + - name: Upload the Play bundle + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: play-bundle + path: | + android/build/play-bundle/app-gms-release.aab + android/build/play-bundle/mapping.txt + if-no-files-found: error + retention-days: 7 + + - name: Remove the upload keystore + if: always() + run: | + if [ -z "$RUNNER_TEMP" ]; then + echo "::error::RUNNER_TEMP is unset" + exit 1 + fi + rm -f "$RUNNER_TEMP/upload.jks"