Build Signed Android APK/AAB #32
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 | |
| output_format: | |
| description: "Output Format" | |
| required: true | |
| default: "aab" | |
| type: choice | |
| options: | |
| - apk | |
| - aab | |
| - both | |
| should_sign: | |
| description: "Sign the build?" | |
| required: true | |
| default: "true" | |
| type: choice | |
| options: | |
| - "true" | |
| - "false" | |
| push: | |
| tags: | |
| - "v*.*.*" | |
| jobs: | |
| build-android: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - 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 | |
| - name: Install dependencies | |
| run: npm install --legacy-peer-deps | |
| - name: Expo Prebuild | |
| run: npx expo prebuild --platform android --clean | |
| - name: Setup Gradle cache | |
| uses: gradle/actions/setup-gradle@v3 | |
| with: | |
| gradle-home-cache-cleanup: true | |
| - name: Decode Keystore | |
| run: | | |
| if [ -z "${{ secrets.KEYSTORE_BASE64 }}" ]; then | |
| echo "❌ Missing secret: KEYSTORE_BASE64" | |
| exit 1 | |
| fi | |
| echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > release.keystore | |
| chmod 600 release.keystore | |
| ls -lh release.keystore || true | |
| echo "✅ Keystore decoded" | |
| - name: Make gradlew executable | |
| run: chmod +x android/gradlew | |
| - name: Build Release APK (unsigned) | |
| if: ${{ github.event.inputs.output_format == 'apk' || github.event.inputs.output_format == 'both' }} | |
| run: | | |
| set -e | |
| cd android | |
| ./gradlew assembleRelease --no-daemon --stacktrace | |
| - name: Build Release AAB | |
| if: ${{ github.event.inputs.output_format == 'aab' || github.event.inputs.output_format == 'both' }} | |
| run: | | |
| set -e | |
| cd android | |
| ./gradlew bundleRelease --no-daemon --stacktrace | |
| - name: Inspect environment & SDK | |
| run: | | |
| echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" | |
| echo "Listing available build-tools:" | |
| ls -1 "$ANDROID_SDK_ROOT/build-tools" || true | |
| echo "apksigner version (if available):" | |
| for p in "$ANDROID_SDK_ROOT"/build-tools/*/apksigner; do | |
| if [ -x "$p" ]; then | |
| echo "-> $p" | |
| "$p" version || true | |
| fi | |
| done | |
| echo "java version:" | |
| java -version || true | |
| - name: Inspect Keystore | |
| if: ${{ github.event.inputs.should_sign == 'true' }} | |
| env: | |
| KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} | |
| run: | | |
| set -e | |
| if [ ! -f release.keystore ]; then | |
| echo "❌ release.keystore not found" | |
| exit 1 | |
| fi | |
| echo "=== Keystore contents ===" | |
| keytool -list -v -keystore release.keystore -storepass "${KEYSTORE_PASSWORD}" || true | |
| - name: Sign APK | |
| if: ${{ github.event.inputs.should_sign == 'true' && (github.event.inputs.output_format == 'apk' || github.event.inputs.output_format == 'both') }} | |
| id: sign_apk | |
| env: | |
| KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} | |
| KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} | |
| KEY_ALIAS: ${{ secrets.KEY_ALIAS }} | |
| run: | | |
| set -euo pipefail | |
| cd android || exit 1 | |
| echo "Finding APK (searching typical output locations)..." | |
| # find the most likely release APK (unsigned or signed) | |
| APK_PATH=$(find app/build/outputs/apk -type f -iname "*release*.apk" | sort | tail -n1 || true) | |
| if [ -z "$APK_PATH" ]; then | |
| echo "❌ APK not found!" | |
| ls -R app/build/outputs || true | |
| exit 1 | |
| fi | |
| echo "📦 Found APK: $APK_PATH" | |
| # Locate build-tools | |
| if [ -d "$ANDROID_SDK_ROOT/build-tools/34.0.0" ]; then | |
| BUILD_TOOLS_PATH="$ANDROID_SDK_ROOT/build-tools/34.0.0" | |
| else | |
| BUILD_TOOLS_VERSION=$(ls -1 "$ANDROID_SDK_ROOT/build-tools" | sort -V | tail -1 || true) | |
| if [ -z "$BUILD_TOOLS_VERSION" ]; then | |
| echo "❌ No build-tools found under $ANDROID_SDK_ROOT/build-tools" | |
| exit 1 | |
| fi | |
| BUILD_TOOLS_PATH="$ANDROID_SDK_ROOT/build-tools/$BUILD_TOOLS_VERSION" | |
| fi | |
| echo "Build tools path: $BUILD_TOOLS_PATH" | |
| if [ ! -x "$BUILD_TOOLS_PATH/zipalign" ] || [ ! -x "$BUILD_TOOLS_PATH/apksigner" ]; then | |
| echo "❌ zipalign or apksigner not found in $BUILD_TOOLS_PATH" | |
| ls -l "$BUILD_TOOLS_PATH" || true | |
| exit 1 | |
| fi | |
| echo "🔧 Aligning APK..." | |
| # Always create a fresh aligned APK as input to apksigner | |
| "$BUILD_TOOLS_PATH/zipalign" -v -p 4 "$APK_PATH" release-aligned.apk | |
| echo "🔐 Signing APK..." | |
| "$BUILD_TOOLS_PATH/apksigner" sign \ | |
| --ks ../release.keystore \ | |
| --ks-key-alias "${KEY_ALIAS}" \ | |
| --ks-pass "pass:${KEYSTORE_PASSWORD}" \ | |
| --key-pass "pass:${KEY_PASSWORD}" \ | |
| --out release-signed.apk \ | |
| release-aligned.apk | |
| echo "✅ APK signed: release-signed.apk" | |
| echo "signed_apk_path=release-signed.apk" >> $GITHUB_OUTPUT | |
| - name: Verify APK signature | |
| if: ${{ github.event.inputs.should_sign == 'true' && (github.event.inputs.output_format == 'apk' || github.event.inputs.output_format == 'both') }} | |
| env: | |
| BUILD_TOOLS_PATH: ${{ env.BUILD_TOOLS_PATH }} | |
| run: | | |
| set -euo pipefail | |
| cd android || exit 1 | |
| # re-detect build-tools (for safety) | |
| if [ -d "$ANDROID_SDK_ROOT/build-tools/34.0.0" ]; then | |
| BUILD_TOOLS_PATH="$ANDROID_SDK_ROOT/build-tools/34.0.0" | |
| else | |
| BUILD_TOOLS_VERSION=$(ls -1 "$ANDROID_SDK_ROOT/build-tools" | sort -V | tail -1 || true) | |
| BUILD_TOOLS_PATH="$ANDROID_SDK_ROOT/build-tools/$BUILD_TOOLS_VERSION" | |
| fi | |
| if [ ! -f release-signed.apk ]; then | |
| echo "❌ release-signed.apk not found" | |
| ls -lh . || true | |
| exit 1 | |
| fi | |
| echo "🔍 Verifying APK signature..." | |
| # use exit code of apksigner verify to determine success | |
| if "$BUILD_TOOLS_PATH/apksigner" verify --print-certs release-signed.apk >/dev/null 2>&1; then | |
| echo "✅ APK is properly signed" | |
| echo "" | |
| echo "=== Certificate Information ===" | |
| "$BUILD_TOOLS_PATH/apksigner" verify --print-certs release-signed.apk || true | |
| else | |
| echo "❌ APK signature verification failed!" | |
| "$BUILD_TOOLS_PATH/apksigner" verify release-signed.apk || true | |
| echo "" | |
| echo "Listing release dir for debugging:" | |
| ls -l | |
| exit 1 | |
| fi | |
| - name: Sign AAB | |
| if: ${{ github.event.inputs.should_sign == 'true' && (github.event.inputs.output_format == 'aab' || github.event.inputs.output_format == 'both') }} | |
| env: | |
| KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} | |
| KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} | |
| KEY_ALIAS: ${{ secrets.KEY_ALIAS }} | |
| run: | | |
| set -euo pipefail | |
| cd android || exit 1 | |
| AAB_PATH=$(find app/build/outputs/bundle -type f -iname "*.aab" | sort | tail -n1 || true) | |
| if [ -z "$AAB_PATH" ]; then | |
| echo "❌ AAB not found!" | |
| ls -R app/build/outputs || true | |
| exit 1 | |
| fi | |
| echo "📦 Found AAB: $AAB_PATH" | |
| echo "🔐 Signing AAB with jarsigner..." | |
| # jarsigner may be present on the runner; this will sign the AAB container. | |
| # If you use Play App Signing by Google Play, consider uploading unsigned and let Play handle signing. | |
| jarsigner -verbose \ | |
| -sigalg SHA256withRSA \ | |
| -digestalg SHA-256 \ | |
| -keystore ../release.keystore \ | |
| -storepass "${KEYSTORE_PASSWORD}" \ | |
| -keypass "${KEY_PASSWORD}" \ | |
| "${AAB_PATH}" \ | |
| "${KEY_ALIAS}" | |
| cp "$AAB_PATH" ../release-signed.aab | |
| echo "✅ AAB signed: release-signed.aab" | |
| - name: Upload APK artifact | |
| if: ${{ (github.event.inputs.output_format == 'apk' || github.event.inputs.output_format == 'both') && github.event.inputs.should_sign == 'true' }} | |
| 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 | |
| if: ${{ (github.event.inputs.output_format == 'aab' || github.event.inputs.output_format == 'both') && github.event.inputs.should_sign == 'true' }} | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: app-release-aab-${{ github.run_number }} | |
| path: release-signed.aab | |
| retention-days: 30 | |
| - name: Cleanup sensitive files | |
| if: always() | |
| run: | | |
| rm -f release.keystore || true | |
| rm -f android/release-aligned.apk || true | |
| rm -f android/release-signed.apk || true | |
| rm -f release-signed.aab || true | |
| echo "✅ Cleanup completed" |