CI integration — the reusable workflow¶
Beetroot ships a reusable GitHub Actions workflow so any repository can spin up a rooted Android-14 research phone in its own CI and run tests against it — without copy-pasting boot scaffolding or hosting a custom runner image.
The canonical use case: you publish a Frida script (or an app, a Magisk module, an anti-tamper test suite…) and you want CI to exercise it against a real rooted Android, on every push.
# .github/workflows/test.yml in YOUR repository
name: test-on-beetroot
on: [push]
jobs:
frida-hook:
uses: Xiddoc/Beetroot/.github/workflows/[email protected]
with:
frida-version: "16.4.10"
test-command: |
uv run --with 'frida==16.4.10' --with frida-tools \
frida -H "$FRIDA_HOST" -l hook.js -f com.example.app
Match the host Frida core to frida-version
The device-side frida-server (set by the frida-version input) and the
host-side frida core that frida-tools uses must agree on major +
minor, or the connection is refused. Pin the frida package to the same
version as frida-version (e.g. --with 'frida==16.4.10'), as above. See
the Frida guide for the matching rule.
That single uses: job checks out your repo, builds the Beetroot image on the
runner, boots an instance, and runs your test-command against the live
device.
This is not a published image, and it is licensing-clean
Beetroot does not publish a container image to GitHub Packages, and a
reusable workflow never appears there either — it is consumed via uses:,
not pulled. The image (redroid + Magisk + optional GApps + Houdini) is
built on your runner by beetroot build, exactly as it would be on a
developer's laptop: the patcher fetches GApps (Google) and Houdini (Intel)
from their upstreams at your CI runtime. Beetroot redistributes none of
it — this workflow ships only Beetroot's own MIT-licensed orchestration.
That is precisely why there is no pre-built image to pull: Beetroot can't
legally redistribute those proprietary blobs, but it can hand you the recipe
to build them yourself.
Requirements¶
The host path needs the kernel binder driver (see
Running in CI for the why). Two ways to satisfy it,
selected by the binder input:
binder |
What happens | Runner needs | Speed |
|---|---|---|---|
host (default) |
Loads binder_linux, boots redroid natively |
A runner where modprobe binder_linux works — GitHub-hosted ubuntu-latest does today |
Fast (~1–2 min boot) |
vm |
Builds a binder-enabled guest kernel, boots redroid in a QEMU micro-VM | Any x86-64 runner (no binder needed) | Slow — compiles a kernel and boots under TCG (~100 s+ boot) |
Use host on standard GitHub-hosted runners. Reach for vm only when your
runner can't load binder (a locked-down/self-hosted environment).
What the runner must provide
Both paths need a running Docker daemon and passwordless sudo
apt-get (the workflow installs adb, and on the vm path QEMU + a kernel
toolchain). The vm path additionally needs outbound network to
cdn.kernel.org (kernel source) and download.docker.com (the static
Docker bundle baked into the guest). GitHub-hosted ubuntu-latest provides
all of this; a custom runs-on may not — the vm path fails fast with a
clear message if no Docker daemon is reachable. The whole job is capped at
120 minutes; the boot wait alone allows ~16 minutes, so size your
test-command with the (slow, kernel-compiling) vm path in mind.
Inputs¶
| Input | Default | Description |
|---|---|---|
test-command |
— (required) | Shell command run once the instance is booted. Runs in working-directory. |
binder |
host |
host (native, fast) or vm (QEMU/TCG, no binder needed). |
gapps |
none |
GApps intent baked into the image: none, minimal, full. Ignored when binder: vm. |
gapps-vendor |
"" |
Optional GApps vendor override: litegapps, opengapps, mindthegapps. Empty lets the intent pick. Cannot be combined with gapps: none. Ignored when binder: vm. |
android-version |
14 |
Android major version: 11, 12, 13, 14. Ignored when binder: vm. |
frida-version |
"" |
Pin a frida-server version (e.g. 16.4.10); empty boots without Frida. Ignored when binder: vm (Frida over the vm backend is not yet supported). |
instance-name |
ci |
Name of the instance to create. |
working-directory |
. |
Directory the test-command step runs in (your checked-out repo). Scopes only that step — the image build always runs in the Beetroot checkout. |
runs-on |
ubuntu-latest |
Runner label for the job. |
beetroot-ref |
master |
Git ref of Xiddoc/Beetroot to check out for the CLI + build context. Defaults to master. If you pin an older ref in uses:, set this to the same ref (see Pinning the ref). |
capture-diagnostics |
false |
When true, capture an on-device diagnostics bundle (logcat, a bounded dumpsys subset, a screencap PNG, /data/tombstones, and the LSPosed module logs when present) in teardown — before the instance is destroyed — and upload it as a workflow artifact. Best-effort (never fails the job). Default false adds no artifact and no cost, so existing callers are unaffected. See Persisting test output. |
artifact-name |
beetroot-diagnostics |
Name of the diagnostics artifact uploaded when capture-diagnostics: true. Give each matrix leg a unique name so concurrent legs don't collide on one artifact. Ignored when capture-diagnostics: false. |
What the test-command gets¶
The booted device is exposed through environment variables, so your command doesn't need to know about ports or instance internals:
| Env var | Value | Use it for |
|---|---|---|
$ADB_SERIAL |
127.0.0.1:5555 |
adb -s "$ADB_SERIAL" shell … |
$FRIDA_HOST |
127.0.0.1:27042 |
frida -H "$FRIDA_HOST" … |
$BEETROOT_INSTANCE |
the instance name | uv run --project "$BEETROOT_SRC" beetroot shell "$BEETROOT_INSTANCE" … |
$BEETROOT_SRC |
the Beetroot checkout | invoking the CLI: uv run --project "$BEETROOT_SRC" beetroot … |
adb is already installed and connected. The CLI is not on PATH — call it
via uv run --project "$BEETROOT_SRC" beetroot … (the wheel strips docker/,
so the workflow runs the CLI from a source checkout).
Outputs¶
| Output | Description |
|---|---|
adb-serial |
The adb serial (host:port) of the booted instance. |
Worked example — testing a Frida hook¶
name: frida-hook-ci
on: [push, pull_request]
jobs:
test:
uses: Xiddoc/Beetroot/.github/workflows/[email protected]
with:
gapps: minimal # the target app needs Play services
frida-version: "16.4.10"
test-command: |
set -euo pipefail
# Push the target APK and install it.
adb -s "$ADB_SERIAL" install -r ./fixtures/target.apk
# Run the hook; -l loads the script, -f spawns the app. The frida core
# is pinned to the same version as frida-version (the matching rule).
timeout 60 uv run --with 'frida==16.4.10' --with frida-tools \
frida -H "$FRIDA_HOST" -l hook.js -f com.example.app \
--runtime=v8 -o frida.log || true
# Assert the hook fired.
grep -q '[+] SSL pinning bypassed' frida.log
Persisting test output
Set capture-diagnostics: true and the workflow collects an on-device
diagnostics bundle in its always() teardown — before it destroys the
instance — and uploads it as an artifact for you:
jobs:
test:
uses: Xiddoc/Beetroot/.github/workflows/[email protected]
with:
capture-diagnostics: true
artifact-name: beetroot-diagnostics # optional; this is the default
test-command: |
adb -s "$ADB_SERIAL" install -r ./fixtures/target.apk
./run-my-hook.sh
The bundle contains adb logcat -d, a bounded dumpsys subset
(activity, meminfo, window, package, battery — not a full
bugreport), a screencap PNG, /data/tombstones (native crash dumps),
the LSPosed per-module logs from /data/adb/lspd/log/ (where
XposedBridge.log(...) lands — see the LSPosed guide), and the
host-side beetroot logs tail. Every probe is best-effort, so a missing
file (no tombstones, no LSPosed) never fails the job. Give each matrix leg a
distinct artifact-name so concurrent legs don't collide.
This closes the old gotcha — a reusable workflow can't add steps after your
test-command, so previously you had to smuggle evidence out from inside
it (a $GITHUB_STEP_SUMMARY write, or failing loudly with the evidence
printed inline) or abandon the reusable workflow for a hand-rolled job. The
in-test-command route still works for anything the bundle doesn't cover
(custom report files); for full control, call this workflow's pieces from a
hand-rolled job (see Running in CI).
Testing across a matrix¶
Because it's a normal job, you can fan it out:
jobs:
test:
strategy:
matrix:
android: ["13", "14"]
gapps: [none, minimal]
uses: Xiddoc/Beetroot/.github/workflows/[email protected]
with:
android-version: ${{ matrix.android }}
gapps: ${{ matrix.gapps }}
test-command: uv run --project "$BEETROOT_SRC" beetroot doctor "$BEETROOT_INSTANCE"
Pinning the ref (do this)¶
Pin the uses: reference to a tag or commit SHA, not a moving branch:
uses: Xiddoc/Beetroot/.github/workflows/[email protected] # tag — good
uses: Xiddoc/Beetroot/.github/workflows/beetroot-ci.yml@<40-char-sha> # SHA — best
The workflow file (the steps) is loaded at the ref you pin in uses:, but the
Beetroot source it checks out for the CLI + build context defaults to
master. A reusable workflow can't reliably read its own uses: ref from
inside, so for an exact version match set beetroot-ref to the same ref:
jobs:
test:
uses: Xiddoc/Beetroot/.github/workflows/[email protected]
with:
beetroot-ref: v0.4 # match the source to the workflow version
test-command: ...
If you track a moving branch (@master), the default already matches and you
can omit beetroot-ref.
How it works (internals)¶
- Resolve the ref — if
beetroot-refis empty, derive it fromgithub.workflow_ref(the part after the last@) so the CLI matches theuses:version. - Two checkouts — your repository (so your test fixtures are present) and
Xiddoc/Beetrootinto.beetroot(the wheel stripsdocker/, so the build context must come from source). - Provide binder (
host) or build the guest kernel + rootfs (vm). beetroot buildthe image (host) — the patcher runs here, on your runner.- Create + configure —
beetroot create, then pinandroid.version+gapps(orbinder: vm) into the generatedbeetroot.yamlsoconfig.base_image_tag()resolves to the exact image that was built. beetroot apply+beetroot upand wait forsys.boot_completed.- Run your
test-commandwith$ADB_SERIAL/$FRIDA_HOSTset. - Always dump the host log tail; then, when
capture-diagnostics: true, collect the on-device diagnostics bundle andupload-artifactit beforebeetroot destroyruns (so the device is still alive); finallydestroyon teardown.
Limitations & gotchas¶
- Runner minutes.
beetroot builddownloads and patches a multi-GB image every run (no published image to pull — see the note above), so a host-path run is several minutes; avm-path run also compiles a kernel. Budget accordingly, and consider caching strategies for heavy use. binder: hostneeds a binder-capable runner. GitHub-hostedubuntu-latestqualifies today, but module availability on hosted images isn't contractual. If a future image dropsbinder_linux, switch tobinder: vm.vmis slow and Frida-less. TCG software emulation is ~5–20× slower; a slow first boot is expected, not a hang. Frida over the vm backend is not yet supported, sofrida-versionis ignored underbinder: vm.- No GitHub Package. Nothing here is published to the Packages section —
the deliverable is the
uses:-able workflow, not an image.
See also¶
- Running in CI / without kernel access — the binder decision tree and the hand-rolled equivalents.
- Adding a backend — the backend capability matrix.
- Binderless hosts (QEMU/TCG) — the
binder: vmdesign.