Wire your golden dataset (lab 08) and calibrated judge (lab 09) into GitHub Actions so that a prompt change which hurts faithfulness cannot merge.
You will build the gate, watch it pass on a clean baseline, then open a pull request with a deliberately broken prompt change and watch CI go red — and green again after the fix. This is the employer-verbatim skill: "eval harnesses wired into CI as regression gates."
Advanced · ~90 minutes hands-on · API spend under $1 per CI run (20 items, generation + judging).
You need a GitHub account, Python 3.11+, and an Anthropic API key.
mkdir evals-ci && cd evals-ci && git init
python -m venv .venv && source .venv/bin/activate
pip install anthropic pydantic && pip freeze > requirements.txt
export ANTHROPIC_API_KEY="sk-ant-..."
Copy in two assets from earlier labs: golden_v1.jsonl (lab 08's golden set) and judge.py (lab 09's frozen judge). If you skipped those labs, run lab 09's make_golden.py (Setup section) to produce the dataset, and use lab 09's judge.py verbatim — both are fully self-contained. Create a GitHub repo and add ANTHROPIC_API_KEY under Settings → Secrets and variables → Actions.
The system under test. The prompt lives in its own file so a PR diff on it is legible. Create prompt.txt:
You are the support bot for Orbitly. Answer the customer's question using ONLY
the policy context provided. If the context does not cover the question, say
you don't have that information and offer to escalate. Quote specific numbers
and durations exactly as written in the context. Keep answers under 80 words.
And app.py:
import os, anthropic
client = anthropic.Anthropic(max_retries=3)
MODEL = os.getenv("MODEL", "claude-haiku-4-5") # cheap tier for the bot under test
SYSTEM = open("prompt.txt").read()
def answer(question: str, context: str) -> str:
resp = client.messages.create(
model=MODEL, max_tokens=300, system=SYSTEM,
messages=[{"role": "user", "content": f"<context>{context}</context>\n\n{question}"}],
)
return next(b.text for b in resp.content if b.type == "text")
The gate. Two layers, cheap checks first: deterministic assertions, then the calibrated judge. Create eval_gate.py:
import json, random, sys
from app import answer
from judge import judge
THRESHOLD = 0.85 # min faithfulness pass-rate; set from your baseline minus margin
N = 20 # sampled per run to bound cost; nightly job can run all 60
items = [json.loads(l) for l in open("golden_v1.jsonl")]
random.seed(11) # fixed sample -> comparable across runs
sample = random.sample(items, N)
results, hard_fail = [], []
for it in sample:
out = answer(it["question"], it["context"])
# layer 1: deterministic checks — no model needed, fail fast
if not out.strip() or len(out.split()) > 120:
hard_fail.append((it["id"], "empty or overlong")); continue
v = judge(it["question"], it["context"], out) # layer 2: calibrated judge
results.append({"id": it["id"], "verdict": v.verdict, "quote": v.quote, "answer": out})
passed = sum(r["verdict"] == "pass" for r in results)
rate = passed / N
json.dump({"pass_rate": rate, "hard_fails": hard_fail, "results": results},
open("eval_report.json", "w"), indent=2)
print(f"faithfulness pass-rate: {rate:.0%} (threshold {THRESHOLD:.0%})")
for r in results:
if r["verdict"] == "fail":
print(f" FAIL {r['id']}: {r['answer'][:90]}")
sys.exit(0 if rate >= THRESHOLD and not hard_fail else 1)
The exit code is the whole mechanism — CI turns red on non-zero.
The workflow. Create .github/workflows/eval.yml:
name: eval-gate
on:
pull_request:
paths: ["prompt.txt", "app.py", "judge.py", "golden_v1.jsonl"]
jobs:
evals:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install -r requirements.txt
- run: python eval_gate.py
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
- uses: actions/upload-artifact@v4
if: always()
with: { name: eval-report, path: eval_report.json }
The paths filter means only prompt/model-touching PRs pay the eval cost; if: always() uploads the per-item report even on failure so reviewers can see which items regressed, not just that a number dropped.
Baseline: prove it green. Run python eval_gate.py locally — expect a pass-rate well above 0.85. Commit everything, push to main, then open a trivial PR (edit a comment in app.py) and confirm the workflow runs green. In branch protection, mark eval-gate as a required status check — now red genuinely blocks the merge button.
The deliberately broken change (must fail red). Branch helpful-tone and edit prompt.txt — a change a well-meaning teammate would absolutely propose:
You are the support bot for Orbitly. Be maximally helpful and confident. Use the
policy context when relevant, and when it doesn't cover the question, answer from
your general knowledge of how SaaS billing and plans typically work. Never tell
the customer you don't have the information. Keep answers under 80 words.
Open the PR. The gate runs the bot with the new prompt, the judge fails the now-ungrounded answers, and the check goes red, blocking merge. Save a screenshot and download the eval-report artifact — the per-item failures are your review comment, written by the harness.
Red to green. Restore the grounding sentence (keep any genuinely harmless tone edits), push to the same branch, and watch the check go green. Merge. You have now demonstrated the full loop: regression proposed → caught mechanically → fixed → merged with evidence.
Optional — promptfoo variant. If your team prefers config over code, the same gate is expressible as a promptfooconfig.yaml (providers, tests from the golden set, llm-rubric asserts) with npx promptfoo eval --fail-on-error in the workflow. Keep the calibrated-judge version as the reference implementation — you know its kappa; promptfoo's default rubric grader you have not calibrated.
Thresholds come from measurement, not aspiration — run the gate five times on main, note the worst pass-rate, and set the threshold just below it so sampling noise doesn't produce flaky reds that teach the team to ignore the gate.
eval-gate check failing (red) — this is mandatory evidence, not optional.eval_report.json is downloadable from both runs and names the failing items.eval-gate as required, so red actually blocks the merge button.python eval_gate.py exit code is 0 on the good prompt and 1 on the broken one.The evals-ci repo plus a DEMO.md containing: links (or screenshots) to the red run and the green run on the same PR, the two eval_report.json files, and your threshold justification. Self-grade (pass at 4+):
paths filter only matches PRs touching listed files; your test PR must edit one of them.ANTHROPIC_API_KEY is set at the repo (not environment) level, or the env block references the right name.The helpful-tone prompt fails for exactly the reason the diff suggests: it licenses the model to answer uncovered questions from general knowledge and forbids admitting ignorance. On the golden set the visible signature is invented specifics — refund windows, SLA credits, and storage quotas that sound plausible and contradict the context ("30-day refunds", "unlimited storage"), plus confident answers on items whose context genuinely lacks the fact. Expect the pass-rate to drop from ~0.90+ to roughly 0.5–0.7, comfortably below the 0.85 gate. The teaching point: the diff looked like a tone change; only the eval knew it was a grounding regression.
Client code and traces must not transit public CI or public LLM endpoints without sign-off. Run the identical gate on the client's CI (self-hosted GitHub runner, GitLab CI, or CodeBuild inside their VPC), store the key in their secret manager, and point the SDK at their sanctioned endpoint (Bedrock/Azure) — the workflow file changes only in the runner label and env wiring. The golden set derived from client traces stays in their tenancy and is itself a deliverable; the gate script and threshold methodology are yours to reuse. Record in the engagement notes which endpoint CI uses, so a future contractor doesn't "fix" it back to the public API.