From 8d60b00b1b8fb1025eafa7401c569e6d486170be Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 20 Jul 2026 01:48:23 -0400 Subject: [PATCH] feat(#330): release-signed APK + AAB behind an approval-gated environment CI has only ever produced debug-signed Android artifacts, because build.gradle.kts:63-70 falls back to signingConfigs "debug" when key.properties is absent, which is exactly the CI condition (#111). Debug-signed builds cannot be installed over a Play install or over a properly signed GitHub release, so users hit "App not installed". Adds a separate workflow that signs with the real keystore. Security posture, given this repo is PUBLIC and the keystore is the one credential that cannot be replaced if leaked: - No pull_request trigger. A PR, including from a fork, must never run a job that can read these secrets. - Pinned to the release-signing Environment, which carries a required reviewer and only accepts main or v* tags. The signing secrets are scoped to that environment, NOT to the repo, so a workflow that omits the environment key cannot read them at all. - Keystore decoded to disk only for the build, removed in a step with if: always() so a failed build leaves nothing behind. - Secrets passed via env: and printf'd into a file, never placed on a command line where they would appear in process listings, and never echoed. Includes an apksigner check that fails the job if the built APK carries CN=Android Debug. A green build alone does not prove release signing, because the gradle fallback is silent; that check is what makes this verifiable rather than assumed. The keystore is PKCS12 despite its .jks extension, and PKCS12 cannot carry a key password distinct from the store password, so one secret correctly populates both fields. Part of epic #312, plan #321 (Phase 5). --- .github/workflows/release-signed.yml | 150 +++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 .github/workflows/release-signed.yml diff --git a/.github/workflows/release-signed.yml b/.github/workflows/release-signed.yml new file mode 100644 index 0000000..719401c --- /dev/null +++ b/.github/workflows/release-signed.yml @@ -0,0 +1,150 @@ +name: Release (signed) + +# Produces RELEASE-SIGNED Android artifacts using the project's real keystore. +# +# SECURITY: this repo is PUBLIC and this workflow has access to the signing +# keystore, which is the single most irreplaceable credential in the project. +# If it leaks, the app can never be updated again on any channel (GitHub, +# F-Droid, Play all require a matching signature). +# +# Therefore: +# - NEVER add a `pull_request` trigger here. A PR (including from a fork) +# must never be able to run a job that can read these secrets. +# - The job is pinned to the `release-signing` Environment, which carries a +# required reviewer, so a signing run cannot proceed unattended. +# - The keystore is written to disk only for the duration of the build and +# deleted in a step that runs even when the build fails. +# - Signing values are passed via `env:`, never interpolated into a command +# line (where they would be visible in process listings) and never echoed. +# +# Debug-signed artifacts for day-to-day PR testing continue to come from +# build.yml and are unaffected by this workflow. See #330, epic #312. + +on: + push: + branches: + - main + tags: + - "v*" + workflow_dispatch: + +concurrency: + group: release-signed-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + android-signed: + runs-on: ubuntu-latest + # Required-reviewer gate. Do not remove. + environment: release-signing + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + + - uses: subosito/flutter-action@v2 + with: + channel: "stable" + cache: true + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/build.gradle', 'android/settings.gradle', 'android/app/build.gradle', 'pubspec.lock') }} + restore-keys: | + ${{ runner.os }}-gradle- + + # The keystore is PKCS12 (despite the .jks extension), which does not + # support a key password distinct from the store password. One secret + # therefore correctly populates both fields. See #330. + - name: Decode signing keystore + env: + KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + run: | + set -eu + if [ -z "${KEYSTORE_BASE64}" ] || [ -z "${KEYSTORE_PASSWORD}" ]; then + echo "::error::Signing secrets are not available to this run." + exit 1 + fi + printf '%s' "${KEYSTORE_BASE64}" | base64 -d > android/app/release.jks + # Fail loudly if the decode produced something implausible rather + # than letting Gradle fall back to debug signing silently. + SIZE=$(stat -c%s android/app/release.jks) + if [ "${SIZE}" -lt 1000 ]; then + echo "::error::Decoded keystore is ${SIZE} bytes; expected ~2.8 KB. Secret is malformed." + exit 1 + fi + umask 077 + cat > android/key.properties <<'PROPS' + storeFile=release.jks + keyAlias=meshcore + PROPS + { + printf 'storePassword=%s\n' "${KEYSTORE_PASSWORD}" + printf 'keyPassword=%s\n' "${KEYSTORE_PASSWORD}" + } >> android/key.properties + echo "Keystore decoded (${SIZE} bytes), key.properties written." + + - run: flutter pub get + + - name: Build signed APK + run: flutter build apk --release --no-pub + + - name: Build signed AAB + run: flutter build appbundle --release --no-pub + + # The load-bearing check. A green build does NOT prove the artifact is + # release-signed: build.gradle.kts silently falls back to the debug + # signing config when key.properties is missing or malformed (#111), + # which is exactly the failure this workflow exists to prevent. So we + # read the certificate off the built APK and fail if it is the debug key. + - name: Verify APK is signed with the release certificate + run: | + set -eu + APK=build/app/outputs/flutter-apk/app-release.apk + BUILD_TOOLS=$(ls -d "${ANDROID_HOME}"/build-tools/* | sort -V | tail -1) + APKSIGNER="${BUILD_TOOLS}/apksigner" + echo "Using ${APKSIGNER}" + CERTS=$("${APKSIGNER}" verify --print-certs "${APK}") + echo "${CERTS}" + if echo "${CERTS}" | grep -qi "CN=Android Debug"; then + echo "::error::APK is DEBUG-SIGNED. key.properties was not picked up; see #111." + exit 1 + fi + echo "${CERTS}" | grep -i "SHA-256 digest" | head -1 + echo "Release certificate confirmed (not the Android Debug key)." + + - name: Upload signed APK + uses: actions/upload-artifact@v4 + with: + name: offband-signed-apk-r${{ github.run_number }}-${{ github.sha }} + path: build/app/outputs/flutter-apk/app-release.apk + if-no-files-found: error + retention-days: 30 + + - name: Upload signed AAB + uses: actions/upload-artifact@v4 + with: + name: offband-signed-aab-r${{ github.run_number }}-${{ github.sha }} + path: build/app/outputs/bundle/release/app-release.aab + if-no-files-found: error + retention-days: 30 + + # Runs even when an earlier step failed. Without `if: always()` a build + # failure would leave the keystore and its password on the runner. + - name: Remove keystore material + if: always() + run: | + rm -f android/app/release.jks android/key.properties + echo "Keystore material removed."