Build Signed Android APK/AAB #26
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Build Signed Android APK/AAB | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| build_type: | |
| description: "Build type" | |
| required: true | |
| default: "release" | |
| type: choice | |
| options: | |
| - debug | |
| - release | |
| push: | |
| tags: | |
| - "v*.*.*" | |
| jobs: | |
| build-android: | |
| runs-on: ubuntu-latest | |
| env: | |
| # Make the keystore path explicit – we keep it in the repo root. | |
| KEYSTORE_PATH: ${{ github.workspace }}/release.keystore | |
| steps: | |
| # ------------------------------------------------- | |
| # 0️⃣ Checkout & basic tooling | |
| # ------------------------------------------------- | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: "20" | |
| cache: "npm" | |
| - name: Set up JDK 17 | |
| uses: actions/setup-java@v4 | |
| with: | |
| distribution: "temurin" | |
| java-version: "17" | |
| - name: Setup Android SDK | |
| uses: android-actions/setup-android@v3 | |
| # ------------------------------------------------- | |
| # 1️⃣ Install npm deps | |
| # ------------------------------------------------- | |
| - name: Install dependencies | |
| run: npm ci --legacy-peer-deps # faster & reproducible | |
| # ------------------------------------------------- | |
| # 2️⃣ Expo pre‑build (generates the Android project) | |
| # ------------------------------------------------- | |
| - name: Expo Prebuild | |
| run: npx expo prebuild --platform android --clean | |
| # ------------------------------------------------- | |
| # 3️⃣ Gradle cache (speeds up later runs) | |
| # ------------------------------------------------- | |
| - name: Setup Gradle cache | |
| uses: gradle/actions/setup-gradle@v3 | |
| with: | |
| gradle-home-cache-cleanup: true | |
| # ------------------------------------------------- | |
| # 4️⃣ Decode the Keystore (store it where every step can read it) | |
| # ------------------------------------------------- | |
| - name: Decode Keystore | |
| run: | | |
| echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > "$KEYSTORE_PATH" | |
| chmod 600 "$KEYSTORE_PATH" | |
| echo "✅ Keystore written to $KEYSTORE_PATH" | |
| # ------------------------------------------------- | |
| # 5️⃣ Make gradlew executable | |
| # ------------------------------------------------- | |
| - name: Make gradlew executable | |
| run: chmod +x android/gradlew | |
| # ------------------------------------------------- | |
| # 6️⃣ Build the **unsigned** Release APK | |
| # ------------------------------------------------- | |
| - name: Build Release APK (Unsigned) | |
| run: | | |
| cd android | |
| ./gradlew assembleRelease --no-daemon | |
| # ------------------------------------------------- | |
| # 7️⃣ Sign the APK | |
| # ------------------------------------------------- | |
| - name: Sign APK | |
| id: sign_apk | |
| run: | | |
| set -euo pipefail | |
| cd android | |
| # locate the unsigned APK (covers both *-unsigned.apk and fallback *-release.apk) | |
| APK_PATH=$(find ./app/build/outputs/apk/release \ | |
| \( -name "*-release-unsigned.apk" -o -name "*-release.apk" \) \ | |
| -print -quit) | |
| if [[ -z "$APK_PATH" ]]; then | |
| echo "❌ No APK found – aborting." | |
| exit 1 | |
| fi | |
| echo "📦 Found APK: $APK_PATH" | |
| # Use ANDROID_SDK_ROOT fallback if ANDROID_HOME is not set | |
| SDK_ROOT="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" | |
| if [[ -z "$SDK_ROOT" ]]; then | |
| echo "❌ ANDROID_HOME and ANDROID_SDK_ROOT are not set – aborting." | |
| exit 1 | |
| fi | |
| # Pick the newest build-tools folder (no hard‑coded version) | |
| BUILD_TOOLS_DIR=$(ls -1 "$SDK_ROOT/build-tools" | sort -V | tail -n1) | |
| ZIPALIGN="$SDK_ROOT/build-tools/$BUILD_TOOLS_DIR/zipalign" | |
| APKSIGNER="$SDK_ROOT/build-tools/$BUILD_TOOLS_DIR/apksigner" | |
| # Align the APK (required before signing) | |
| "$ZIPALIGN" -v -p 4 "$APK_PATH" release-aligned.apk | |
| echo "✅ Aligned → release-aligned.apk" | |
| # Sign the aligned APK | |
| "$APKSIGNER" sign \ | |
| --ks "$KEYSTORE_PATH" \ | |
| --ks-key-alias "${{ secrets.KEY_ALIAS }}" \ | |
| --ks-pass "pass:${{ secrets.KEYSTORE_PASSWORD }}" \ | |
| --key-pass "pass:${{ secrets.KEY_PASSWORD }}" \ | |
| --out release-signed.apk \ | |
| release-aligned.apk | |
| echo "✅ Signed APK → release-signed.apk" | |
| # We keep the signed APK inside the android/ directory (repo path: android/release-signed.apk) | |
| echo "signed_apk_path=android/release-signed.apk" >> $GITHUB_OUTPUT | |
| # ------------------------------------------------- | |
| # 8️⃣ Build the Release AAB (Android App Bundle) | |
| # ------------------------------------------------- | |
| - name: Build Release AAB | |
| run: | | |
| cd android | |
| ./gradlew bundleRelease --no-daemon | |
| # ------------------------------------------------- | |
| # 9️⃣ Sign the AAB (jarsigner) | |
| # ------------------------------------------------- | |
| - name: Sign AAB | |
| run: | | |
| cd android | |
| AAB_PATH=$(find ./app/build/outputs/bundle/release -name "*-release.aab" -print -quit) | |
| if [[ -z "$AAB_PATH" ]]; then | |
| echo "❌ No AAB found – aborting." | |
| exit 1 | |
| fi | |
| echo "📦 Found AAB: $AAB_PATH" | |
| jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \ | |
| -keystore "$KEYSTORE_PATH" \ | |
| -storepass "${{ secrets.KEYSTORE_PASSWORD }}" \ | |
| -keypass "${{ secrets.KEY_PASSWORD }}" \ | |
| "$AAB_PATH" "${{ secrets.KEY_ALIAS }}" | |
| # Move signed AAB to android/release-signed.aab (kept inside android/ directory) | |
| mv "$AAB_PATH" release-signed.aab | |
| echo "✅ Signed AAB → android/release-signed.aab" | |
| # ------------------------------------------------- | |
| # 🔍 Verify the signed APK (uses the same android/release-signed.apk path) | |
| # ------------------------------------------------- | |
| - name: Verify APK signature | |
| run: | | |
| set -euo pipefail | |
| # Use ANDROID_SDK_ROOT fallback if ANDROID_HOME is not set | |
| SDK_ROOT="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" | |
| if [[ -z "$SDK_ROOT" ]]; then | |
| echo "❌ ANDROID_HOME and ANDROID_SDK_ROOT are not set – aborting." | |
| exit 1 | |
| fi | |
| BUILD_TOOLS_DIR=$(ls -1 "$SDK_ROOT/build-tools" | sort -V | tail -n1) | |
| APKSIGNER="$SDK_ROOT/build-tools/$BUILD_TOOLS_DIR/apksigner" | |
| APK_FILE="android/release-signed.apk" | |
| if [[ ! -f "$APK_FILE" ]]; then | |
| echo "❌ $APK_FILE not found – aborting." | |
| exit 1 | |
| fi | |
| echo "📦 Verifying: $APK_FILE" | |
| "$APKSIGNER" verify --print-certs "$APK_FILE" | |
| # Optional: extract the SHA‑1 fingerprint for further checks | |
| SHA1=$("$APKSIGNER" verify --print-certs "$APK_FILE" | | |
| grep "SHA-1 digest:" | head -1 | awk '{print $3}') | |
| echo "Detected SHA‑1: $SHA1" | |
| # ------------------------------------------------- | |
| # 📦 Upload the signed artifacts | |
| # ------------------------------------------------- | |
| - name: Upload APK artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: app-release-apk-${{ github.run_number }} | |
| path: android/release-signed.apk | |
| retention-days: 30 | |
| - name: Upload AAB artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: app-release-aab-${{ github.run_number }} | |
| path: android/release-signed.aab | |
| retention-days: 30 | |
| # ------------------------------------------------- | |
| # 🧹 Secure cleanup (always runs, even on failure) | |
| # ------------------------------------------------- | |
| - name: Cleanup sensitive files | |
| if: always() | |
| run: | | |
| rm -f "$KEYSTORE_PATH" | |
| echo "✅ Keystore removed" |