mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
311 lines
14 KiB
YAML
311 lines
14 KiB
YAML
name: Release Android APK
|
|
|
|
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
|
|
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 assembleRelease --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/release/app-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/release/app-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/release/app-release.apk
|
|
DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.apk"
|
|
cp "$SRC" "$DEST"
|
|
|
|
MAPPING_SRC=android/app/build/outputs/mapping/release/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"
|
|
if [ -n "$MAPPING" ]; then echo "$MAPPING"; fi
|
|
echo "FILES_EOF"
|
|
} >> "$GITHUB_OUTPUT"
|
|
|
|
- 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: |
|
|
if [ "${{ github.event_name }}" = "release" ]; then
|
|
TAG="${{ github.event.release.tag_name }}"
|
|
elif [ -n "${{ github.event.inputs.attach_to_release_tag }}" ]; then
|
|
TAG="${{ github.event.inputs.attach_to_release_tag }}"
|
|
else
|
|
TAG=$(gh release list --limit 1 --json tagName --jq '.[0].tagName' || true)
|
|
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 }}
|