From a phone in a drawer to a green check on a pull request.
About 15 minutes, most of it spent downloading the runtime bundle.
arm64-v8a), Android 9 / API 28 or laterNot supported: 32-bit ARM, x86 Android, Docker, KVM, nested virtualization.
DroidRunner is not on Google Play — the app exists to execute code fetched from outside the store, which the Device & Network Abuse policy does not allow. Install the APK directly.
Recommended: Obtainium, because an unattended runner should not wait for someone to notice a release. Add app → paste the repository URL:
https://github.com/m96-chan/DroidRunner
Set the APK filter droidrunner-v.*\.apk. Every release also
carries the corresponding-source archive
droidrunner-v<version>-source.tar.gz, so without the filter
Obtainium has two assets to choose between. It then updates the app in place
as new tags are published.
By hand: download
droidrunner-v<version>.apk from the
latest release
and open it. You may have to allow installation from your browser or file
manager once.
DroidRunner signs in as a GitHub App rather than asking you to paste a token, so it needs to be installed on the repository or organization it will serve.
Open github.com/apps/droidgithubrunner and install it on the repositories the runner should serve. The app asks for Administration: read and write, which is what registering a self-hosted runner requires — that permission is also what lets someone change repository settings, so grant it per-repository rather than to everything you own.
If you skip this, the app will detect it during sign-in and prompt you.
Launch DroidRunner, open the setup screen (⚙ in the header) and tap Connect GitHub.
An 8-character code appears and is copied to your clipboard. Enter it at github.com/login/device — on the phone or on any other machine — and approve.
This is the OAuth device flow, so there is no client secret inside the APK and no personal access token for you to create or rotate. The resulting user token is encrypted with the Android Keystore, stays on the Android side, and is exchanged for a short-lived registration token when needed. It never enters the Linux environment where jobs run.
Pick a repository, or an organization the app is installed on, then tap Register.
Registration runs behind a progress dialog: the app fetches a registration
token, discovers the newest runtime-* release, verifies the
manifest signature, downloads the ~200 MB bundle, checks its SHA-256,
extracts it and runs the official config.sh. On a slow
connection this is the long part of the setup.
Changing the target later is fine: stop the runner, pick a new one, and the button offers Re-register.
Still on the setup screen: decide when this device is allowed to accept work.
Thresholds for battery level, thermal status and free storage decide when jobs are held. A device that is hot, full, or on battery and below your threshold stops taking new jobs and resumes on its own once the condition clears — except critical thermal, which can interrupt a running job, because a phone that is throttling itself produces numbers nobody should trust anyway.
Losing mains power is not on its own a reason to stop. A charged phone keeps working through a power cut until the level actually falls, which is most of what a battery is here for. Run only on mains power is a separate toggle, off by default, for a device that must never spend its own charge.
Ephemeral mode re-registers the runner and wipes the work directory after every job. Slower to start each job, and the right default if more than one workflow — or more than one person — can reach this device.
Start on boot brings the runner back after a reboot — once the device is unlocked. Android holds the boot broadcast back while the phone is credential-locked and delivers it at the first unlock, and until then the runtime and the stored credentials cannot be read at all. So a phone with a secure lock screen does not resume CI after a power cut until somebody picks it up; a phone dedicated to CI should have no lock screen, which is the configuration where a reboot really is unattended. When the wait happens anyway, the runner panel says how long the device was offline.
Also worth granting the battery-optimization exemption the app asks for; vendor task killers are the most common reason a runner quietly goes offline overnight.
Go back to the dashboard. The runner panel should reach listening for jobs within a few seconds, and the header shows the same state.
A registered device starts its runner automatically whenever the app launches; manual Start/Stop controls live in the runner panel. The device now also appears under Settings → Actions → Runners on GitHub, with its labels.
Commit this as .github/workflows/device.yml:
name: Device smoke on: workflow_dispatch: permissions: contents: read jobs: smoke: runs-on: [self-hosted, android, arm64] steps: - uses: actions/checkout@v4 - run: uname -a && nproc && free -h
Run it from the Actions tab. The runner panel on the phone switches to running job and the log tail scrolls; the job counter increments when it finishes.
runs-on
match what the device actually registered — they are listed under the app title
and on GitHub's runner page. Every label in runs-on must be present;
a typo means the job waits forever rather than failing.
droidrunner-device is copied into the guest from the APK on every
runner start, so it stays in step with the Device Agent it talks to. It reaches
that agent over loopback, authenticated with a token minted for the running job.
runs-on: [self-hosted, android, nnapi-accelerator] steps: - run: droidrunner-device capabilities - run: droidrunner-device devices - run: droidrunner-device test model my-model.tflite --device google-edgetpu --iterations 30
nnapi-accelerator, not android-npu.
android-npu says the SoC family is known to have an NPU — a
hint, not a promise — so a job asking for it can land on a device that runs
everything on its CPU and says nothing.
Latency says a graph was accepted. Whether it was computed correctly is a different question, and the one that matters if you are shipping a model: a bug that only appears on the vendor's silicon looks exactly like working code when the check runs on a laptop.
- uses: m96-chan/DroidRunner/actions/run-model@main id: device with: model: build/model.tflite device: qnn-htp inputs: fixtures/input-0.bin output-dir: out - run: test "${{ steps.device.outputs.executed }}" = accelerator - run: cmp out/output-0.bin fixtures/golden-0.bin
Every result reports executed — who actually ran the graph, not
who was asked — and a run that cannot be attributed is refused rather than
reported as a number. The shape another repository can rely on is written down in
RESULT-CONTRACT.md.
Almost always the vendor's battery management. The app defends itself twice over — the service is sticky, and a watchdog job starts it again within fifteen minutes if the system kills it anyway — and on a Xiaomi both were measured working, one covering for the other. Grant the battery-optimization exemption the app requests, and on Xiaomi/MIUI, Samsung, Huawei and OPPO also lock the app in the recent-apps list and allow autostart.
Some ROMs defeat all of it. On a RedMagic 8 nothing restarts a killed runner: not the sticky service, not an alarm, and not the watchdog job with every constraint satisfied and half an hour to run in, while the app sits exempt from battery optimization and unrestricted in the background. The vendor's per-app power screen is behind a signature permission, so the app cannot even open it for you — allow DroidRunner there by hand, in the phone's own settings.
Seen after reinstalling the app or force-stopping it: GitHub still holds the old listener session. It resolves itself — the listener retries and logs Runner reconnected. Queued jobs sit unassigned until then, so give it a few minutes or restart the runner from the dashboard.
Re-register from the setup screen rather than reinstalling; the app clears the stale runner state itself. If a token expired, sign in again — user tokens can be configured to expire, and an expired one surfaces as a registration failure rather than a login prompt.
The dashboard says why: on battery and below your threshold, thermal status raised, or free space below the floor — or simply off mains, if you turned on Run only on mains power. Plug it in, cool it down, or lower the threshold on the setup screen. This is admission control doing its job, not a bug.
Allow "install unknown apps" for whatever opened the APK. On MIUI, sideloading
over adb additionally needs Install via USB in developer
options.
Self-builders need their own GitHub App identity for the device flow (free, no server), their own APK signing key, and — if they publish runtime bundles — a manifest signing key. The README covers all three, plus the proot cross-compile that has to happen once before the first Gradle build:
git clone https://github.com/m96-chan/DroidRunner cd DroidRunner ANDROID_NDK_ROOT=$ANDROID_HOME/ndk/<version> runtime/build-proot.sh ./gradlew assembleDebug
A build with no trusted manifest key configured cannot verify a runtime bundle and says so during install rather than pretending otherwise.