tutorial

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.

what you need

Not supported: 32-bit ARM, x86 Android, Docker, KVM, nested virtualization.

Pick the right phone. It will execute whatever code your workflows contain, and it will run hot for long stretches. Use a device you have wiped and dedicated to CI — not the one with your photos on it. Leave that device without a lock screen as well: a locked phone does not start its runner after a reboot until a human unlocks it.

setup

  1. Install the app

    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.

    Updates install over the existing app only while the signing key stays the same. Moving to a differently signed build — your own, say — means uninstalling first, and that discards the runner registration and the stored GitHub credentials — you would register the device again.
  2. Install the GitHub App

    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.

  3. Sign in from the device

    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.

  4. Choose a target and register

    Pick a repository, or an organization the app is installed on, then tap Register.

    Organization scope is wider than it looks. An organization runner accepts jobs from every repository in the organization unless you place it in a runner group with an allow-list. On a device that executes workflow code, that is a materially larger trust boundary. Start with a single repository.

    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.

  5. Set the job policy

    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.

  6. Done — it starts by itself

    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.

your first job

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.

Nothing picked the job up? Check that the labels in 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.

Asking the NPU something

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
Select on 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.

Checking the answer, not just the clock

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.

when something goes wrong

The runner goes offline while the screen is off

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.

"A session for this runner already exists"

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.

Registration fails, or the app says it is already configured

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.

Jobs are held and never start

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.

Installation is blocked

Allow "install unknown apps" for whatever opened the APK. On MIUI, sideloading over adb additionally needs Install via USB in developer options.

building it yourself

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.