Skip to content

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)

  1. Resolve the ref — if beetroot-ref is empty, derive it from github.workflow_ref (the part after the last @) so the CLI matches the uses: version.
  2. Two checkouts — your repository (so your test fixtures are present) and Xiddoc/Beetroot into .beetroot (the wheel strips docker/, so the build context must come from source).
  3. Provide binder (host) or build the guest kernel + rootfs (vm).
  4. beetroot build the image (host) — the patcher runs here, on your runner.
  5. Create + configurebeetroot create, then pin android.version + gapps (or binder: vm) into the generated beetroot.yaml so config.base_image_tag() resolves to the exact image that was built.
  6. beetroot apply + beetroot up and wait for sys.boot_completed.
  7. Run your test-command with $ADB_SERIAL / $FRIDA_HOST set.
  8. Always dump the host log tail; then, when capture-diagnostics: true, collect the on-device diagnostics bundle and upload-artifact it before beetroot destroy runs (so the device is still alive); finally destroy on teardown.

Limitations & gotchas

  • Runner minutes. beetroot build downloads 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; a vm-path run also compiles a kernel. Budget accordingly, and consider caching strategies for heavy use.
  • binder: host needs a binder-capable runner. GitHub-hosted ubuntu-latest qualifies today, but module availability on hosted images isn't contractual. If a future image drops binder_linux, switch to binder: vm.
  • vm is 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, so frida-version is ignored under binder: vm.
  • No GitHub Package. Nothing here is published to the Packages section — the deliverable is the uses:-able workflow, not an image.

See also