name: Build Android on: workflow_dispatch: inputs: upload_to_release: description: "Upload APK/AAB to latest GitHub release" required: false type: boolean default: false concurrency: group: build-android-${{ github.ref }} cancel-in-progress: true env: APP_DIR: packages/app ANDROID_DIR: packages/app-core/platforms/android NODE_VERSION: "24.15.0" # Default to least privilege. Override per-job where needed. permissions: contents: read jobs: build-android: runs-on: ${{ fromJSON(vars.HETZNER_FLEET_ONLINE == 'false' && '["ubuntu-24.04"]' || '["self-hosted","hetzner-robot"]') }} timeout-minutes: 30 permissions: actions: read contents: write steps: - name: Checkout (with the llama.cpp fork submodule) uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: # The elizavoice JNI wrapper compiles against the omnivoice headers # inside plugins/plugin-local-inference/native/llama.cpp — the staged # fused lib exports the fused-voice ABI, so the headers must be # checked out or build:android fails at [elizavoice-jni] (#15540). submodules: recursive show-progress: false - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: ${{ env.NODE_VERSION }} - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 with: bun-version: canary - name: Setup JDK 21 uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 with: distribution: temurin java-version: "21" - name: Setup Android SDK uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 - name: Cache Gradle uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with: path: | ~/.gradle/caches ~/.gradle/wrapper key: gradle-${{ runner.os }}-${{ hashFiles('packages/app-core/platforms/android/**/*.gradle*', 'packages/app-core/platforms/android/gradle/wrapper/gradle-wrapper.properties', 'packages/app/capacitor.config.ts') }} restore-keys: gradle-${{ runner.os }}- - name: Install dependencies run: bun install # Gradle's copyForkLlamaLib hard-requires the fused inference lib for # arm64-v8a (libelizainference.so + its backend .so siblings) and fails # the whole APK build without it (#15540). CI has no ~/.eliza staging # and no NDK cross-compile here — the fused libs are built by the # build-llama-ffi-android workflow. Native artifacts are valid only for # the exact producer inputs in this checkout; mixing a newer or older # llama pin with these JNI headers can link successfully and fail later # on-device. - name: Stage fused inference libs (from build-llama-ffi-android) env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail producer_inputs=( "plugins/plugin-local-inference/native/llama.cpp" ".github/workflows/build-llama-ffi-android.yml" "packages/app-core/scripts/aosp/compile-libllama.mjs" "packages/app-core/scripts/aosp/compile-libllama-paths.mjs" ) run_id="" while IFS=$'\t' read -r candidate source_sha; do compatible=true for path in "${producer_inputs[@]}"; do local_sha=$(git rev-parse "HEAD:$path") source_sha_for_path=$(gh api "repos/${{ github.repository }}/contents/$path?ref=$source_sha" \ --jq '.sha // empty' 2>/dev/null || true) if [ "$source_sha_for_path" != "$local_sha" ]; then compatible=false break fi done if [ "$compatible" = true ]; then run_id="$candidate" break fi done < <(gh api "repos/${{ github.repository }}/actions/workflows/build-llama-ffi-android.yml/runs?status=success&per_page=20" \ --jq '.workflow_runs[] | [.id, .head_sha] | @tsv') if [ -z "$run_id" ]; then echo "::error::no successful build-llama-ffi-android run matches this checkout's native producer inputs — build that workflow first" >&2 exit 1 fi echo "staging fused libs from input-compatible build-llama-ffi-android run $run_id" stage="$RUNNER_TEMP/elizainference" mkdir -p "$stage/arm64" "$stage/x86_64" gh run download "$run_id" -R "${{ github.repository }}" \ -n libelizainference-android-arm64-vulkan-fused -D "$stage/arm64" # x86_64 is optional: gradle skips absent non-arm64 ABIs. gh run download "$run_id" -R "${{ github.repository }}" \ -n libelizainference-android-x86_64-cpu-fused -D "$stage/x86_64" || true arm64_so=$(find "$stage/arm64" -name libelizainference.so | head -1) if [ -z "$arm64_so" ]; then echo "::error::arm64 artifact downloaded but contains no libelizainference.so" >&2 find "$stage/arm64" -type f | head -20 >&2 exit 1 fi echo "ELIZA_MTP_ANDROID_LIBDIR=$(dirname "$arm64_so")" >> "$GITHUB_ENV" x86_so=$(find "$stage/x86_64" -name libelizainference.so 2>/dev/null | head -1 || true) if [ -n "$x86_so" ]; then echo "ELIZA_MTP_ANDROID_LIBDIR_X86_64=$(dirname "$x86_so")" >> "$GITHUB_ENV" fi echo "arm64 libdir contents:" ls -la "$(dirname "$arm64_so")" - name: Prepare Android platform working-directory: ${{ env.APP_DIR }} run: bun run build:android - name: Run Android developer-install preflight working-directory: ${{ env.APP_DIR }} run: bun run preflight:android:sideload - name: Override Gradle JDK path for CI working-directory: ${{ env.ANDROID_DIR }} run: | # Remove the hardcoded local JDK path so Gradle uses JAVA_HOME from setup-java sed -i '/org.gradle.java.home=/d' gradle.properties - name: Determine version id: version run: | if [[ "${{ github.event_name }}" == "release" ]]; then VERSION="${{ github.event.release.tag_name }}" VERSION="${VERSION#v}" else VERSION="0.0.0-dev" fi echo "version=$VERSION" >> $GITHUB_OUTPUT # Compute versionCode from semver (major*10000 + minor*100 + patch) IFS='.-' read -r major minor patch _ <<< "$VERSION" VERSION_CODE=$(( ${major:-0} * 10000 + ${minor:-0} * 100 + ${patch:-0} )) echo "version_code=$VERSION_CODE" >> $GITHUB_OUTPUT - name: Build debug APK if: github.event_name == 'workflow_dispatch' working-directory: ${{ env.ANDROID_DIR }} env: ELIZAOS_VERSION_NAME: ${{ steps.version.outputs.version }} ELIZAOS_VERSION_CODE: ${{ steps.version.outputs.version_code }} run: ./gradlew assembleDebug --no-daemon - name: Build release APK & AAB if: github.event_name == 'release' working-directory: ${{ env.ANDROID_DIR }} env: ELIZAOS_VERSION_NAME: ${{ steps.version.outputs.version }} ELIZAOS_VERSION_CODE: ${{ steps.version.outputs.version_code }} run: | ./gradlew assembleRelease --no-daemon ./gradlew bundleRelease --no-daemon - name: Sign release APK if: github.event_name == 'release' && env.ANDROID_KEYSTORE_BASE64 != '' env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/keystore.jks UNSIGNED_APK="${{ env.ANDROID_DIR }}/app/build/outputs/apk/release/app-release-unsigned.apk" SIGNED_APK="${{ env.ANDROID_DIR }}/app/build/outputs/apk/release/eliza-${{ steps.version.outputs.version }}.apk" ${ANDROID_HOME}/build-tools/35.0.0/apksigner sign \ --ks /tmp/keystore.jks \ --ks-pass "pass:${ANDROID_KEYSTORE_PASSWORD}" \ --ks-key-alias "${ANDROID_KEY_ALIAS}" \ --key-pass "pass:${ANDROID_KEY_PASSWORD}" \ --out "$SIGNED_APK" \ "$UNSIGNED_APK" rm /tmp/keystore.jks - name: Generate Android checksums if: github.event_name == 'release' run: | find "${{ env.ANDROID_DIR }}/app/build/outputs" \ \( -name "*.apk" -o -name "*.aab" \) -type f -print0 \ | sort -z \ | xargs -0 sha256sum > "${{ env.ANDROID_DIR }}/app/build/outputs/SHA256SUMS.txt" cat "${{ env.ANDROID_DIR }}/app/build/outputs/SHA256SUMS.txt" - name: Upload debug APK artifact if: github.event_name == 'workflow_dispatch' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: eliza-android-debug path: ${{ env.ANDROID_DIR }}/app/build/outputs/apk/debug/app-debug.apk if-no-files-found: error retention-days: 7 - name: Upload release artifacts if: github.event_name == 'release' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: eliza-android-release path: | ${{ env.ANDROID_DIR }}/app/build/outputs/apk/release/*.apk ${{ env.ANDROID_DIR }}/app/build/outputs/bundle/release/*.aab ${{ env.ANDROID_DIR }}/app/build/outputs/SHA256SUMS.txt if-no-files-found: error retention-days: 30 - name: Upload to GitHub Release if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.upload_to_release) uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b with: tag_name: ${{ github.event_name == 'release' && github.event.release.tag_name || '' }} files: | ${{ env.ANDROID_DIR }}/app/build/outputs/apk/release/*.apk ${{ env.ANDROID_DIR }}/app/build/outputs/bundle/release/*.aab ${{ env.ANDROID_DIR }}/app/build/outputs/apk/debug/app-debug.apk ${{ env.ANDROID_DIR }}/app/build/outputs/SHA256SUMS.txt fail_on_unmatched_files: false