name: Release Android on: release: types: [published] workflow_dispatch: inputs: attach_to_release_tag: description: "Optional release tag to attach the APK to (leave empty to skip upload)" required: false default: "" # Signing is described in docs/android-release.md. env: # Certificate SHA-256 of android/app/debug.keystore, the stock React Native # template key (CN=Android Debug, SHA-1 5E:8F:16:06:...:F6:25). It is public, # so Android 9+ must never see it as the signer of a published build. DEBUG_CERT_SHA256: fac61745dc0903786fb9ede62a962b399f7348f0bb6f899b8332667591033b9c jobs: build: runs-on: ubuntu-latest timeout-minutes: 45 permissions: contents: write id-token: write attestations: write steps: # Fail in seconds instead of after a 20-minute build. There is no # unsigned or debug-signed fallback: anything signed with the debug key # can be "updated" by anyone. - name: Require the release signing secrets env: KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | missing="" [ -n "$KEYSTORE_BASE64" ] || missing="$missing ANDROID_KEYSTORE_BASE64" [ -n "$KEYSTORE_PASSWORD" ] || missing="$missing ANDROID_KEYSTORE_PASSWORD" [ -n "$KEY_ALIAS" ] || missing="$missing ANDROID_KEY_ALIAS" [ -n "$KEY_PASSWORD" ] || missing="$missing ANDROID_KEY_PASSWORD" if [ -n "$missing" ]; then echo "::error title=Release signing is not configured::Missing repository secrets:$missing. Release APKs are never signed with the public debug key; see docs/android-release.md." exit 1 fi - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - uses: actions/setup-java@v4 with: distribution: temurin java-version: 17 cache: gradle # The action installs the legacy `tools` package by default, which the # runner's cmdline-tools 16 no longer carries ("Failed to find package # 'tools'"). Gradle fetches the platforms and build-tools it needs. - uses: android-actions/setup-android@v3 with: packages: platform-tools - name: Cache Android NDK / CMake uses: actions/cache@v4 with: path: | ~/.android/build-cache /usr/local/lib/android/sdk/ndk /usr/local/lib/android/sdk/cmake key: android-sdk-${{ runner.os }}-${{ hashFiles('android/build.gradle', 'android/gradle.properties', 'android/app/build.gradle') }} restore-keys: android-sdk-${{ runner.os }}- - name: Compute native cache key input run: | node -e " const fs = require('fs'); const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8')); if (lock.packages && lock.packages['']) delete lock.packages[''].version; delete lock.version; fs.writeFileSync('.cxx-cache-input.json', JSON.stringify(lock.packages || {})); " - name: Cache native CMake build outputs uses: actions/cache@v4 with: path: | android/app/.cxx android/app/build/intermediates/cxx android/app/build/intermediates/merged_native_libs android/app/build/intermediates/stripped_native_libs key: cxx-${{ runner.os }}-${{ hashFiles('.cxx-cache-input.json', 'android/build.gradle', 'android/app/build.gradle') }} restore-keys: cxx-${{ runner.os }}- - name: Install JS dependencies run: npm ci # Checks the password and alias before the long build, and refuses a # debug key that was uploaded as the release key by mistake. - name: Decode and check the release key id: key env: KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} run: | set -euo pipefail KEYSTORE="$RUNNER_TEMP/release.keystore" CERT="$RUNNER_TEMP/release-cert.der" echo "$KEYSTORE_BASE64" | base64 -d > "$KEYSTORE" if ! keytool -exportcert -keystore "$KEYSTORE" -alias "$KEY_ALIAS" \ -storepass:env KEYSTORE_PASSWORD -file "$CERT"; then echo "::error title=Release key unreadable::Could not read alias ANDROID_KEY_ALIAS from ANDROID_KEYSTORE_BASE64 with ANDROID_KEYSTORE_PASSWORD." exit 1 fi CERT_SHA256=$(sha256sum "$CERT" | cut -d' ' -f1) OWNER=$(keytool -printcert -file "$CERT") if [ "$CERT_SHA256" = "$DEBUG_CERT_SHA256" ] || [[ "$OWNER" == *"CN=Android Debug"* ]]; then echo "::error title=Release key is a debug key::ANDROID_KEYSTORE_BASE64 holds an Android debug key. Generate a release key as described in docs/android-release.md." exit 1 fi echo "Release certificate SHA-256: $CERT_SHA256" echo "store_file=$KEYSTORE" >> "$GITHUB_OUTPUT" echo "cert_sha256=$CERT_SHA256" >> "$GITHUB_OUTPUT" - name: Build release APK working-directory: android env: ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_FILE: ${{ steps.key.outputs.store_file }} ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | chmod +x ./gradlew ./gradlew assembleGithubRelease --no-daemon - name: Put apksigner on the PATH run: | BUILD_TOOLS=$(ls -d "$ANDROID_HOME"/build-tools/*/ | sort -V | tail -n 1) echo "Using $BUILD_TOOLS" echo "$BUILD_TOOLS" >> "$GITHUB_PATH" # Optional v3 key rotation away from the debug key that signed every # release up to 0.1.62. Gradle signed the APK with the release key only, # which existing installs reject as a different app. With a lineage the # APK is re-signed so that Android 9+ sees the release key plus proof # that the debug key handed over to it, while Android 7-8.1 (v2 only) # still see the debug key. docs/android-release.md has the details. - name: Apply the signing key rotation id: rotation env: LINEAGE_BASE64: ${{ secrets.ANDROID_SIGNING_LINEAGE_BASE64 }} KEYSTORE: ${{ steps.key.outputs.store_file }} KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} RELEASE_CERT_SHA256: ${{ steps.key.outputs.cert_sha256 }} run: | set -euo pipefail if [ -z "$LINEAGE_BASE64" ]; then echo "No ANDROID_SIGNING_LINEAGE_BASE64: keeping the release-key-only signature." echo "rotated=false" >> "$GITHUB_OUTPUT" exit 0 fi LINEAGE="$RUNNER_TEMP/signing.lineage" echo "$LINEAGE_BASE64" | base64 -d > "$LINEAGE" apksigner lineage --in "$LINEAGE" --print-certs | tee "$RUNNER_TEMP/lineage.txt" DIGESTS=$(sed -n 's/^Signer #[0-9]* in lineage certificate SHA-256 digest: //p' "$RUNNER_TEMP/lineage.txt") if [ "$(sed -n '1p' <<< "$DIGESTS")" != "$DEBUG_CERT_SHA256" ] \ || [ "$(sed -n '$p' <<< "$DIGESTS")" != "$RELEASE_CERT_SHA256" ]; then echo "::error title=Wrong signing lineage::ANDROID_SIGNING_LINEAGE_BASE64 must lead from the debug key to the key in ANDROID_KEYSTORE_BASE64." exit 1 fi # Rollback on an old signer lets anything signed with it take the app # back after the rotation, which would undo the whole point. PAST_ROLLBACK=$(grep 'Has rollback capability' "$RUNNER_TEMP/lineage.txt" | sed -e 's/.*: *//' -e '$d') if [[ "$PAST_ROLLBACK" == *true* ]]; then echo "::error title=Unsafe signing lineage::An old signer in the lineage has the rollback capability. Recreate it with --set-rollback false." exit 1 fi # Oldest signer first: apksigner uses it for v2, and the rotated key # for v3 from API 28. Without --rotation-min-sdk-version it would # only rotate on Android 13+ (v3.1) and leave 9-12L on the debug key. APK=android/app/build/outputs/apk/github/release/app-github-release.apk apksigner sign \ --v4-signing-enabled false \ --ks android/app/debug.keystore --ks-key-alias androiddebugkey \ --ks-pass pass:android --key-pass pass:android \ --next-signer \ --ks "$KEYSTORE" --ks-key-alias "$KEY_ALIAS" \ --ks-pass env:KEYSTORE_PASSWORD --key-pass env:KEY_PASSWORD \ --lineage "$LINEAGE" \ --rotation-min-sdk-version 28 \ --out "$RUNNER_TEMP/app-release-rotated.apk" \ "$APK" mv "$RUNNER_TEMP/app-release-rotated.apk" "$APK" echo "rotated=true" >> "$GITHUB_OUTPUT" # The last gate before anything is uploaded. - name: Verify the APK signature env: RELEASE_CERT_SHA256: ${{ steps.key.outputs.cert_sha256 }} ROTATED: ${{ steps.rotation.outputs.rotated }} run: | set -euo pipefail APK=android/app/build/outputs/apk/github/release/app-github-release.apk apksigner verify --verbose --print-certs "$APK" # build-tools 36 prints "Signer #1 certificate SHA-256 digest: …", # 37 prints "V3.0 Signer: certificate …" and "V2 Signer: …". signers() { apksigner verify --print-certs "$@" "$APK" \ | sed -n -E 's/^(Signer #[0-9]+|V[0-9.]+ Signer:) certificate SHA-256 digest: //p' \ | sort -u } MODERN=$(signers --min-sdk-version 28) LEGACY=$(signers --max-sdk-version 27) echo "Signer on Android 9+: $MODERN" echo "Signer on Android 7-8.1: $LEGACY" if [[ "$MODERN" == *"$DEBUG_CERT_SHA256"* ]]; then echo "::error title=APK is debug-signed::Android 9+ would see the public debug key as the signer." exit 1 fi if [ "$MODERN" != "$RELEASE_CERT_SHA256" ]; then echo "::error title=Wrong APK signer::Android 9+ would see '$MODERN', expected only the release key $RELEASE_CERT_SHA256." exit 1 fi if [ "$ROTATED" = "true" ]; then echo "::warning title=Android 7-8.1 still trust the debug key::The v2 signature is made with the debug key so that installs on Android 7-8.1 can update. Those devices remain exposed; see docs/android-release.md." elif [ "$LEGACY" != "$RELEASE_CERT_SHA256" ]; then echo "::error title=Wrong APK signer::Android 7-8.1 would see '$LEGACY', expected only the release key $RELEASE_CERT_SHA256." exit 1 fi aapt2 dump badging "$APK" | grep '^package:' || true # The R8 mapping goes out with the APK: without the one from this exact # build, the obfuscated frames in a release stack trace cannot be # decoded (docs/android-release.md, "Stack traces"). - name: Rename APK and R8 mapping with version id: artifact run: | set -euo pipefail VERSION=$(cat VERSION | tr -d '[:space:]') SRC=android/app/build/outputs/apk/github/release/app-github-release.apk DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.apk" cp "$SRC" "$DEST" sha256sum "$DEST" > "$DEST.sha256" MAPPING_SRC=android/app/build/outputs/mapping/githubRelease/mapping.txt MAPPING="" if [ -f "$MAPPING_SRC" ]; then MAPPING="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}-mapping.txt" cp "$MAPPING_SRC" "$MAPPING" elif grep -Eq '^android\.enableMinifyInReleaseBuilds=true\s*$' android/gradle.properties; then echo "::error title=R8 mapping missing::R8 is on but the build wrote no $MAPPING_SRC, so stack traces from this APK could not be decoded." exit 1 fi echo "path=$DEST" >> "$GITHUB_OUTPUT" echo "mapping=$MAPPING" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" { echo "files<> "$GITHUB_OUTPUT" # Ties the APK to this commit and workflow run, which the signing key # alone does not: `gh attestation verify --repo bulwarkmail/native`. - name: Attest build provenance uses: actions/attest@v4 with: subject-path: ${{ steps.artifact.outputs.path }} - uses: actions/upload-artifact@v4 with: name: ${{ steps.artifact.outputs.path }} path: ${{ steps.artifact.outputs.path }} if-no-files-found: error - uses: actions/upload-artifact@v4 if: steps.artifact.outputs.mapping != '' with: name: ${{ steps.artifact.outputs.mapping }} path: ${{ steps.artifact.outputs.mapping }} if-no-files-found: error - name: Resolve target release tag id: target_tag env: GH_TOKEN: ${{ github.token }} run: | # A manual run with no tag only builds: it must not replace the APK # of the latest release with one from another commit. if [ "${{ github.event_name }}" = "release" ]; then TAG="${{ github.event.release.tag_name }}" else TAG="${{ github.event.inputs.attach_to_release_tag }}" fi echo "tag=$TAG" >> "$GITHUB_OUTPUT" if [ -n "$TAG" ]; then echo "Will attach APK to release: $TAG" else echo "No release tag resolved - APK is in workflow artifacts only." fi - name: Attach APK and R8 mapping to release if: steps.target_tag.outputs.tag != '' uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.target_tag.outputs.tag }} files: ${{ steps.artifact.outputs.files }} # The Google Play bundle (docs/android-release.md, "Google Play"). It is # signed with the release key, which Play knows as the upload key; Play # re-signs what it delivers with its own app signing key. A separate job so # the GitHub APK isn't held up by the three-ABI native build. play-bundle: runs-on: ubuntu-latest timeout-minutes: 75 permissions: contents: read env: PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} steps: - name: Require the release signing secrets env: KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | missing="" [ -n "$KEYSTORE_BASE64" ] || missing="$missing ANDROID_KEYSTORE_BASE64" [ -n "$KEYSTORE_PASSWORD" ] || missing="$missing ANDROID_KEYSTORE_PASSWORD" [ -n "$KEY_ALIAS" ] || missing="$missing ANDROID_KEY_ALIAS" [ -n "$KEY_PASSWORD" ] || missing="$missing ANDROID_KEY_PASSWORD" if [ -n "$missing" ]; then echo "::error title=Release signing is not configured::Missing repository secrets:$missing. See docs/android-release.md." exit 1 fi - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - uses: actions/setup-java@v4 with: distribution: temurin java-version: 17 cache: gradle - uses: android-actions/setup-android@v3 with: packages: platform-tools - name: Install JS dependencies run: npm ci - name: Decode and check the upload key id: key env: KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} run: | set -euo pipefail KEYSTORE="$RUNNER_TEMP/release.keystore" CERT="$RUNNER_TEMP/release-cert.der" echo "$KEYSTORE_BASE64" | base64 -d > "$KEYSTORE" keytool -exportcert -keystore "$KEYSTORE" -alias "$KEY_ALIAS" \ -storepass:env KEYSTORE_PASSWORD -file "$CERT" CERT_SHA256=$(sha256sum "$CERT" | cut -d' ' -f1) if [ "$CERT_SHA256" = "$DEBUG_CERT_SHA256" ]; then echo "::error title=Upload key is a debug key::ANDROID_KEYSTORE_BASE64 holds the Android debug key." exit 1 fi echo "store_file=$KEYSTORE" >> "$GITHUB_OUTPUT" echo "cert_sha256=$CERT_SHA256" >> "$GITHUB_OUTPUT" # Play splits a bundle per ABI, so the extra architectures cost a phone # nothing and let 32-bit ARM phones and x86_64 Chromebooks install it. - name: Build the Play bundle working-directory: android env: ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_FILE: ${{ steps.key.outputs.store_file }} ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | chmod +x ./gradlew ./gradlew bundlePlayRelease --no-daemon -PreactNativeArchitectures=armeabi-v7a,arm64-v8a,x86_64 # Play rejects a bundle signed with another key than the registered # upload key, and its policies reject a self-updater and the media # permission. Catch all three here instead of in the Play Console. - name: Check the bundle env: RELEASE_CERT_SHA256: ${{ steps.key.outputs.cert_sha256 }} run: | set -euo pipefail AAB=android/app/build/outputs/bundle/playRelease/app-play-release.aab SIGNER=$(keytool -printcert -jarfile "$AAB" | sed -n 's/^[[:space:]]*SHA256: //p' | head -n 1 | tr -d ':' | tr 'A-F' 'a-f') echo "Bundle signer SHA-256: $SIGNER" if [ "$SIGNER" != "$RELEASE_CERT_SHA256" ]; then echo "::error title=Wrong bundle signer::Expected the upload key $RELEASE_CERT_SHA256, got '$SIGNER'." exit 1 fi MANIFEST=$(unzip -p "$AAB" base/manifest/AndroidManifest.xml | tr -c '[:print:]' '\n') if ! grep -q 'org.bulwarkmail.mobile' <<< "$MANIFEST"; then echo "::error title=Wrong package::The bundle is not org.bulwarkmail.mobile." exit 1 fi for permission in REQUEST_INSTALL_PACKAGES READ_MEDIA_IMAGES READ_MEDIA_VIDEO RECORD_AUDIO; do if grep -q "android.permission.$permission" <<< "$MANIFEST"; then echo "::error title=Permission not allowed on Play::The Play bundle declares $permission." exit 1 fi done - name: Name the bundle and R8 mapping with version id: artifact run: | set -euo pipefail VERSION=$(cat VERSION | tr -d '[:space:]') AAB="bulwark-mobile-play-${VERSION}-${GITHUB_SHA::7}.aab" MAPPING="bulwark-mobile-play-${VERSION}-${GITHUB_SHA::7}-mapping.txt" cp android/app/build/outputs/bundle/playRelease/app-play-release.aab "$AAB" cp android/app/build/outputs/mapping/playRelease/mapping.txt "$MAPPING" echo "aab=$AAB" >> "$GITHUB_OUTPUT" echo "mapping=$MAPPING" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - uses: actions/upload-artifact@v4 with: name: ${{ steps.artifact.outputs.aab }} path: | ${{ steps.artifact.outputs.aab }} ${{ steps.artifact.outputs.mapping }} if-no-files-found: error # Runs once PLAY_SERVICE_ACCOUNT_JSON is set (Play only accepts API # uploads after the first bundle was uploaded by hand in the Console). # Every release goes out to the internal and closed (beta) testers right # away; closed testing may wait for Google's review first. - name: Upload to Google Play (internal and closed testing) if: env.PLAY_SERVICE_ACCOUNT_JSON != '' uses: r0adkll/upload-google-play@v1 with: serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} packageName: org.bulwarkmail.mobile releaseFiles: ${{ steps.artifact.outputs.aab }} mappingFile: ${{ steps.artifact.outputs.mapping }} releaseName: ${{ steps.artifact.outputs.version }} tracks: internal,beta status: completed