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 env: # SHA-256 of the strycher-personal.jks signing certificate, taken # from artifacts already published and installed by users: releases # b55 and b58, and a local release build. Established without the # keystore password, since reading a certificate off a signed APK # needs no password. # # Pinning the exact value (rather than only rejecting the debug key) # also catches a DIFFERENT key being substituted, which would break # cross-channel updates against Play just as badly as debug signing. EXPECTED_SHA256: e7da8cd5bf22ac3eda7cb954b8b120a18b6c4382d1b6e6fdd204ddedddaf5488 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}") # Debug fallback is the #111 failure mode and the reason this check # exists: build.gradle.kts silently signs with the debug config when # key.properties is absent, and a green build hides it completely. 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 ACTUAL=$(echo "${CERTS}" \ | grep -i "Signer #1 certificate SHA-256 digest" \ | head -1 \ | awk -F': ' '{print $2}' \ | tr -d '[:space:]') if [ -z "${ACTUAL}" ]; then echo "::error::Could not read a certificate SHA-256 from the APK." exit 1 fi if [ "${ACTUAL}" != "${EXPECTED_SHA256}" ]; then echo "::error::Signing certificate MISMATCH." echo "::error::expected ${EXPECTED_SHA256}" echo "::error::actual ${ACTUAL}" echo "::error::This APK would not install over an existing Offband install." exit 1 fi echo "Signing certificate verified: ${ACTUAL}" - 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."