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.
| Question | Emulator on the runner | Mobilebox sandbox |
|---|---|---|
| Test interface | ADB, instrumentation, any framework | REST screen observations and actions; no ADB |
| Architecture | Image you choose; the action defaults to x86 | Android 14, arm64-v8a only |
| Credentials | None for the device | An API key in CI secrets |
| You pay for | Runner time, including boot | Runner 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.
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
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: WelcomeReplace 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
| Output | Next step |
|---|---|
| unsupported_abi | Build an arm64-v8a variant |
| Installed X, expected Y | Fix APK_FILE or the build variant |
| wait_timeout and screen text | Read the screen: dialog, crash, splash or another language |
| Cleanup failed | List 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.