Skip to Content
Regression testing in CI/CD

Regression testing in CI/CD

Build your agent in GitHub Actions, run it on your bench, and fail the job when a version gets worse.

When you finish this page, a GitHub Actions workflow builds your agent, runs it on your bench, and fails when the new version scores below a floor you set. It uses the same API calls as Add your agent and Run a trial, wrapped in one script.

What each run does

  1. Builds the image and pushes it to the GitHub Container Registry, by digest.
  2. Checks that the bench can pull the image, then registers it as a new candidate.
  3. Adds the candidate to one long-lived trial, so every version faces the same tasks. The first run starts the trial.
  4. Polls the trial’s results until none of the candidate’s attempts is running or waiting for its grade.
  5. Reads the candidate’s pass rate and fails the job if even the top of its confidence interval is below MIN_PASS_RATE.

Every version stays in the trial, so the console shows them side by side.

Set up

1. A candidate template. Save the candidate body from step 7 of Add your agent as bench/candidate.json in your repository, without name and image.ref. The script fills both in on each run.

bench/candidate.json
{ "image": {"port": 8008, "path": "/chat", "health_path": "/health"}, "channel_config": { "init": null, "channel": { "type": "http", "url": null, "method": "POST", "request": {"message_field": "message", "session_field": "session_id"}, "response": {"type": "json", "message_field": "response", "session_field": "session_id"} } } }

2. Variables and a secret. In the repository’s Settings → Secrets and variables → Actions, add:

NameKindValue
BENCH_APIvariablehttps://bench.api.veris.ai/v1
BENCH_IDvariableyour bench id
MIN_PASS_RATEvariablethe floor, from 0 to 100 (see Choose the floor)
BENCH_API_KEYsecretyour vbk_ key

3. Image access. The workflow pushes to ghcr.io/<owner>/<repository>. The first push creates a private package, even from a public repository, and the bench can’t pull a private package without a login. After the first run, do one of these once:

  • make the package public: on GitHub, open the package, then Package settings → Change visibility; or
  • save a registry login for it (Add your agent, step 5) with a classic token that has only the read:packages scope.

Until then, the script stops before it registers anything, with the reason check-image gives. To push to another registry, change the login and build steps; the script only needs the image reference with its digest.

The script

.github/bench/regression.sh
#!/usr/bin/env bash # Add this build's image to a long-lived trial as a new candidate, wait for its # results, and fail when even the top of its confidence interval is under MIN_PASS_RATE. set -euo pipefail for v in BENCH_API BENCH_ID VERIS_API_KEY TRIAL_NAME IMAGE_REF CANDIDATE_JSON WAIT_MINUTES MIN_PASS_RATE; do [ -n "${!v:-}" ] || { echo "Set $v." >&2; exit 1; } done [[ $MIN_PASS_RATE =~ ^[0-9]+(\.[0-9]+)?$ ]] || { echo "MIN_PASS_RATE must be a number from 0 to 100, not \"$MIN_PASS_RATE\"." >&2; exit 1; } [[ $WAIT_MINUTES =~ ^[0-9]+$ ]] || { echo "WAIT_MINUTES must be a whole number, not \"$WAIT_MINUTES\"." >&2; exit 1; } # One bench API call: prints the response, or the API's error on stderr and fails. call() { local body body=$(curl -sS --max-time 60 --fail-with-body \ -H "Authorization: Bearer $VERIS_API_KEY" -H "Content-Type: application/json" "$@") \ || { echo "Bench API error: $body" >&2; return 1; } printf '%s\n' "$body" } get() { call --retry 3 "$@"; } # reads retry brief outages; writes never retry, so nothing is created twice send() { call "$@"; } # 1. Find the trial by name, and refuse one whose tasks changed since it started. trials='[]'; offset=0 while :; do page=$(get "$BENCH_API/benches/$BENCH_ID/trials?limit=200&offset=$offset") trials=$(jq -c --argjson found "$trials" --arg n "$TRIAL_NAME" '$found + [.items[] | select(.name == $n)]' <<<"$page") offset=$((offset + 200)) [ "$offset" -lt "$(jq .total <<<"$page")" ] || break done if [ "$(jq length <<<"$trials")" -gt 1 ]; then echo "More than one trial is named \"$TRIAL_NAME\". Use a unique name." >&2; exit 1 fi trial_id=$(jq -r '.[0].id // empty' <<<"$trials") if [ -n "$trial_id" ] && jq -e '.[0] | .outdated or (.edited_task_ids + .stale_persona_ids | length > 0)' <<<"$trials" >/dev/null; then echo "Trial $trial_id changed under its tasks: $(jq -c '.[0] | {outdated_reasons, edited_task_ids, stale_persona_ids}' <<<"$trials")" >&2 echo "Start a new series with a new TRIAL_NAME." >&2; exit 1 fi # 2. Check that the bench can pull the image, then register it as a new candidate. check=$(jq -n --arg r "$IMAGE_REF" '{ref: $r}' | send -X POST "$BENCH_API/registries/check-image" -d @-) case $(jq -r .status <<<"$check") in ok | public) ;; *) echo "The bench can't pull $IMAGE_REF: $(jq -r '"\(.status): \(.detail)"' <<<"$check")" >&2; exit 1 ;; esac sha=${GITHUB_SHA:-local} candidate_id=$(jq --arg n "$TRIAL_NAME $(date -u +%F) ${sha:0:7}" --arg r "$IMAGE_REF" '. + {name: $n} | .image.ref = $r' "$CANDIDATE_JSON" \ | send -X POST "$BENCH_API/benches/$BENCH_ID/candidates" -d @- | jq -r .id) echo "Candidate $candidate_id ($IMAGE_REF)" # 3. Add it to the trial, or start the trial on the first run. if [ -n "$trial_id" ]; then jq -n --arg c "$candidate_id" '{candidate_ids: [$c]}' \ | send -X POST "$BENCH_API/trials/$trial_id/candidates" -d @- >/dev/null else trial_id=$(jq -n --arg n "$TRIAL_NAME" --arg c "$candidate_id" '{name: $n, kind: "full", candidate_ids: [$c]}' \ | send -X POST "$BENCH_API/benches/$BENCH_ID/trials" -d @- | jq -r .id) fi echo "Trial $trial_id: https://benchmark.veris.ai/benchmarks/$BENCH_ID?trial=$trial_id" # 4. Wait until none of the candidate's attempts is running or waiting for its grade. # Each read also moves the trial forward. deadline=$(( $(date +%s) + WAIT_MINUTES * 60 )) while :; do row=$(get "$BENCH_API/trials/$trial_id/results" \ | jq -c --arg c "$candidate_id" '.rollups[] | select(.candidate_id == $c)') [ -n "$row" ] || { echo "Trial $trial_id has no results row for candidate $candidate_id." >&2; exit 1; } [ "$(jq .pending <<<"$row")" -eq 0 ] && break if [ "$(date +%s)" -ge "$deadline" ]; then echo "Trial $trial_id still running after $WAIT_MINUTES minutes." >&2; exit 1 fi sleep 60 done # 5. Gate on the candidate's row. jq '{pass_rate, ci_low, ci_high, n, passes}' <<<"$row" if jq -e --argjson min "$MIN_PASS_RATE" '.ci_high < $min' <<<"$row" >/dev/null; then echo "Pass rate $(jq .pass_rate <<<"$row")% is below $MIN_PASS_RATE% even at the top of its interval." >&2; exit 1 fi

