A CI job can check an APK on Android without booting an emulator on the runner: upload the build to a remote sandbox, open it, wait for one label that proves launch, and delete the phone. If you run instrumented tests on an emulator already, keep that; it answers a different question.

Remote sandbox or emulator on the runner?

GitHub’s standard Linux runners support hardware-accelerated Android virtualization, and android-emulator-runner boots an emulator inside the job. Sources reviewed on October 30, 2026.

QuestionEmulator on the runnerMobilebox sandbox
Test interfaceADB, instrumentation, any frameworkREST screen observations and actions; no ADB
ArchitectureImage you choose; the action defaults to x86Android 14, arm64-v8a only
CredentialsNone for the deviceAn API key in CI secrets
You pay forRunner time, including bootRunner time plus ready phone-minutes

No speed claim either way; measure both if wall clock matters.

The script

Save as ci-smoke.mjs and run with Node 24; no dependencies. It reuses the quickstart's request and polling code, so run the quickstart once first to separate key problems from app problems.

js
const base = process.env.MOBILESANDBOX_URL ?? 'https://mobilebox.dev'
const key = process.env.MOBILESANDBOX_API_KEY
const { APK_FILE, EXPECTED_PACKAGE, EXPECTED_LABEL } = process.env
if (!key || !APK_FILE || !EXPECTED_PACKAGE || !EXPECTED_LABEL) {
  throw new Error('Set MOBILESANDBOX_API_KEY, APK_FILE, EXPECTED_PACKAGE and EXPECTED_LABEL')
}
const { readFile } = await import('node:fs/promises')

async function call(path, method = 'GET', body, type = 'application/json') {
  const response = await fetch(base + path, {
    method,
    headers: { Authorization: `Bearer ${key}`, ...(body === undefined ? {} : { 'Content-Type': type }) },
    ...(body === undefined ? {} : { body: type === 'application/json' ? JSON.stringify(body) : body }),
    signal: AbortSignal.timeout(180_000),
  })
  const text = await response.text()
  if (!response.ok) throw new Error(`${method} ${path} → ${response.status} ${text}`)
  return text ? JSON.parse(text) : null
}

let id, taskFailed = false
async function ready(phone) {
  const deadline = Date.now() + 180_000
  while (phone.status === 'creating' || phone.status === 'starting') {
    if (Date.now() >= deadline) throw new Error('Boot deadline exceeded')
    phone = await call(`/v1/phones/${id}?wait=ready&timeout=50`)
    if (phone.status !== 'ready') await new Promise(r => setTimeout(r, 1000))
  }
  if (phone.status !== 'ready') throw new Error(JSON.stringify(phone))
}
try {
  const phone = await call('/v1/phones?wait=ready&timeout=50', 'POST', {
    name: `ci-${process.env.GITHUB_RUN_ID ?? Date.now()}`, idle_timeout: 120, max_duration: 600,
  })
  id = phone.id // keep this id; never create again while it boots
  await ready(phone)
  const apk = await readFile(APK_FILE)
  const installed = await call(`/v1/phones/${id}/apps`, 'POST', apk, 'application/octet-stream')
  if (installed.package !== EXPECTED_PACKAGE) {
    throw new Error(`Installed ${installed.package}, expected ${EXPECTED_PACKAGE}`)
  }
  const result = await call(`/v1/phones/${id}/actions`, 'POST', { actions: [
    { type: 'open_app', package: installed.package },
    { type: 'wait_for', text: EXPECTED_LABEL, timeout_ms: 30_000 },
  ] })
  const failed = result.results.find(item => !item.ok)
  if (failed) throw new Error(`${JSON.stringify(failed.error)}\nScreen:\n${result.screen.text}`)
  console.log(`Smoke passed: ${installed.package} ${installed.version_name} shows "${EXPECTED_LABEL}"`)
} catch (error) {
  taskFailed = true
  throw error
} finally {
  if (id) {
    try { await call(`/v1/phones/${id}`, 'DELETE') }
    catch (cleanupError) {
      console.error(`Cleanup failed for ${id}:`, cleanupError)
      if (!taskFailed) throw cleanupError
    }
  }
}

It exits non-zero on a wrong package, a failed action or a failed cleanup, and deletes the phone whenever finally runs. A cancelled job can skip finally; idle_timeout and max_duration then park the phone, which still counts toward your kept phones. A pass is evidence about one build and one label.

Run it from GitHub Actions

yaml
name: android-smoke
on:
  push:
    branches: [main]
jobs:
  smoke:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
      - run: ./gradlew assembleDebug
      - run: node ci-smoke.mjs
        env:
          MOBILESANDBOX_API_KEY: ${{ secrets.MOBILESANDBOX_API_KEY }}
          APK_FILE: app/build/outputs/apk/debug/app-debug.apk
          EXPECTED_PACKAGE: com.example.app
          EXPECTED_LABEL: Welcome

Replace the package, label and APK path; the path is the Android Gradle plugin default for a module named app. The trigger is push because GitHub withholds secrets from fork pull requests. Keys in one project share its phones and credit, so give CI its own named key. Accounts currently open with an existing beta key; provider sign-in is not enabled yet.

Read the failure

OutputNext step
unsupported_abiBuild an arm64-v8a variant
Installed X, expected YFix APK_FILE or the build variant
wait_timeout and screen textRead the screen: dialog, crash, splash or another language
Cleanup failedList phones and delete it

The printed screen lands in the CI log, so keep real accounts off the first screen.

This proves one arm64 build installed on an Android 14 virtual sandbox, launched and showed one label. It does not cover instrumentation, physical devices, Google Play services, iOS or later screens. Next, add a saved-state check from outcome-focused evaluation.

Put it to work.

Open the quickstartRead the API reference