A prompt edit is a code change that ships without a compiler, a type checker, or — at most companies — a single test. This playbook gives prompts what code has had for twenty years: a declarative test config, a CI gate on every PR, and a failed check when an edge case regresses.
Tier: Core · Workflow area: Testing AI systems — foundations
You will encode prompts, providers, and assertions in a promptfooconfig.yaml, matrix across prompt versions and models, and wire the eval into GitHub Actions so "improved the summary prompt" cannot silently break refund-request handling again.
npm i -g promptfoo), provider API keys available as CI secrets.Declare the config: prompts × providers × tests.
# promptfooconfig.yaml
prompts:
- file://prompts/summarize_v3.txt
- file://prompts/summarize_v4_candidate.txt
providers:
- id: anthropic:claude-sonnet-4-5
config: { temperature: 0 }
defaultTest:
assert:
- type: is-json
- type: javascript
value: "JSON.parse(output).summary.length <= 600 || 'summary too long'"
tests:
- description: "refund edge case (INC-231)"
vars: { ticket: file://golden/gs-0087-input.txt }
assert:
- type: contains-all
value: ["refund delay", "damaged item"]
- type: not-contains
value: "refund approved"
- description: "hostile tone stays professional"
vars: { ticket: file://golden/gs-0104-input.txt }
assert:
- type: llm-rubric
value: "Summary is neutral; does not repeat abusive language"
Note the shape: cheap deterministic asserts in defaultTest run on every case; judge rubrics appear only where judgment is required (the layered-assertion playbook applies verbatim here).
Run locally and read the matrix.
promptfoo eval && promptfoo view
┌──────────────────────────┬──────────────┬────────────────┐
│ │ summarize_v3 │ v4_candidate │
├──────────────────────────┼──────────────┼────────────────┤
│ refund edge case │ PASS │ FAIL not-cont. │
│ hostile tone │ PASS │ PASS │
│ 41 more cases │ 41/41 │ 40/41 │
└──────────────────────────┴──────────────┴────────────────┘
This side-by-side is the entire value proposition: v4 reads better and quietly re-introduced the INC-231 failure. Without the matrix, that discovery happens in production.
Handle non-determinism explicitly. For variance-prone cases, add repeats and thresholds rather than praying:
tests:
- description: "ambiguous ticket - statistical"
options: { repeat: 5 }
threshold: 0.8
vars: { ticket: file://golden/gs-0042-input.txt }
Temperature 0 plus repeat-with-threshold on the hard cases keeps the gate stable without hiding real regressions (the statistical-criteria playbook covers choosing N and thresholds).
Gate the PR in GitHub Actions.
# .github/workflows/prompt-eval.yml
name: prompt-regression
on:
pull_request:
paths: ["prompts/**", "promptfooconfig.yaml", "golden/**"]
jobs:
eval:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<pinned-sha>
- uses: promptfoo/promptfoo-action@<pinned-sha>
with:
config: promptfooconfig.yaml
openai-api-key: ${{ secrets.OPENAI_API_KEY }}
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
env:
PROMPTFOO_CACHE_ENABLED: "true"
Path-filtering is the cost control: the eval spends API money only when prompts, config, or golden data change. The action posts the pass/fail matrix as a PR comment — reviewers see exactly which case broke, with outputs attached.
Set the merge policy and write it down. Recommended defaults: any known_failures-stratum case failing blocks merge, period; other regressions block unless the PR explicitly documents the trade ("v4 accepts 1 fail on ambiguous-input for +6 passes on multi-issue") and a named reviewer approves. Regressions you can see and choose are engineering; regressions you cannot see are the thing this gate exists to end.
Use the same rig for model migrations. Swap-in a second provider and rerun — the matrix now answers "can we move to the cheaper model?" with per-case evidence:
providers:
- anthropic:claude-sonnet-4-5
- anthropic:claude-haiku-4-5 # migration candidate
This turns the periodic "new model dropped" scramble into a 20-minute eval run, which is the quiet superpower of keeping this config maintained.
The unit of regression is the golden case, not the prompt. Prompts are allowed to change freely — that is the point — as long as every known-failure case still passes and every trade-off is visible in a PR comment before merge, not in a support queue after.
promptfoo eval --output results.json in a job and fail on nonzero exit; render the matrix into the MR via promptfoo output formats.--filter-pattern.promptfooconfig.yaml files and runs each — keeps blast radius and cost per PR small.file:// templates with vars) — otherwise green CI validates a prompt nobody runs.llm-rubric line costs money and adds variance on every PR. Push checks down the assertion hierarchy; rubrics are for the few genuinely judgment-shaped criteria.threshold on a failing case is accepting a regression without the paperwork. Loosen only via the documented merge policy.anthropic:claude-latest-style aliases make Tuesday's pass and Thursday's fail incomparable. Pin model versions; upgrade them as deliberate, evaluated PRs.Add promptfooconfig.yaml to the CODEOWNERS entry for QA. Prompt edits then automatically request review from the people who own the regression gate — which quietly ends the era of prompts changing in a dashboard on Friday afternoon.