CI/CD
Gate a pull request on a Roverly run. Your pipeline uploads a build, starts a run, and turns the verdict into a check.
The integration is an HTTP API — GitHub Actions, GitLab CI, Jenkins, Bitbucket and CircleCI all work the same way, with nothing to install.
Prerequisites
Two things a person sets up once. CI cannot do either, and a pipeline wired before they exist fails every time.
A paired agent host, online. Runs execute on your own hardware, not on Roverly's — a developer machine, a dedicated test box, a device farm, or a mix. Install the desktop app, pair it, and connect the device or simulator. iOS on real hardware needs provisioning in place first.
Whichever host polls first claims the run, so pair as many as you like; two will never pick up the same one. If none is online the run never starts and your step times out.
A pinned production run, for regression. Do a learn run from the desktop
app, then mark it Production run on the runs page.
CI cannot do this: pinning needs a learn run, and learn is refused over the
API. Without it, POST /v1/runs returns 400.
Get an API key
Dashboard → Settings → API. Give the key both scopes:
| Scope | Allows |
|---|---|
trigger |
Start runs, upload builds |
read |
Poll a run, read findings |
Store it as ROVERLY_API_KEY in your CI secret store. It is a bearer token —
anyone holding it can spend your run allowance.
Projects. A key reaches every project in your workspace by default. Uncheck
All projects to narrow it to the ones a given pipeline needs — a key for one
repo then cannot start runs or read findings anywhere else, so a leak from that
repo's CI settings stays contained. A project outside the key's list answers
404, the same as one that does not exist.
Your desktop's key is a different kind and cannot call this API.
Quickstart
curl -fsSL https://roverly.ai/cli/roverly -o roverly && chmod +x roverly
ROVERLY_API_KEY="$ROVERLY_KEY" ./roverly test \
--project-id "$ROVERLY_PROJECT" \
--run-type feature \
--pr-body "$PR_BODY" --pr-title "$PR_TITLE" --pr-number "$PR_NUMBER" \
--build ./app/build/outputs/apk/release/app-release.apk \
--summary-file roverly-comment.md
--run-type feature is the CLI's default and is shown here only for clarity. A
feature run needs a goal, so a pipeline with no PR context — push-to-main,
nightly, a tag build — must pass --run-type regression explicitly or supply
--goal. (The API itself has no default: run_type is required in the body.)
Branch and commit are detected for you
On GitHub Actions the CLI fills branch and commit from the runner's own
environment, so the run is tagged with nothing to configure. Those tags are what
the dashboard shows on a run and what ?branch= filters the release gate by.
On a pull_request event it reads the PR's head SHA out of the event
payload rather than using GITHUB_SHA. GITHUB_SHA there is an ephemeral merge
commit that GitHub creates to test the merge result — it is not a commit in your
repository and it stops resolving once the PR closes, so a run tagged with it
would point at nothing.
| Tag | Where it comes from |
|---|---|
branch |
GITHUB_HEAD_REF on a PR (the source branch), else GITHUB_REF_NAME |
commit |
the PR head SHA on a PR, else GITHUB_SHA |
environment |
never detected — pass --environment yourself |
Pass --branch / --commit explicitly to override the detected value; an
explicit flag always wins. On CI other than GitHub Actions nothing is detected,
so pass all three.
./roverly test ... --environment staging # tag the deployment target
./roverly test ... --branch "$MY_BRANCH" # override the detected branch
| Exit code | Meaning |
|---|---|
0 |
Check passes |
1 |
Check fails |
2 |
The tool was invoked wrong |
--summary-file writes a pull-request comment you can post as-is.
For a copy-paste version, roverly.ai/cli/snippet.sh
is the same gate in about fifteen lines of curl and jq. It skips the build
upload and the PR comment.
API reference
Base URL https://api.roverly.ai. Every call needs
Authorization: Bearer <key>.
Every request names a project, and every response returns one. Reads take
?project_id=; writes carry it in the body. Responses echo it back, so a
pipeline can check the verdict it is about to post belongs to the project it
asked about rather than trusting a run id from elsewhere. A run id that belongs
to a different project than the one you named is a 404, even when your key
reaches both.
Upload a build
Mobile only. Web projects test a URL and install nothing.
POST /v1/artifacts → { id, project_id, upload_url, upload_content_length, expires_at_iso }
PUT <upload_url> → the bytes, direct to storage
POST /v1/artifacts/{id}/complete?project_id=…
Bytes go straight to storage, never through the API. Send exactly the
Content-Length returned as upload_content_length — the signature binds it.
Declare size_bytes, sha256, and package_id (Android package or iOS bundle
id). The package id is checked against the project's: a mismatch would install
one app and launch another.
Builds expire after 7 days. Max 200 MB.
For an iOS simulator build, zip the .app and upload the zip. The archive must
contain exactly one .app.
Start a run
POST /v1/runs
| Field | Notes |
|---|---|
project_id |
Required |
run_type |
regression or feature. learn is refused. |
goal |
Required for feature — what to exercise. Map your PR description to it. |
artifact_id |
The build to install |
target_url |
Web only — a preview deployment to test instead of the project URL |
branch, commit_sha, environment |
Free-text traceability |
pr_number, pr_url, pr_title |
Optional PR context |
jira_issue_key |
Optional — the Jira ticket behind this PR (see below) |
Jira context. Connect Jira under Settings → Integrations and Roverly folds
the linked ticket's summary and description into a feature run's goal, so the
agent tests against the acceptance criteria and not just the PR description.
Roverly then distils the two into a short goal — a sentence or two naming what
to exercise, plus a few numbered steps — because that is what the agent reads on
every step of the run. Send your PR description as goal and let it do the
rest; there is no format to follow, and a ## Test plan section is used when
you have one. The distilled goal appears on the run in the dashboard, with the
original behind a disclosure, and in the summary Roverly gives you to post.
The ticket is found automatically when your project key appears in pr_title,
branch or goal (PROJ-412); send jira_issue_key when it appears in none
of them, or to override the guess. Enrichment is best-effort — if Jira is
unreachable the run still starts with the goal you sent.
GitHub App (optional). Install the Roverly app on your repositories and
the verdict is posted as a native check run, by Roverly rather than by your
pipeline's token — which is also what lets a fork's pull request get a result.
Your pipeline is unchanged: it still builds, uploads and triggers, and the
summary endpoint still works. Install it from Settings → Integrations.
The App reports; it does not trigger — so your secret stays. ROVERLY_API_KEY
and --project-id are still required exactly as above: the key authenticates the
trigger, and the project id names which Roverly project this repo's PRs run
against. The App knows neither — by the time it posts, the run already exists,
and it reads only the repo (from pr_url) and finds the right installation. What
the App can replace is the poll-and-comment step: with it installed you no
longer need the CLI to post the summary comment, though keeping both is fine.
Two fields decide whether a check appears. A check run attaches to a commit,
so commit_sha must be a real SHA — a branch name is ignored and no check is
posted. The repository is read from pr_url, so that has to be the
https://github.com/owner/repo/pull/N form. Send both on the trigger; miss
either and the run still executes and summary still answers, you just get no
check.
Failed runs get a check too, carrying the reason the run ended. Re-running the same run updates the existing check rather than adding a second one.
GitHub's "Re-run" button works. Clicking it re-tests the pull request using the build already on file — Roverly rebuilds nothing. If that build has expired (they are kept 7 days) the check says so instead of failing silently; push the commit again to refresh it.
Preview deployments. Previews live off your domain (*.vercel.app,
*.netlify.app), so add those hosts to the project's preview-domain allowlist
first. Otherwise the run is refused — a stray URL must not be able to aim a run
at someone else's site.
Read the verdict
GET /v1/runs/{id}?project_id=… → { project_id, status, ... }
GET /v1/runs/{id}/summary?project_id=… → { project_id, status, reasons, blocking, markdown, superseded, steps, goal }
GET /v1/runs/{id}/findings?project_id=… → { project_id, run_id, findings: [...] }
Poll until status is one of succeeded, failed, cancelled.
processingis not finished. Findings are produced duringprocessing. A client that stops when the status is no longerrunningreads the verdict before the findings exist and reports a pass on a broken change. Check membership of the three terminal statuses — never "notrunning".
Then read summary. Branch on status (pass / fail) and post markdown as
a comment. It is always HTTP 200; an HTTP error means auth or infrastructure,
never a verdict.
What Roverly did, not just whether it passed. On a run that succeeded,
summary returns steps[] — the ordered actions the agent took, each naming
the screen it started on, what it did, and where it landed. Taps the agent made
by coordinate are resolved back to the element under them, falling back to
tapped at (x, y) when nothing resolves. The same list is folded into
markdown, so posting that comment gives a reviewer the evidence the feature
was exercised rather than asking them to take "passed" on trust.
A failed run returns no steps, deliberately: it verified nothing, and a list of taps ending in a crash is noise on a pull request. It gets a one-or-two-sentence reason instead.
Progress while it runs — automatic with the GitHub App. If Roverly's App is installed, the check starts spinning ("Roverly is testing this pull request") the moment an agent picks up the run, then updates in place to the verdict when it finishes. No polling needed for that.
Without the App, polling before the run finishes is not wasted — the reasons
carry the same plain-sentence waypoint you can post as a status update:
| the agent has claimed the run | "A Roverly desktop agent has picked up this CI run" |
| the agent finished, analysis is running | "The Roverly desktop agent has completed this CI run; kicking off backend processing" |
Neither is a verdict. status is "fail" until the run is terminal, which is
why the same response also says "Not a verdict yet — poll until the run reaches
succeeded, failed or cancelled."
Stop polling before 60 minutes. An unclaimed run is cleaned up at 60. Past that you are polling an abandoned run and reporting it as your app's verdict. 45 minutes is a sensible ceiling.
Concurrent pushes
Push six times and you get the in-flight run plus one run for the sixth commit. Not six runs.
A run an agent has started always finishes. A queued run nobody has picked up is replaced by the newer one. Replaced runs do not count against your plan.
When your job is the replaced one, summary returns superseded: true.
Branch on status, not on superseded alone. There are two cases behind
that one flag:
superseded |
status |
Do | |
|---|---|---|---|
| Replaced before any agent picked it up | true |
"pass" |
Exit 0 — nothing ran, and a red check here is noise |
| Replaced after the run had already started | true |
the real verdict | Honour status. The run finished and its findings are real, just about an older commit |
A client that exits 0 on superseded alone will silently swallow a genuine
failure in the second case. The reasons say the result is stale.
Failure modes
| Cause | What you see |
|---|---|
| No paired desktop online | Run stays queued; your step times out |
| Another run in progress | Queued behind it; same timeout |
| No production run pinned | 400 at trigger, naming the fix |
| Over your plan's allowance | 402 at trigger |
| Build missing, expired or wrong package | 400 at trigger; no run starts |
| Run errored | summary fails, naming the error |
| Replaced by a newer commit | superseded: true — exit 0 only if status is "pass"; see above |
Anything that is not a pass renders as a failed check. The summary text says which.
Fork pull requests are not special-cased. The API cannot tell a fork PR from any other, so a fork PR starts a real run and spends a real slot. On a public repo that is usually wrong — both for cost and because a contributor's token generally cannot post the comment back. Gate it in your pipeline. On GitHub Actions:
if: github.event.pull_request.head.repo.full_name == github.repository
Plans and limits
No plan gates this API and there is no separate CI quota. A CI-triggered run draws on the same monthly pool as one started from the desktop.
| Plan | Runs / month | Past the pool |
|---|---|---|
| Starter | 8 | 402 until the month rolls over |
| Pro | 40 | Metered as overage; runs continue |
| Enterprise | Per contract | Per contract |
On Starter that means eight gated pull requests a month; the ninth
POST /v1/runs returns 402 rather than queueing.
On Pro, overage is funded by prepaid credits. A run needs $2.00 available to start once the pool is used up — add credits under Settings → AI.
Wiring this up with an AI assistant
Everything on this page is also published as one plain-text file: llms.txt. Point a coding assistant at it and it can write a working pipeline in one fetch, instead of crawling the site.
Two things are worth telling it explicitly, because they are what a
correct-looking config gets wrong: the paired-desktop and pinned-production-run
prerequisites are set up by a person, not by CI, and processing is not a
terminal status.