Roverly in your CI pipeline
Run Roverly's UI tests on every pull request and gate the merge on the result. Your pipeline builds the app, uploads it, asks Roverly to test it, and turns the verdict into a passing or failing check.
Works the same on GitHub Actions, GitLab CI, Jenkins, Bitbucket, CircleCI — the integration is an HTTP API, so there is nothing platform-specific to install.
Before you start
Two things are set up once, by a person. CI cannot do either, and a pipeline wired before they are done will fail every time.
1. A paired desktop must be online. Runs execute on your own machine, not on Roverly's servers — that is what lets us drive a real device, a simulator, or a browser you control. Install the desktop app, pair it, and make sure the device or simulator you want to test is connected and configured. For iOS on real hardware, provisioning must already be in place.
A pull request opened while every paired machine is asleep produces a run that never starts. Your CI step times out and says so.
2. Regression runs need a pinned production run. A regression run works by comparing what it sees against a known-good baseline. From the desktop app, do a learn run against your app, then mark it as the Production run on the runs page.
This one is genuinely manual: pinning requires a learn run, and CI is not allowed
to trigger learn runs. If you skip it, POST /v1/runs refuses with a message
saying so.
Getting a key
Dashboard → Settings → API. A CI key carries two scopes:
| Scope | What it allows |
|---|---|
trigger |
Starting runs, and uploading builds |
read |
Polling a run and reading its findings |
Give your pipeline both. Store it as ROVERLY_API_KEY in your CI's secret store
— it is a bearer token, so anyone holding it can spend your run allowance.
The key your desktop uses is a different kind and cannot call this API.
The quickest version
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" \
--branch "$BRANCH" --commit "$COMMIT_SHA" \
--build ./app/build/outputs/apk/release/app-release.apk \
--summary-file roverly-comment.md
Exit code 0 passes the check, 1 fails it, 2 means the tool was invoked
wrong. --summary-file writes a ready-to-post pull-request comment.
Prefer to paste rather than download? The same gate is about fifteen lines of
curl and jq: roverly.ai/cli/snippet.sh. It skips the
build upload and the PR comment, and gates on the same verdict.
The API
Base URL https://api.roverly.ai. Every call takes
Authorization: Bearer <your key>.
Upload a build
Mobile only. Web projects test a URL and install nothing.
POST /v1/artifacts → { id, upload_url, upload_content_length, expires_at_iso }
PUT <upload_url> → the bytes, straight to storage
POST /v1/artifacts/{id}/complete
The bytes never pass through the API — you PUT them directly to storage using
the signed URL. Send exactly the Content-Length returned as
upload_content_length; the signature binds it, and a different value is
rejected.
Declare the file's size_bytes, its sha256, and the package_id (Android
package or iOS bundle id) that the build contains. The package id is checked
against your project's — a mismatch means the agent would install one app and
launch another, which would report a verdict about the wrong software.
Builds are kept 7 days and capped at 200 MB. Long enough to re-run a pull request; not a place to store artifacts.
For an iOS simulator build, zip the .app bundle and upload the zip — a
.app is a directory, so it is not a file you can upload. 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 this change should be exercised against. Map your PR description to it. |
artifact_id |
the build to install, from the upload above |
target_url |
web only — the preview deployment to test instead of your project's URL |
branch, commit_sha, environment |
free-text traceability |
pr_number, pr_url, pr_title |
pull-request context, entirely optional |
Which type to use:
regression— diff the whole app against your pinned baseline. Catches visual changes, navigation changes, structural changes and crashes.feature— a goal-scoped run over the area your change touches. It has no baseline to diff against, so it catches crashes and logged errors in that area. It does not tell you whether anything looks different. That is narrower than "test what this PR changed" may sound, and worth knowing before you rely on it.
Testing a preview deployment. For web, target_url points the run at your
PR's preview build. Because previews live off your own domain
(*.vercel.app, *.netlify.app), add those hosts to the project's
preview-domain allowlist in project settings first — otherwise the run is
refused, deliberately, so a stray URL cannot aim a run at somebody else's site.
Wait for it, then read the verdict
GET /v1/runs/{id} → { status, ... }
GET /v1/runs/{id}/summary → { status, reasons, blocking, markdown, superseded }
Poll GET /v1/runs/{id} until status is one of succeeded, failed,
cancelled.
processingis not finished. The analysis that produces the findings runs duringprocessing. A client that stops as soon as the status is no longerrunningreads the verdict before the findings exist, and reports a confident pass on a broken change. Check for membership of the three terminal statuses — never "not running".
Then GET /v1/runs/{id}/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.
Give up before an hour. A run nobody has picked up is cleaned up after 60 minutes. Waiting past that means polling a run that has already been abandoned, and reporting that as your app's verdict. 45 minutes is a sensible ceiling.
Pushing again while a run is in flight
Push six times and you get the in-flight run plus one run for the sixth commit — not six runs, and not the second commit's result.
A run an agent has already started always finishes. A queued run that no agent has picked up yet is replaced by the newer one. Replaced runs do not count against your plan.
When your job is the one that got replaced, summary returns
superseded: true with status: "pass". Exit 0. Being replaced is a skip,
not a failure — a newer run is about to answer the same question, and a red check
here would just be noise on a busy pull request.
When things go wrong
| What happened | What you see |
|---|---|
| No paired desktop online | The run stays queued; your step times out saying so |
| Another run in progress | Queued behind it; same timeout applies |
| No production run pinned | 400 at trigger time, naming the two-step fix |
| Over your plan's allowance | 402 at trigger time |
| Build missing, expired, or the wrong package | 400 at trigger time — no run starts |
| Run itself errored | summary fails, naming the error |
| Replaced by a newer commit | superseded: true, exit 0 |
In this version, everything that reaches your CI step and is not a pass renders as a failed check. The summary text tells you which of the above it was.
Fork pull requests are not special-cased — gate them yourself. Roverly's
API is CI-agnostic and has no way to know a pull request came from a fork, so a
fork PR starts a real run and spends a real slot from your monthly pool. On a
public repository that is usually not what you want, both for cost and because a
contributor's token generally cannot post the comment back. Skip the step in
your pipeline's own condition — on GitHub Actions, for example,
if: github.event.pull_request.head.repo.full_name == github.repository.
Plans
No plan gates this API, and there is no separate CI quota. A CI-triggered
run draws on the same monthly pool of agentic runs as a run you start from the
desktop, and every plan can call /v1.
What differs is how many runs the pool holds, and what happens when you exhaust it:
| Plan | Runs included per month | Past the pool |
|---|---|---|
| Starter | 8 | 402 — runs are refused until the month rolls over |
| Pro | 40 | Metered as pay-as-you-go; runs keep going |
| Enterprise | Unlimited | — |
The practical consequence for Starter: eight gated pull requests a month, and
the ninth POST /v1/runs returns 402 rather than queueing. If your team
merges more often than that, Pro is the plan that does not stop.
Questions, or an integration we do not cover? Get in touch.