Files
native/.github/workflows/release-android.yml

469 lines
21 KiB
YAML

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<<FILES_EOF"
echo "$DEST"
echo "$DEST.sha256"
if [ -n "$MAPPING" ]; then echo "$MAPPING"; fi
echo "FILES_EOF"
} >> "$GITHUB_OUTPUT"
# Ties the APK to this commit and workflow run, which the signing key
# alone does not: `gh attestation verify <apk> --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