CI integration
Run Vera from GitHub Actions, GitLab CI, Bitbucket Pipelines or any CI system over the raw API, with JUnit XML reports and consistent gating behaviour.
Trigger Vera tests from CI with an API token and fail the build when a test fails — while quarantined tests, retry-flakes and healed passes surface as warnings instead of breaking the merge.
This page runs a fixed set: a test, a suite, or the whole project. To run only the tests a pull request's diff actually touches, see §8 and change impact.
1. Mint a token
In the dashboard, open Settings → CI Tokens (or POST /api/tokens with
{ "name": "ci" }). The vera_… token is shown once — store it as a CI
secret (e.g. VERA_TOKEN). Also note your project id and your Vera base
URL (VERA_URL) — e.g. the hosted deployment https://vera-agent.com.
2. GitHub Actions — the vera-run action (recommended)
name: E2E (Vera)
on: [pull_request]
jobs:
vera:
runs-on: ubuntu-latest
steps:
# ... deploy your PR preview and capture its URL ...
- uses: mahmoodnasr/vera-run@v1 # once published; or vendor the dir and use `./.github/actions/vera-run`
with:
api-url: ${{ secrets.VERA_URL }}
api-token: ${{ secrets.VERA_TOKEN }}
project: ${{ vars.VERA_PROJECT_ID }}
# optional:
# suite: suite_xyz # or `test: test_xyz`
# environment: staging # named Vera environment profile
# preview-url: ${{ steps.deploy.outputs.url }}
# fail-on-flaky: 'true'
# fail-on-healed: 'true'The action fails the job only on blocking failures: a failed quarantined
test or a test that passed on retry produces a ::warning:: annotation and a
job-summary table (with a report link) instead. When preview-url is omitted
it falls back to VERCEL_URL / DEPLOY_PRIME_URL / DEPLOY_URL — so the same
suite runs against every PR's preview deploy. See
actions/vera-run for all inputs/outputs.
3. GitLab CI
Copy ci/gitlab/.gitlab-ci.yml into your
project's .gitlab-ci.yml (or include: it), then:
- Settings → CI/CD → Variables: add
VERA_TOKEN= yourvera_…token, Masked (and Protected if your MR branches are protected). - Set
VERA_URLandVERA_PROJECTin the job'svariables:block.
vera-e2e:
stage: test
image:
name: alpine/curl:latest
entrypoint: ['']
variables:
VERA_URL: 'https://vera.example.com'
VERA_PROJECT: 'proj_abc123'
VERA_PREVIEW_URL: '$CI_ENVIRONMENT_URL' # review-app URL, when your deploy job sets `environment:`
script:
- | # the embedded gate script — see the template for the full body
artifacts:
when: always
reports:
junit: vera-junit/*.xml # per-step results in the MR test widget
dotenv: .vera/result.env # VERA_STATUS/VERA_PASSED/... for later jobsThe job needs only curl and a POSIX shell — no Node, no jq, no install
step. It deliberately does not set allow_failure: true: the script decides
what is blocking, and marking the whole job non-blocking would hide real
failures too.
4. Bitbucket Pipelines
Copy ci/bitbucket/bitbucket-pipelines.yml
into bitbucket-pipelines.yml (or merge its step into yours), then:
- Repository settings → Repository variables: add
VERA_TOKEN= yourvera_…token, Secured. - Edit the
export VERA_URL=/export VERA_PROJECT=lines at the top of the step (Bitbucket has no per-stepvariables:block).
The step writes JUnit XML to test-results/, which Bitbucket picks up
automatically and renders in the build's Tests tab. Bitbucket's default
image already has curl.
5. Gating parity across CI providers
All three surfaces gate identically. This is a product promise, not a coincidence: a quarantined test that reddened your GitLab pipeline while the same test only warned on GitHub would make quarantine worthless.
| Outcome | GitHub Action | GitLab CI | Bitbucket Pipelines |
|---|---|---|---|
| All tests passed | pass | pass | pass |
| A real (non-quarantined) failure | fail | fail | fail |
| A quarantined test failed | pass + warning | pass + warning | pass + warning |
| A test passed only on retry | pass + warning | pass + warning | pass + warning |
…with fail-on-flaky opted in | fail | fail | fail |
| A test passed only after a healed selector | pass + warning | pass + warning | pass + warning |
…with fail-on-healed opted in | fail | fail | fail |
| Invalid/revoked token, 5xx, unreachable | fail | fail | fail |
Opt into failing on retry-flakes with fail-on-flaky: 'true' (GitHub) or
VERA_FAIL_ON_FLAKY: 'true' (GitLab/Bitbucket). Quarantined failures stay
non-gating either way — quarantine is the escape hatch, and an escape hatch that
can still fail the build is not one.
The two opt-ins are independent: fail-on-flaky never gates a healed pass,
and fail-on-healed never gates a flake. Both are exercised as their own rows
in the parity suite.
Green is not one thing
A run that reports passed got there in one of three ways, and Vera tells you
which:
| What happened | Gates by default | |
|---|---|---|
| PASS | The test did what it says, first time | — |
| PASS ON RETRY | It failed, then passed on a retry — flaky | no (fail-on-flaky) |
| PASS ON HEAL | A selector went stale; self-heal rewrote it and the test then passed | no (fail-on-healed) |
Healing only ever rewrites the locator, never the expectation — the
assertion still had to hold against your app, which is why a healed pass is not
a failure. What it can hide is a semantic change: if a Delete customer
button disappears and the healer binds the step to a neighbouring control, a
broad assertion can still pass. The interaction the test was written to exercise
is gone, and a pass/fail column cannot show you that.
So healed is reported beside the status rather than folded into it, and the
run detail names every rewritten selector (before → after). Teams gating a
release branch typically set fail-on-healed: 'true' there and leave it off for
feature branches: a heal is then a merge-blocking prompt for a human to confirm
the test still means what it meant.
How the promise is kept:
- GitLab and Bitbucket run the same script,
ci/vera-gate.sh, embedded verbatim in both templates. apps/server/src/services/ci-gating-parity.test.tsstands up a stub/api/v1, executes both the GitHub Action and the shell gate against every row of that table, and fails if their exit codes differ or if either template's embedded copy has drifted.pnpm testruns it, so the table above cannot quietly go stale.
6. The raw API (any CI system)
POST /api/v1/projects/:id/run— body{ "testId": "…" },{ "suiteId": "…" },{ "testIds": ["…", "…"] }(max 50; more is400 too_many_test_idsand nothing runs — a result built from the first 50 of 84 would read like a complete one), or{ "all": true }. Exactly one of them: combiningtestIdswithtestId/suiteId/allis rejected. Optional"environment": "staging"(named profile) and"baseUrlOverride": "https://pr-42.example.com"(wins over the environment — meant for per-PR preview URLs). Runs synchronously and returns the terminal result:{ "runIds": ["…"], "status": "passed", "passed": 12, "failed": 1, "blockingFailed": 0, "quarantinedFailed": 1, "passedOnRetry": 2, "healed": 1, "reportUrl": "https://vera.example.com/projects/proj_x/tests" }Gate CI on
status(already quarantine-aware: only non-quarantined failures fail it) or onblockingFailed.healedcounts the runs that reached green only after self-heal rewrote a selector — never part ofstatus, always reported. Single-test runs return{ "status", "blocking", "quarantined", "passedOnRetry", "healed", "reportUrl" }— gate onblocking.GET /api/v1/runs/:id— current status (+passedOnRetry,healed).GET /api/v1/runs/:id/wait— long-polls up to 60s for a terminal status (202if still running).GET /api/v1/runs/:id/junit— the run as JUnit XML (see §7).GET /api/v1/suite-runs/:id/junit— a suite run as JUnit XML (one<testsuite>per member run).
All require Authorization: Bearer vera_…. A revoked/invalid token → 401.
resp=$(curl -sS -X POST \
-H "Authorization: Bearer $VERA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"all": true}' \
"$VERA_URL/api/v1/projects/$PROJECT_ID/run")
[ "$(echo "$resp" | jq -r '.status')" = "passed" ] || { echo "::error::Vera run failed"; exit 1; }7. JUnit XML — render results in the PR checks UI
Every run (and every suite run) can be exported as JUnit XML, the format
that test-report actions understand. Feed it to
dorny/test-reporter,
mikepenz/action-junit-report,
or your CI's native JUnit ingester and you get a per-step pass/fail breakdown
attached to the check — no scraping the JSON.
GET /api/v1/runs/:id/junit→ one<testsuite>(the test), one<testcase>per step.GET /api/v1/suite-runs/:id/junit→ a<testsuites>wrapping one<testsuite>per member run.
Both respond Content-Type: application/xml with an attachment
Content-Disposition, and require Authorization: Bearer vera_…. The
session-authed browser variants (GET /api/runs/:id/junit,
GET /api/suite-runs/:id/junit) return the same body for the dashboard.
What maps to what:
| JUnit | Vera |
|---|---|
<testsuite name> | the test's name (matrix/dataset runs append [cell]/[row: …]) |
<testcase name> | #<idx> <action> <selector/url snippet> for each step |
<testcase classname> | the test's name |
<testcase time> | the step's duration, in seconds |
<failure message=…> + body | a failed step's error (first line as the message, full text in a CDATA body) |
<skipped/> | a step that never ran (e.g. after an earlier failure) |
<properties> | runId, testId, engine, environment, status |
Sample GitHub Actions step — trigger a run, wait for it, download its JUnit, and publish it:
- name: Run Vera + publish JUnit
run: |
# trigger a single test (or suiteId / all:true) and capture the run id
run_id=$(curl -sS -X POST \
-H "Authorization: Bearer ${{ secrets.VERA_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"testId": "'"${{ vars.VERA_TEST_ID }}"'"}' \
"${{ secrets.VERA_URL }}/api/v1/projects/${{ vars.VERA_PROJECT_ID }}/run" \
| jq -r '.runIds[0]')
# block until it reaches a terminal status, then fetch the JUnit report
curl -sS -H "Authorization: Bearer ${{ secrets.VERA_TOKEN }}" \
"${{ secrets.VERA_URL }}/api/v1/runs/$run_id/wait" > /dev/null
curl -sS -H "Authorization: Bearer ${{ secrets.VERA_TOKEN }}" \
"${{ secrets.VERA_URL }}/api/v1/runs/$run_id/junit" -o vera-junit.xml
- name: Publish test report
if: always()
uses: mikepenz/action-junit-report@v4
with:
report_paths: vera-junit.xmlThe batch trigger ({"suiteId": "…"} / {"all": true}) returns a runIds
array — fetch GET /api/v1/runs/<id>/junit per id and hand the reporter all the
files, or, for suite runs recorded as a suite_runs row (dashboard- or
scheduler-triggered), pull the single combined
GET /api/v1/suite-runs/<suiteRunId>/junit.
8. Run only the impacted tests (vera check)
Everything above runs a fixed selection. On a pull request you usually want
the selection to follow the diff — which is vera check --run, documented in
full at change impact:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # so `origin/<base_ref>` resolves
- name: Impacted tests
run: npx vera-agent check --base origin/${{ github.base_ref }} --run
env:
VERA_API_URL: ${{ vars.VERA_API_URL }}
VERA_PROJECT_ID: ${{ vars.VERA_PROJECT_ID }}
VERA_TOKEN: ${{ secrets.VERA_TOKEN }}Three things to know before you paste that anywhere:
- The variable names differ from the rest of this page. The CLI reads
VERA_API_URL/VERA_PROJECT_ID/VERA_TOKEN; thevera-runaction takesapi-url/project/api-tokeninputs, and the shell gate readsVERA_URL/VERA_PROJECT/VERA_TOKEN. Only the token name is shared. fetch-depth: 0is load-bearing.actions/checkoutdefaults to a shallow clone whereorigin/<base_ref>does not exist, and an explicit--basethat does not resolve fails the step.--strictis opt-in and comes later. It fails the build on a business rule the diff touches with no adequate test, and on a project whose map coverage is still near zero it would be red on every PR. Turn it on once the numbers say you can — the reasoning is in §9 of the change-impact guide.
Exit codes are 0 clean · 1 usage/config · 2 could not complete · 3 a test
failed · 4 --strict found a gap. A sticky PR comment rendering this report
is planned, not shipped; today it is a step that passes or fails and prints to
the log, plus --json if you want to build your own.
Notes
- Tokens are org-scoped; a token only reaches its own org's projects.
- Store only ciphertext of the token on the server (SHA-256) — the plaintext is never retrievable after creation. Rotate by minting a new token and revoking the old one.
- Healed passes:
healedistrue/a count when a run reached green only after a stale selector was rewritten mid-run. It never changesstatus; gate on it withfail-on-healed/VERA_FAIL_ON_HEALEDif you want a human to confirm the rewritten step still exercises what it was written for. - Quarantine/flake semantics: a quarantined test keeps running and reporting,
but its failures never fail
status; a run that failed then passed on a retry reportspassedOnRetry. Both appear in the response so CI can surface them as warnings.