The workflow

.github/workflows/bench-regression.yaml
name: Bench regression on: workflow_dispatch: schedule: - cron: "0 6 * * *" # every day at 06:00 UTC permissions: contents: read packages: write # push the image to GHCR concurrency: group: bench-regression cancel-in-progress: false jobs: bench: runs-on: ubuntu-latest timeout-minutes: 360 steps: - uses: actions/checkout@v4 - name: Image name # GHCR needs it in lowercase run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push id: build uses: docker/build-push-action@v6 with: context: . push: true tags: ${{ env.IMAGE }}:${{ github.sha }} - name: Run on the bench env: BENCH_API: ${{ vars.BENCH_API }} BENCH_ID: ${{ vars.BENCH_ID }} MIN_PASS_RATE: ${{ vars.MIN_PASS_RATE }} VERIS_API_KEY: ${{ secrets.BENCH_API_KEY }} TRIAL_NAME: regression IMAGE_REF: ${{ env.IMAGE }}@${{ steps.build.outputs.digest }} CANDIDATE_JSON: bench/candidate.json WAIT_MINUTES: "300" run: bash .github/bench/regression.sh

Choose when it runs

Pick one or more triggers under on:.

TriggerRunsSuits
workflow_dispatch:when someone presses Run workflowtrying it out, one-off checks
schedule: with a cron: lineon a timetablethe full trial, nightly or weekly
push: with branches: [main]after each mergea check on every change
pull_request:on each pull requesta small trial, see below

A full trial can take hours: every task, every archetype, three times. The workflow allows 300 minutes of waiting inside GitHub’s 360-minute job limit, which is too long for a pull-request check. So most teams run two trials from the same script:

  • The full trial, nightly. The workflow above, as it is.
  • A small trial, on every pull request. Start a trial once with a few task_ids, as in Run a trial, and name it, say, pr-check. Then copy the workflow, set on: pull_request:, point TRIAL_NAME at pr-check, and lower WAIT_MINUTES. Each pull request adds its build as a new candidate to that trial and fails under the same floor.

Choose the floor

The job fails only when ci_high, the top of the new version’s interval, is below MIN_PASS_RATE. Noise alone rarely does that. Start a little below your agent’s current pass rate, and raise the floor as the agent improves. With MIN_PASS_RATE at 0, the job reports the result and never fails on it.

When the job fails

MessageWhat to do
Set MIN_PASS_RATE. (or another name)Add the missing variable or secret.
The bench can't pull ghcr.io/...: needs_login: ...Make the package public, or save a login (see Set up).
More than one trial is named "regression"Give TRIAL_NAME a unique name.
Trial trial_... changed under its tasksThe bench changed since the trial started. Set a new TRIAL_NAME to start a new series.
Trial trial_... still running after 300 minutesRead the trial in the console. Raise WAIT_MINUTES, or use a smaller trial.
Pass rate ...% is below ...% even at the top of its intervalThe new version got worse. Open the trial in the console and compare transcripts.
Bench API error: {...}The API’s own message. Fix what it names, for example the image reference or bench/candidate.json.

A full example

The card-replacement agent  in the Veris cookbook runs this pattern every night: its workflow  and script . It also leaves Veris-side failures out of the pass rate and draws a chart of the trial over time.