Feature/local build workflow setup#12
Conversation
- Create local-android-build.yml workflow for building APKs without Expo.dev - Builds directly using Gradle on self-hosted runner (your local machine) - Generates GitHub releases with APK artifacts - Includes automatic versioning and changelog generation - Add comprehensive setup & troubleshooting documentation This allows building APKs without hitting Expo.dev build limits or paying monthly subscription fees. Uses your own machine as a build runner. Documentation: - LOCAL_BUILD_SETUP.md - Complete setup guide with prerequisites - LOCAL_BUILD_QUICKSTART.md - Quick reference (5-step start) - LOCAL_BUILD_TROUBLESHOOTING.md - 15+ common issues & solutions - IMPLEMENTATION_SUMMARY.md - Overview & architecture Workflow features: ✅ No Expo.dev charges ✅ Full version management ✅ Automatic GitHub releases ✅ Pre-release builds for dev branches ✅ Build metadata tracking ✅ 14-day artifact retention
- System architecture diagram showing data flow - Runner setup and configuration overview - Build process timeline and storage info - Comparison matrices (local vs EAS) - Decision tree for workflow selection - Quick reference card for common tasks Helps visualize the entire system setup and understand how the local build workflow integrates with GitHub Actions and your machine.
- Quick start guide with immediate action items - Step-by-step setup for GitHub token and runner - Build verification and APK installation - Cost comparison (save -960/year) - Quick troubleshooting reference - Example workflow diagram This is the entry point for users implementing the local build workflow.
Maps all documentation files Quick start checklist Cost analysis and comparison Workflow overview with diagrams Support and troubleshooting reference This serves as the main entry point for understanding and using the entire local build system.
- Fix markdown formatting in LOCAL_BUILD_TROUBLESHOOTING.md
• Add blank lines before code blocks for better readability
• Normalize yaml indentation for consistency
• Improve section spacing
- Add ADB + Expo device management scripts
• scripts/adb-connect.sh - Full-featured device/emulator selector
- Handles USB devices and Android Virtual Devices
- Cleans up offline emulators automatically
- Launches Expo targeting selected device
• scripts/adb-connect2.sh - Simplified WSL-safe version
- Lighter implementation without complex pipes
- Better bash compatibility
- Easier to debug and modify
These scripts simplify the workflow of selecting a device and running
Expo dev client, especially useful with the new local build setup.
There was a problem hiding this comment.
Pull request overview
This pull request adds a local Android build workflow setup that enables building APKs directly on a self-hosted runner instead of using Expo.dev's cloud build service. The PR aims to provide a cost-effective alternative for Android app builds when developers hit Expo's build limits or want to avoid subscription fees.
Changes:
- Adds a new GitHub Actions workflow (
.github/workflows/local-android-build.yml) for building Android APKs on a self-hosted runner - Includes two ADB connection scripts for managing Android device connections and launching Expo builds
- Provides comprehensive documentation across 6 files covering setup, troubleshooting, architecture, and quick references
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
.github/workflows/local-android-build.yml |
New self-hosted workflow for local Android builds using Gradle |
scripts/adb-connect.sh |
Script for ADB device management and Expo launching (referenced in package.json) |
scripts/adb-connect2.sh |
Alternative/duplicate ADB script (not referenced anywhere) |
GETTING_STARTED.md |
Quick start guide with 5-step setup process |
README_LOCAL_BUILD.md |
Main overview document with cost comparisons and file references |
LOCAL_BUILD_SETUP.md |
Detailed setup guide with prerequisites and troubleshooting |
LOCAL_BUILD_QUICKSTART.md |
Quick reference card for common commands |
LOCAL_BUILD_TROUBLESHOOTING.md |
Solutions for 15+ common issues |
LOCAL_BUILD_ARCHITECTURE.md |
Visual diagrams and architecture documentation |
IMPLEMENTATION_SUMMARY.md |
Features overview and comparison with EAS builds |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| - name: "💾 Commit Version Update" | ||
| if: github.ref == 'refs/heads/main' | ||
| run: | | ||
| git add version.json |
There was a problem hiding this comment.
The use of || true here differs from the existing eas-android-build.yml workflow (lines 71-72), which does not suppress errors. While adding || true is actually a good defensive practice (prevents workflow failure if commit/push fails due to no changes or merge conflicts), this inconsistency means the two workflows handle the same operations differently. Consider updating the existing workflow to match this error handling, or document why they should behave differently.
| git add version.json | |
| git add version.json | |
| # Use `|| true` to avoid failing the build when there are no changes to commit | |
| # or when pushing fails (e.g. non-fast-forward). This workflow treats the | |
| # version bump as best-effort so the APK build still succeeds even if these | |
| # Git operations fail. Note: this is intentionally more lenient than the | |
| # behavior in eas-android-build.yml. |
| branches: ["**"] | ||
| paths: | ||
| - '.github/workflows/local-android-build.yml' # Allow manual re-runs of this workflow |
There was a problem hiding this comment.
The workflow is configured to trigger only when the workflow file itself is modified (line 10). This means regular code pushes to any branch will NOT trigger this workflow, which is inconsistent with the documentation (e.g., README_LOCAL_BUILD.md line 39 states "Builds run automatically on git push"). Either update the trigger to include all code changes (remove the paths filter), or update the documentation to clarify that builds only run on workflow file changes or manual triggers.
| @@ -0,0 +1,372 @@ | |||
| # 🚀 Getting Started in 5 Minutes | |||
There was a problem hiding this comment.
The documentation has inconsistent time estimates. The title claims "Getting Started in 5 Minutes" but line 275 states the actual setup takes 15 minutes. Similarly, README_LOCAL_BUILD.md line 17 mentions "5-minute setup" while line 29 says "takes ~15 minutes". Consider updating titles and references to use consistent and realistic time estimates (likely 15-20 minutes for the full setup).
| # 🚀 Getting Started in 5 Minutes | |
| # 🚀 Getting Started in 15 Minutes |
| - name: "📦 Setup Node.js 22.x" | ||
| uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: 22.x |
There was a problem hiding this comment.
The local-android-build.yml workflow specifies Node.js 22.x, while the existing eas-android-build.yml workflow uses Node.js 20.x. This inconsistency could lead to unexpected behavior or compatibility issues. Consider standardizing on the same Node.js version across both workflows to ensure consistent behavior.
| - name: "📦 Setup Node.js 22.x" | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 22.x | |
| - name: "📦 Setup Node.js 20.x" | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 20.x |
| ## Files Added | ||
|
|
||
| ### 1. **Workflow File** (Main Implementation) | ||
| 📄 `.github/workflows/local-android-build.yml` - **The buildworkflow** |
There was a problem hiding this comment.
Typo: "buildworkflow" should be "build workflow" (two words).
| 📄 `.github/workflows/local-android-build.yml` - **The buildworkflow** | |
| 📄 `.github/workflows/local-android-build.yml` - **The build workflow** |
| sudo systemctl status actions.runner.* | ||
|
|
||
| # If not running, start it | ||
| sudo systemctl start actions.runner.* |
There was a problem hiding this comment.
This line contains excessive whitespace/padding between "sudo" and "systemctl". The command should be on a single line without the extra spacing. This appears to be a formatting error that could confuse users trying to copy the command.
| sudo systemctl start actions.runner.* | |
| sudo systemctl start actions.runner.* |
| #!/usr/bin/env bash | ||
| set -euo pipefail | ||
| shopt -s lastpipe | ||
|
|
||
| # ──────────────────────────────────────────── | ||
| # 🎨 Helper: Styling | ||
| # ──────────────────────────────────────────── | ||
| info() { printf "\033[1;34mℹ️ %s\033[0m\n" "$*"; } | ||
| success() { printf "\033[1;32m✅ %s\033[0m\n" "$*"; } | ||
| warn() { printf "\033[1;33m⚠️ %s\033[0m\n" "$*"; } | ||
| error() { printf "\033[1;31m❌ %s\033[0m\n" "$*"; } | ||
|
|
||
| section() { | ||
| printf "\n\033[1;35m────────────────────────────────────────────\033[0m\n" | ||
| printf "📟 \033[1;1mADB + Expo Device Manager (WSL Safe)\033[0m\n" | ||
| printf "\033[1;35m────────────────────────────────────────────\033[0m\n" | ||
| } | ||
| divider() { printf "\033[1;35m────────────────────────────────────────────\033[0m\n"; } | ||
|
|
||
| require_cmd() { | ||
| command -v "$1" >/dev/null 2>&1 || { error "Missing required command: $1"; exit 1; } | ||
| } | ||
|
|
||
| # ──────────────────────────────────────────── | ||
| # 🧠 Detect USB & emulator devices | ||
| # ──────────────────────────────────────────── | ||
| USB_DEVICES=() | ||
| EMU_DEVICES=() | ||
| ALL_DEVICES=() | ||
|
|
||
| get_devices() { | ||
| USB_DEVICES=() | ||
| EMU_DEVICES=() | ||
|
|
||
| # Drop the header line safely; do NOT let grep/sed failure trip set -e | ||
| local adb_out | ||
| adb_out="$(adb devices -l 2>/dev/null | sed '1d' || true)" | ||
|
|
||
| while IFS= read -r line; do | ||
| # Skip empty/whitespace-only lines | ||
| [[ -z "${line//[[:space:]]/}" ]] && continue | ||
|
|
||
| # serial + status are always first two fields | ||
| local serial status rest model device_name entry | ||
| serial="$(awk '{print $1}' <<<"$line" || true)" | ||
| status="$(awk '{print $2}' <<<"$line" || true)" | ||
| [[ -z "$serial" || -z "$status" ]] && continue | ||
|
|
||
| # Everything after the 2nd field | ||
| rest="$(cut -d' ' -f3- <<<"$line" 2>/dev/null || true)" | ||
|
|
||
| # Extract model:XYZ if present (no grep -P dependency) | ||
| model="$(sed -n 's/.*model:\([^[:space:]]*\).*/\1/p' <<<"$rest" | head -n1 || true)" | ||
|
|
||
| device_name="${model:-$serial}" | ||
| entry="$serial::$status::$device_name" | ||
|
|
||
| if [[ "$serial" == emulator-* ]]; then | ||
| EMU_DEVICES+=("$entry") | ||
| else | ||
| USB_DEVICES+=("$entry") | ||
| fi | ||
| done <<<"$adb_out" | ||
| } | ||
|
|
||
| # ──────────────────────────────────────────── | ||
| # 🔐 Ensure ADB server is running | ||
| # ──────────────────────────────────────────── | ||
| start_adb_if_needed() { | ||
| section | ||
| require_cmd adb | ||
| require_cmd npx | ||
|
|
||
| info "Checking ADB server..." | ||
| # Starting the server is safe even if it is already running | ||
| adb start-server >/dev/null 2>&1 || true | ||
| success "ADB server is responsive." | ||
| } | ||
|
|
||
| # ──────────────────────────────────────────── | ||
| # 📱 Device Selector Prompt | ||
| # ──────────────────────────────────────────── | ||
| SELECTED_SERIAL="" | ||
| SELECTED_NAME="" | ||
| SELECTED_STATUS="" | ||
|
|
||
| prompt_device_choice() { | ||
| divider | ||
| info "Select a device to launch Expo:" | ||
|
|
||
| local i serial status name icon | ||
| for i in "${!ALL_DEVICES[@]}"; do | ||
| IFS='::' read -r serial status name <<<"${ALL_DEVICES[$i]}" | ||
| icon="📱" | ||
| [[ "$serial" == emulator-* ]] && icon="🖥️" | ||
| printf " %d) %s %s (%s) [%s]\n" "$((i + 1))" "$icon" "$serial" "$name" "$status" | ||
| done | ||
|
|
||
| local choice_raw choice_idx max="${#ALL_DEVICES[@]}" | ||
| while true; do | ||
| read -rp "🚀 Enter choice [1-${max}]: " choice_raw | ||
| [[ "$choice_raw" =~ ^[0-9]+$ ]] || { warn "Enter a number from 1 to ${max}."; continue; } | ||
| (( choice_raw >= 1 && choice_raw <= max )) || { warn "Out of range. Enter 1..${max}."; continue; } | ||
|
|
||
| choice_idx=$((choice_raw - 1)) | ||
| IFS='::' read -r SELECTED_SERIAL SELECTED_STATUS SELECTED_NAME <<<"${ALL_DEVICES[$choice_idx]}" | ||
| break | ||
| done | ||
| } | ||
|
|
||
| # ──────────────────────────────────────────── | ||
| # ⏳ Wait for device to be online (with timeout if available) | ||
| # ──────────────────────────────────────────── | ||
| wait_for_selected_device() { | ||
| divider | ||
| info "Waiting for ${SELECTED_SERIAL} to be online..." | ||
|
|
||
| if command -v timeout >/dev/null 2>&1; then | ||
| # adb syntax: adb -s SERIAL wait-for-device | ||
| timeout 90s adb -s "$SELECTED_SERIAL" wait-for-device || { | ||
| error "Timed out waiting for ${SELECTED_SERIAL}." | ||
| exit 1 | ||
| } | ||
| else | ||
| adb -s "$SELECTED_SERIAL" wait-for-device | ||
| fi | ||
|
|
||
| # Confirm state | ||
| local state | ||
| state="$(adb -s "$SELECTED_SERIAL" get-state 2>/dev/null || true)" | ||
| if [[ "$state" != "device" ]]; then | ||
| warn "Device state is '${state:-unknown}'. If this is 'unauthorized', accept the RSA prompt on the device." | ||
| else | ||
| success "${SELECTED_SERIAL} is online." | ||
| fi | ||
| } | ||
|
|
||
| # ──────────────────────────────────────────── | ||
| # 🚀 Launch Expo with selected device | ||
| # ──────────────────────────────────────────── | ||
| launch_expo() { | ||
| divider | ||
| printf "🎯 Selected: \033[1m%s (%s)\033[0m [%s]\n" "$SELECTED_SERIAL" "$SELECTED_NAME" "$SELECTED_STATUS" | ||
|
|
||
| # Optional cleanup: try to kill a known zombie emulator-5554 if present | ||
| if adb devices 2>/dev/null | awk '{print $1,$2}' | grep -qE '^emulator-5554[[:space:]]+offline$' ; then | ||
| warn "Attempting to kill zombie emulator: emulator-5554" | ||
| adb -s emulator-5554 emu kill >/dev/null 2>&1 || true | ||
| fi | ||
|
|
||
| wait_for_selected_device | ||
|
|
||
| divider | ||
| info "Launching Expo on ${SELECTED_SERIAL}..." | ||
| export EXPO_ANDROID_DEVICE_ID="$SELECTED_SERIAL" | ||
|
|
||
| # Use expo run:android targeting the chosen device | ||
| npx expo run:android --device "$SELECTED_SERIAL" | ||
| } | ||
|
|
||
| # ──────────────────────────────────────────── | ||
| # 🧠 Main logic | ||
| # ──────────────────────────────────────────── | ||
| start_adb_if_needed | ||
|
|
||
| divider | ||
| info "Detecting connected devices..." | ||
| get_devices | ||
|
|
||
| if [[ "${#USB_DEVICES[@]}" -eq 0 ]]; then | ||
| warn "No USB devices found." | ||
| else | ||
| info "USB Devices detected:" | ||
| for dev in "${USB_DEVICES[@]}"; do | ||
| IFS='::' read -r serial status name <<<"$dev" | ||
| printf " 📱 %s [%s]\n" "$serial" "$status" | ||
| done | ||
| fi | ||
|
|
||
| divider | ||
| if [[ "${#EMU_DEVICES[@]}" -eq 0 ]]; then | ||
| warn "No emulators running." | ||
| else | ||
| info "Available Android Virtual Devices:" | ||
| for dev in "${EMU_DEVICES[@]}"; do | ||
| IFS='::' read -r serial status name <<<"$dev" | ||
| printf " 🖥️ %s [%s]\n" "$serial" "$status" | ||
| done | ||
| fi | ||
|
|
||
| ALL_DEVICES=("${USB_DEVICES[@]}" "${EMU_DEVICES[@]}") | ||
|
|
||
| if [[ "${#ALL_DEVICES[@]}" -eq 0 ]]; then | ||
| divider | ||
| error "No available devices to select." | ||
| exit 1 | ||
| fi | ||
|
|
||
| prompt_device_choice | ||
| launch_expo No newline at end of file |
There was a problem hiding this comment.
This script (adb-connect2.sh) appears to be a duplicate or alternative version of adb-connect.sh with similar functionality. However, it's not referenced anywhere in the codebase (package.json only references adb-connect.sh). Consider removing this file if it's not needed, or documenting its purpose and when it should be used instead of adb-connect.sh. Having two similar scripts without clear differentiation can lead to confusion and maintenance issues.
- Auto-detect Android SDK at common locations (~\nAndroid/Sdk, ~/Library/Android/sdk) - Export ANDROID_HOME from detection step for use in subsequent steps - Add fallback ANDROID_HOME in build step - Better error messages for SDK detection failures - Ensures gradle can locate Android SDK on self-hosted runners This fixes the 'SDK location not found' error when running on self-hosted GitHub Actions runners that don't have ANDROID_HOME explicitly set in the shell environment.
No description provided.