Regression eval suites in CI
Run evals in CI so prompt and model changes can't silently make an AI feature worse, with thresholds, flakiness handling, cost control and reporting.
On this page
Why this matters
AI features regress quietly. A prompt change that fixes one kind of request breaks another. A new model version is better on average and worse on your most important category. A teammate tightens an instruction for length and the answers start missing a key detail. None of these show up in code review, because the code looks fine.
Running your eval suite in CI catches these changes before users do, the same way unit tests catch code regressions. This article covers what to run on every change, how to deal with outputs that vary from run to run, a working runner and GitHub Actions workflow, and how to keep the cost predictable.
What to run on every change
Running every eval case on every commit is slow and expensive. Split the suite in two:
- A smoke set on every pull request that touches prompts, model settings or AI code: 20 to 50 cases covering every category and every must-never-fail case. Fast enough to wait for.
- The full suite nightly, and before releases or model switches: every case, including LLM-judge criteria. Slower, more thorough, and the source of your trend line.
Show text descriptionHide text description
A sequence of 5 steps.
- Pull request : Changes a prompt, model setting or AI code
- Smoke evals : Code checks on a small set covering every category
- Compare to baseline : Block on regressions beyond tolerance, or any must-never-fail failure
- Merge : Baseline updated deliberately when a change improves scores
- Nightly full suite : All cases and judge criteria; results tracked over time
Trigger the smoke set only when relevant files change, so ordinary UI work doesn’t wait for evals or spend money on them.
Thresholds and flakiness
Model outputs vary between runs, so eval scores do too. A suite that fails randomly gets ignored, then disabled. Handle variation deliberately:
- Compare against a baseline, with a tolerance. Instead of a fixed number, compare each category’s pass rate with the last accepted run, and allow a small margin for noise, for example three percentage points. Larger drops block the merge.
- No tolerance for must-never-fail cases. Any failure there blocks, every time.
- Make cases less flaky. Many flaky cases have vague criteria. Tighten the criterion before reaching for statistics.
- Re-run genuinely variable cases. For a few cases where variation is inherent, run them three times and use the majority verdict.
- Grow the set. With more cases, one random failure moves the score less. Twenty cases per category is a reasonable floor.
Update the baseline deliberately, in the same pull request that improves the scores, so the history shows why it moved.
A CI workflow
The runner below executes the suite with a concurrency limit, computes the pass rate per category, compares it with a stored baseline, and exits with an error if any category regressed beyond the tolerance or any must-never-fail case failed. --smoke runs only the smoke cases; --update-baseline stores the results as the new baseline.
import Anthropic from '@anthropic-ai/sdk';
import { readFile, writeFile } from 'node:fs/promises';
const client = new Anthropic();
const SMOKE = process.argv.includes('--smoke');
const UPDATE_BASELINE = process.argv.includes('--update-baseline');
const CONCURRENCY = 4; // Stay well inside your rate limits.
const TOLERANCE = 0.03; // Allow three points of run-to-run noise before calling it a regression.
interface Case {
id: string;
category: string;
input: string;
mustInclude: string[];
smoke?: boolean;
}
type Scores = Record<string, number>;
/** The feature under test. Returns null if the response didn't finish normally, which fails the case. */
async function feature(input: string): Promise<string | null> {
const response = await client.messages.create({
model: 'claude-opus-5-5',
max_tokens: 4096,
output_config: { effort: 'low' },
system: 'Summarize the email thread in at most five short bullet points. Name the decision that is needed, if any.',
messages: [{ role: 'user', content: input }],
});
if (response.stop_reason !== 'end_turn') return null;
return response.content.flatMap((b) => (b.type === 'text' ? [b.text] : [])).join('');
}
const passes = (output: string | null, c: Case) =>
output !== null && c.mustInclude.every((fact) => output.toLowerCase().includes(fact.toLowerCase()));
/** Runs tasks with at most `limit` in flight. */
async function mapLimited<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const results: R[] = [];
let next = 0;
const worker = async () => {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i] as T);
}
};
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
const all: Case[] = JSON.parse(await readFile('evals/cases.json', 'utf8'));
const cases = SMOKE ? all.filter((c) => c.smoke) : all;
const results = await mapLimited(cases, CONCURRENCY, async (c) => {
try {
return { c, passed: passes(await feature(c.input), c) };
} catch {
// An API error fails the case rather than crashing the whole run.
return { c, passed: false };
}
});
const scores: Scores = {};
for (const category of new Set(cases.map((c) => c.category))) {
const inCategory = results.filter((r) => r.c.category === category);
scores[category] = inCategory.filter((r) => r.passed).length / inCategory.length;
}
const baseline: Scores = JSON.parse(await readFile('evals/baseline.json', 'utf8').catch(() => '{}'));
const problems: string[] = [];
for (const [category, score] of Object.entries(scores)) {
const before = baseline[category];
const line = `${category.padEnd(20)} ${(score * 100).toFixed(0)}%${before === undefined ? '' : ` (baseline ${(before * 100).toFixed(0)}%)`}`;
console.log(line);
if (category === 'must-never-fail' && score < 1) problems.push(`must-never-fail: ${(score * 100).toFixed(0)}%, expected 100%`);
else if (before !== undefined && score < before - TOLERANCE) problems.push(`${category} dropped from ${(before * 100).toFixed(0)}% to ${(score * 100).toFixed(0)}%`);
}
await writeFile('evals/latest.json', JSON.stringify(scores, null, 2));
if (UPDATE_BASELINE) await writeFile('evals/baseline.json', JSON.stringify(scores, null, 2));
if (problems.length > 0) {
console.error(`\nRegressions:\n- ${problems.join('\n- ')}`);
process.exitCode = 1;
} And a GitHub Actions workflow that runs the smoke set on relevant pull requests and the full suite every night:
name: Evals
on:
pull_request:
paths: ['prompts/**', 'lib/ai/**', 'evals/**']
schedule:
- cron: '0 3 * * *' # Nightly full suite at 03:00 UTC
jobs:
evals:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Smoke evals
if: github.event_name == 'pull_request'
run: npx tsx evals/run-suite.ts --smoke
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
- name: Full eval suite
if: github.event_name == 'schedule'
run: npx tsx evals/run-suite.ts
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
- name: Keep results
if: always()
uses: actions/upload-artifact@v4
with:
name: eval-results
path: evals/latest.json A few practical notes:
- Store the API key as a repository secret, never in the workflow file. Pull requests from forks don’t receive secrets by default, which is the safe behavior; run evals for external contributions manually.
- The paths filter means only changes to prompts, AI code or evals trigger the smoke run.
- Keep the results artifact so you can compare runs and investigate failures later.
- Add judge criteria to the nightly run using the calibrated judge, and keep smoke runs to fast code checks.
Keeping costs predictable
Evals call the model, so they cost money on every run. Keep it under control:
- Smoke sets small, full suites nightly, not on every commit.
- Trigger on relevant paths only.
- Use prompt caching for long shared system prompts or documents across cases; see estimating cost per task.
- Run the full suite through the Batches API (opens in a new tab) when results don’t need to be immediate, at a discount.
- Set a spending limit for the key CI uses, separate from production.
- Estimate per run: cases × average cost per case. A smoke set of 30 cases at a few cents each costs about as much as a coffee per day of active development, a bargain compared with shipping a regression.
Common mistakes
- Running the full suite on every commit. Slow and costly. Use a smoke set on relevant pull requests and the full suite nightly.
- Fixed thresholds with no tolerance. Normal run-to-run variation then fails builds randomly and people stop trusting the check.
- Tolerance on must-never-fail cases. These should block on any failure.
- Moving the baseline silently. Update it in the pull request that improved scores, so the history explains every change.
- API keys in workflow files. Use repository secrets and a separate key with its own spending limit.
Try it
Exercise
About 90 min
Add an eval gate to a repository
- Put 20 to 30 cases in
evals/cases.json, marking about half assmoke, with at least two in amust-never-failcategory. - Adapt
run-suite.tsto call your feature, and run it locally with--update-baselineto create the first baseline. - Add the workflow, with your API key as a repository secret.
- Open a pull request that deliberately makes the prompt worse, and confirm the check fails. Then revert it.
Show a hint
A quick way to make the prompt worse on purpose: remove the instruction that covers your must-never-fail case. The gate should block it.
Show a worked solution
A working setup looks like this:
evals/cases.jsonwith categories and smoke flags;evals/baseline.jsoncommitted.- Pull requests touching
prompts/run about 15 smoke cases in under two minutes. - The deliberately degraded prompt fails with a message like “must-never-fail: 50%, expected 100%”, and the pull request can’t merge.
- A nightly run uploads
latest.json, giving you a daily record of quality.
Check your understanding
Key takeaways
- Run a smoke set on relevant pull requests and the full suite nightly.
- Compare categories with a baseline plus tolerance; block on any must-never-fail failure.
- Reduce flakiness with tighter criteria, more cases and majority votes for inherently variable cases.
- Keep cost predictable with path filters, caching, batching and a separate key with a spending limit.
Progress is saved in this browser and lights up the roadmap.
Related articles
- The AI product lifecycle, from problem to iteration The six-step loop every AI feature goes through, from user problem to shipping and learning, with the artifact and exit criterion for each step. Level: Beginner
- Feature flags and staged rollouts for AI Ship AI features gradually with feature flags, covering staged exposure, kill switches, flagging prompts and models, and rollback plans that work in minutes. Level: Advanced
- Why evals are product metrics Evals measure how often an AI feature is good enough for users. Learn why they belong with product metrics, not only in an ML notebook. Level: Advanced