name: Build Android Candidate on: workflow_dispatch: inputs: candidate_label: description: Optional label for the Actions artifacts, such as smoke or rc1 required: false default: '' type: string permissions: contents: read concurrency: group: android-candidate-${{ github.ref }} cancel-in-progress: false env: NODE_VERSION: 22.x JAVA_VERSION: '17' jobs: validate: name: Validate candidate source runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Require main branch shell: bash run: | if [ "$GITHUB_REF" != "refs/heads/main" ]; then echo "Android signing candidates must be built from main; received $GITHUB_REF." >&2 exit 1 fi - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: ${{ env.NODE_VERSION }} cache: npm - name: Install dependencies run: npm ci - name: Validate release identity run: npm run release:validate - name: Typecheck run: npm run typecheck - name: Lint run: npm run lint - name: Run deterministic tests run: npm run test:release github-apk: name: Build signed GitHub APK needs: validate runs-on: ubuntu-latest environment: android-release env: ASTRA_DISTRIBUTION: github steps: - uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: ${{ env.NODE_VERSION }} cache: npm - name: Setup Java uses: actions/setup-java@v5 with: distribution: temurin java-version: ${{ env.JAVA_VERSION }} - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 - name: Install dependencies run: npm ci - name: Validate GitHub release identity run: node scripts/release/android-release.mjs validate github - name: Generate Android project from tracked configuration run: npx expo prebuild --platform android --no-install --clean - name: Verify generated native customizations shell: bash run: | grep -Fq "ASTRA VENDORED KOTLIN AUDIO" android/settings.gradle grep -Fq "ASTRA KOTLIN AUDIO SUBSTITUTION" android/build.gradle grep -Fq "ASTRA RELEASE SIGNING" android/app/build.gradle grep -Fq 'android:shell="true"' android/app/src/main/AndroidManifest.xml - name: Decode app-signing keystore env: ASTRA_KEYSTORE_BASE64: ${{ secrets.ANDROID_APP_SIGNING_KEYSTORE_BASE64 }} shell: bash run: | if [ -z "$ASTRA_KEYSTORE_BASE64" ]; then echo "ANDROID_APP_SIGNING_KEYSTORE_BASE64 is not configured." >&2 exit 1 fi printf '%s' "$ASTRA_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/astra-app-signing.jks" chmod 600 "$RUNNER_TEMP/astra-app-signing.jks" - name: Build ARM-universal release APK working-directory: android env: EXPO_PUBLIC_LASTFM_API_KEY: ${{ secrets.EXPO_PUBLIC_LASTFM_API_KEY }} EXPO_PUBLIC_LASTFM_SHARED_SECRET: ${{ secrets.EXPO_PUBLIC_LASTFM_SHARED_SECRET }} ASTRA_ANDROID_KEYSTORE_PATH: ${{ runner.temp }}/astra-app-signing.jks ASTRA_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_APP_SIGNING_KEYSTORE_PASSWORD }} ASTRA_ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_APP_SIGNING_KEY_ALIAS }} ASTRA_ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_APP_SIGNING_KEY_PASSWORD }} run: ./gradlew :app:assembleRelease -PreactNativeArchitectures=armeabi-v7a,arm64-v8a --no-daemon - name: Verify release dependency substitution working-directory: android shell: bash run: | ./gradlew :app:dependencies --configuration releaseRuntimeClasspath --no-daemon > "$RUNNER_TEMP/release-dependencies.txt" grep -Fq "project :kotlin-audio" "$RUNNER_TEMP/release-dependencies.txt" - name: Verify APK identity, signature, permissions, and ABIs env: ASTRA_EXPECTED_CERT_SHA256: ${{ vars.ANDROID_APP_SIGNING_CERT_SHA256 }} shell: bash run: | APK="android/app/build/outputs/apk/release/app-release.apk" AAPT="$ANDROID_HOME/build-tools/36.0.0/aapt" APKSIGNER="$ANDROID_HOME/build-tools/36.0.0/apksigner" EXPECTED="$(printf '%s' "$ASTRA_EXPECTED_CERT_SHA256" | tr -d ' :' | tr '[:lower:]' '[:upper:]')" ACTUAL="$($APKSIGNER verify --print-certs "$APK" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p' | tr '[:lower:]' '[:upper:]')" if [ -z "$EXPECTED" ] || [ "$ACTUAL" != "$EXPECTED" ]; then echo "APK signing certificate does not match the protected app-signing fingerprint." >&2 exit 1 fi node scripts/release/android-release.mjs verify-apk-metadata github android/app/build/outputs/apk/release/output-metadata.json unzip -Z1 "$APK" | grep -Fq 'lib/armeabi-v7a/' unzip -Z1 "$APK" | grep -Fq 'lib/arm64-v8a/' if unzip -Z1 "$APK" | grep -Eq '^lib/(x86|x86_64)/'; then echo "GitHub APK unexpectedly contains emulator ABIs." >&2 exit 1 fi if ! $AAPT dump badging "$APK" | grep -Fq "uses-feature-not-required: name='android.hardware.camera'"; then echo "GitHub APK incorrectly requires camera hardware." >&2 exit 1 fi for permission in \ android.permission.READ_EXTERNAL_STORAGE \ android.permission.RECORD_AUDIO \ android.permission.SYSTEM_ALERT_WINDOW \ android.permission.WRITE_EXTERNAL_STORAGE; do if $AAPT dump permissions "$APK" | grep -Fq "$permission"; then echo "GitHub APK contains forbidden permission $permission." >&2 exit 1 fi done - name: Prepare GitHub artifact bundle run: node scripts/release/android-release.mjs prepare github android/app/build/outputs/apk/release/app-release.apk dist/android/github - name: Upload GitHub APK candidate uses: actions/upload-artifact@v4 with: name: astra-github-${{ inputs.candidate_label || format('run-{0}', github.run_number) }} path: dist/android/github/ if-no-files-found: error retention-days: 30 google-play-aab: name: Build signed Google Play AAB needs: validate runs-on: ubuntu-latest environment: android-release env: ASTRA_DISTRIBUTION: google-play steps: - uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: ${{ env.NODE_VERSION }} cache: npm - name: Setup Java uses: actions/setup-java@v5 with: distribution: temurin java-version: ${{ env.JAVA_VERSION }} - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 - name: Install dependencies run: npm ci - name: Validate Play release identity run: node scripts/release/android-release.mjs validate google-play - name: Generate Android project from tracked configuration run: npx expo prebuild --platform android --no-install --clean - name: Verify generated native customizations shell: bash run: | grep -Fq "ASTRA VENDORED KOTLIN AUDIO" android/settings.gradle grep -Fq "ASTRA KOTLIN AUDIO SUBSTITUTION" android/build.gradle grep -Fq "ASTRA RELEASE SIGNING" android/app/build.gradle grep -Fq 'android:shell="true"' android/app/src/main/AndroidManifest.xml - name: Run Astra native unit tests working-directory: android run: >- ./gradlew :astra-audio-route:testDebugUnitTest :astra-desktop-transport:testDebugUnitTest :astra-library-scanner:testDebugUnitTest :astra-scope:testDebugUnitTest :kotlin-audio:testDebugUnitTest --no-daemon - name: Reclaim native test build space working-directory: android env: ASTRA_ALLOW_INSECURE_RELEASE_SIGNING: 'true' run: ./gradlew clean --no-daemon - name: Decode upload keystore env: ASTRA_KEYSTORE_BASE64: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_BASE64 }} shell: bash run: | if [ -z "$ASTRA_KEYSTORE_BASE64" ]; then echo "ANDROID_UPLOAD_KEYSTORE_BASE64 is not configured." >&2 exit 1 fi printf '%s' "$ASTRA_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/astra-upload.jks" chmod 600 "$RUNNER_TEMP/astra-upload.jks" - name: Build Play bundle working-directory: android env: EXPO_PUBLIC_LASTFM_API_KEY: ${{ secrets.EXPO_PUBLIC_LASTFM_API_KEY }} EXPO_PUBLIC_LASTFM_SHARED_SECRET: ${{ secrets.EXPO_PUBLIC_LASTFM_SHARED_SECRET }} ASTRA_ANDROID_KEYSTORE_PATH: ${{ runner.temp }}/astra-upload.jks ASTRA_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_PASSWORD }} ASTRA_ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_UPLOAD_KEY_ALIAS }} ASTRA_ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_UPLOAD_KEY_PASSWORD }} run: ./gradlew :app:bundleRelease -PreactNativeArchitectures=armeabi-v7a,arm64-v8a --no-daemon - name: Verify release dependency substitution working-directory: android shell: bash run: | ./gradlew :app:dependencies --configuration releaseRuntimeClasspath --no-daemon > "$RUNNER_TEMP/release-dependencies.txt" grep -Fq "project :kotlin-audio" "$RUNNER_TEMP/release-dependencies.txt" - name: Verify AAB signature env: ASTRA_EXPECTED_CERT_SHA256: ${{ vars.ANDROID_UPLOAD_CERT_SHA256 }} shell: bash run: | AAB="android/app/build/outputs/bundle/release/app-release.aab" EXPECTED="$(printf '%s' "$ASTRA_EXPECTED_CERT_SHA256" | tr -d ' :' | tr '[:lower:]' '[:upper:]')" ACTUAL="$(keytool -printcert -jarfile "$AAB" | awk '/SHA256:/{gsub(":", "", $2); print toupper($2); exit}')" jarsigner -verify "$AAB" >/dev/null if [ -z "$EXPECTED" ] || [ "$ACTUAL" != "$EXPECTED" ]; then echo "AAB signing certificate does not match the protected upload-key fingerprint." >&2 exit 1 fi - name: Prepare Google Play artifact bundle run: node scripts/release/android-release.mjs prepare google-play android/app/build/outputs/bundle/release/app-release.aab dist/android/google-play - name: Upload Google Play AAB candidate uses: actions/upload-artifact@v4 with: name: astra-google-play-${{ inputs.candidate_label || format('run-{0}', github.run_number) }} path: dist/android/google-play/ if-no-files-found: error retention-days: 30