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. (Healed passes are described here for completeness: a CI-started run does not heal today, so that outcome does not yet occur — details.)
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 |
The two healed rows describe what every surface would do, and the parity suite proves they agree — but a CI-started run cannot reach that outcome today. See Healed passes are not reachable from CI yet.
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) — and not reachable from CI yet |
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.
Healed passes are not reachable from CI yet
PASS ON HEAL is a real outcome, but not one a CI-started run produces today, and you should know that before you rely on the opt-in.
A run started through POST /api/v1/…/run — which is what all three surfaces
above call — executes cloud-side, in the Vera server process or on its worker,
whatever the project's runTarget says, and neither attaches a selector
healer. Healing is an unbounded number of extra AI calls per run, and a CI run
that silently rewrote its own selectors would make every green ambiguous — which
is exactly the ambiguity this section exists to remove.
So on the CI path healed is false / 0, and fail-on-healed /
VERA_FAIL_ON_HEALED never fires. The flag is kept, documented and executed by
the gating-parity suite so that a team can set it once and have it take effect
the day a CI-started run can heal, rather than finding the control missing at
the moment they need it.
Where a run can heal. Only where someone opted in, and none of it is a run these surfaces start:
vera-agentruns, when an org admin has turned onsettings.agent.selfHeal(PUT /api/org/settings, off by default). That covers every run the agent executes:vera-agent startdraining the queue,vera run,vera verify, andvera check --run --local/vera watch --affected. Avera check --run --localstep inside a CI job can therefore heal. It reports each healed pass with the rewritten selectors, and gates on it with its own--fail-on-healedflag or the sameVERA_FAIL_ON_HEALED=truethe shell gate reads — opt-in, under the same rules as the rows above, exiting5(details). Without the opt-in a healed pass there is green.- The repair surfaces — the Auto-heal button and auto-repair.
- AI test generation and planning runs, which self-heal unless the request turns it off.
Those runs carry healed: true on GET /api/v1/runs/:id, which reads whatever
the run row holds.
fail-on-flaky is unaffected. Retries happen on every run, healer or no
healer, so PASS ON RETRY is reported and gated today exactly as described above.
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, and always0from this endpoint today, because a run started here has no healer attached (why). Single-test runs return{ "status", "blocking", "quarantined", "passedOnRetry", "healed", "reportUrl" }— gate onblocking.GET /api/v1/runs/:id— current status (+passedOnRetry,healed). Unlike the run endpoint above this reads whatever the row holds, so it reportshealed: truefor a run that did heal: avera-agentrun withsettings.agent.selfHealon, a repair run, or an AI-generation run (where a run can heal).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 · 5 --fail-on-healed found a healed pass
(only a --local run can heal). 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. Note that a CI-started run does not heal today, so that opt-in is currently inert — the full explanation.fail-on-flakyis unaffected. - 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.