HinterBuild logoHinterBuild
AI Systems · 13 min read

AI Evals in CI/CD with GitHub Actions: Integration Guide

Run AI evals in CI/CD with GitHub Actions: pytest eval suites, quality gates, regression detection and cost controls that block bad prompts before deploy.

Muhammad Abdul Sami, author

Muhammad Abdul Sami

· 13 min read

  • Evaluation
  • CI/CD
  • LLM
  • Testing
  • DevOps

AI evals in CI/CD with GitHub Actions turn prompt changes, model swaps, and retrieval tweaks into pull requests that either pass a measurable quality bar or don't merge. This guide covers the full pipeline: a pytest-based eval suite, the GitHub Actions workflows that run it on PRs and nightly, quality gates and regression detection against a stored baseline, and the cost controls that keep an LLM-backed test suite affordable.

Table of Contents:

Why AI Evals in CI/CD: The Quality Problem

Short answer: Manual AI testing doesn't scale. Automated evals in CI/CD catch quality regressions before deployment, enabling confident iteration on prompts, models, and retrieval systems.

A SaaS company deployed a "minor" prompt update that decreased accuracy from 87% to 64% in production. Users noticed before they did. After we implemented AI evals in CI/CD, they caught 23 breaking changes in 6 months—all before production. Zero quality incidents since.

Key Takeaways:

  • Automated evals run on every pull request, catching regressions early
  • Quality gates block deploys below accuracy thresholds
  • Regression detection compares metrics against baseline
  • Cost controls prevent expensive test runs from spiraling
  • GitHub Actions provides free CI minutes for open source
  • Full pipeline runs in 3-8 minutes for typical eval suites when PRs use a sampled dataset

For production AI systems, CI/CD evals are as essential as unit tests. The difference from unit tests is that the thing under test is non-deterministic, slow, and costs money per call, so the pipeline design has to account for all three.

Why LLM Changes Need Automated Evals

A prompt edit is a code change with no type checker, no compiler, and no obvious unit test. The same is true of a model version bump (gpt-4o-2024-08-06 to a newer snapshot), an embedding model change, or a chunking tweak in a RAG pipeline. Each one can shift output quality by double digits with a one-line diff. The prompt regression patterns we see most often are exactly this: a reasonable-looking edit that silently breaks a category of inputs nobody tested by hand.

What CI evals give you that manual spot-checks don't:

  • Coverage — every PR runs against hundreds of curated examples, not the five the author tried
  • A baseline — you know the number before the change, so you can measure the delta
  • A paper trail — the run log shows what was tested, at what threshold, and who overrode it
  • Speed of iteration — engineers can try aggressive prompt changes because the pipeline will tell them if something broke

What to Run Where: PR vs Main vs Nightly

The single most important design decision is which evals run at which stage. Running the full suite on every PR is too slow and too expensive; running nothing until nightly means regressions land on main first. The split we use:

StageTriggerDatasetJudge modelTypical timeTypical costGate
PR evalpull_request on src/, prompts/, evals/20% sample (min 50 examples)Small model (e.g. gpt-4o-mini, Claude Haiku)3-8 min~$0.50-1.50Blocking on accuracy, hallucination, safety
Merge evalpush to mainFull datasetSame as PR10-20 min~$2-4Blocking; updates baseline on pass
Nightlyschedule (cron)Full dataset + edge cases + consistency samplingStronger model for judge tasks15-45 min~$3-8Alerting only (Slack), no block
Pre-releaseManual workflow_dispatchFull dataset + staging smoke testsProduction judge20-60 min~$5-10Blocking on deploy

Costs are illustrative for a few hundred examples at ~1k tokens each; your numbers scale linearly with dataset size and token count. The point is the shape: cheap and fast where feedback loops are tight, thorough where it matters.


Pipeline Architecture: The Full Stack

AI evaluation CI/CD pipeline integrates with standard development workflows.

python
"""
.
├── .github/
│   └── workflows/
│       ├── eval-on-pr.yml          # Run on PRs
│       ├── eval-nightly.yml        # Comprehensive nightly
│       └── deploy-production.yml   # Deploy with gates
├── evals/
│   ├── __init__.py
│   ├── test_accuracy.py            # Accuracy evals
│   ├── test_hallucination.py       # Hallucination detection
│   ├── test_performance.py         # Latency benchmarks
│   └── datasets/
│       ├── accuracy_v1.jsonl       # Versioned eval data
│       └── edge_cases_v1.jsonl
├── src/
│   └── ai_system.py                # System under test
├── requirements.txt
└── pyproject.toml
"""

# evals/test_accuracy.py
import pytest
import json
from pathlib import Path
from typing import List, Dict, Any
from openai import AsyncOpenAI

client = AsyncOpenAI()

@pytest.fixture
def eval_dataset() -> List[Dict[str, Any]]:
    """Load evaluation dataset."""
    dataset_path = Path(__file__).parent / "datasets" / "accuracy_v1.jsonl"
    
    examples = []
    with dataset_path.open("r") as f:
        for line in f:
            examples.append(json.loads(line))
    
    return examples

@pytest.fixture
def baseline_metrics() -> Dict[str, float]:
    """Load baseline metrics from previous run."""
    baseline_path = Path(__file__).parent / "baselines" / "accuracy_baseline.json"
    
    if not baseline_path.exists():
        return {}
    
    with baseline_path.open("r") as f:
        return json.load(f)

@pytest.mark.asyncio
async def test_accuracy_threshold(eval_dataset, baseline_metrics):
    """Test accuracy meets or exceeds threshold."""
    from src.ai_system import AISystem
    
    system = AISystem()
    
    correct = 0
    total = len(eval_dataset)
    
    for example in eval_dataset:
        response = await system.query(example["input"])
        
        # Check if response matches expected output
        if _is_correct(response, example["expected_output"]):
            correct += 1
    
    accuracy = correct / total
    
    # Assert meets minimum threshold
    MIN_ACCURACY = 0.85
    assert accuracy >= MIN_ACCURACY, f"Accuracy {accuracy:.2%} below threshold {MIN_ACCURACY:.2%}"
    
    # Check for regression
    if baseline_metrics.get("accuracy"):
        baseline_accuracy = baseline_metrics["accuracy"]
        MAX_REGRESSION = 0.03  # 3% max drop
        
        assert accuracy >= baseline_accuracy - MAX_REGRESSION, (
            f"Accuracy regressed by {baseline_accuracy - accuracy:.2%} "
            f"(baseline: {baseline_accuracy:.2%}, current: {accuracy:.2%})"
        )
    
    print(f"✓ Accuracy: {accuracy:.2%} ({correct}/{total})")

def _is_correct(response: str, expected: str) -> bool:
    """Check if response is correct."""
    # Implement domain-specific correctness check
    return response.strip().lower() == expected.strip().lower()

@pytest.mark.asyncio
async def test_hallucination_rate(eval_dataset):
    """Test hallucination rate below threshold."""
    from src.ai_system import AISystem
    
    system = AISystem()
    
    hallucinations = 0
    total = len(eval_dataset)
    
    for example in eval_dataset:
        if "context" not in example:
            continue
        
        response = await system.query(
            example["input"],
            context=example["context"],
        )
        
        # Check if response is grounded in context
        is_grounded = await _check_grounding(response, example["context"])
        
        if not is_grounded:
            hallucinations += 1
    
    hallucination_rate = hallucinations / total
    
    MAX_HALLUCINATION_RATE = 0.02  # 2% max
    assert hallucination_rate <= MAX_HALLUCINATION_RATE, (
        f"Hallucination rate {hallucination_rate:.2%} exceeds threshold {MAX_HALLUCINATION_RATE:.2%}"
    )
    
    print(f"✓ Hallucination rate: {hallucination_rate:.2%} ({hallucinations}/{total})")

async def _check_grounding(response: str, context: str) -> bool:
    """Check if response is grounded in context."""
    prompt = f"""Is this response grounded in the context?

Response: {response}
Context: {context}

Return JSON: {{"grounded": true/false}}"""
    
    result = await client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        response_format={"type": "json_object"},
    )
    
    return json.loads(result.choices[0].message.content)["grounded"]

@pytest.mark.asyncio
async def test_latency_p95(eval_dataset):
    """Test P95 latency below threshold."""
    from src.ai_system import AISystem
    import time
    
    system = AISystem()
    
    latencies = []
    
    for example in eval_dataset[:50]:  # Sample for speed
        start = time.perf_counter()
        await system.query(example["input"])
        latency_ms = (time.perf_counter() - start) * 1000
        
        latencies.append(latency_ms)
    
    import numpy as np
    p95_latency = np.percentile(latencies, 95)
    
    MAX_P95_LATENCY = 2000  # 2 seconds
    assert p95_latency <= MAX_P95_LATENCY, (
        f"P95 latency {p95_latency:.0f}ms exceeds threshold {MAX_P95_LATENCY}ms"
    )
    
    print(f"✓ P95 latency: {p95_latency:.0f}ms")

Pytest integration enables standard test runners and reporting. Everything here is plain pytest with pytest-asyncio, which means junit XML output, HTML reports, -k filtering, and markers all work out of the box. You don't need an eval-specific framework to get started, though DeepEval and promptfoo both plug into this same structure if you want their built-in metrics.

Three details in the code above matter more than they look:

  • Datasets are versioned files (accuracy_v1.jsonl), not database queries. The eval run must be reproducible from the git SHA plus a dataset version.
  • The threshold and the regression check are separate assertions. An absolute floor (85%) catches a bad system; a relative check (no more than 3 points below baseline) catches a bad change to a good system.
  • The grounding check uses a judge model. LLM-as-judge is the only scalable way to score open-ended outputs, but judges have their own variance and bias (Zheng et al. measured position and verbosity bias in Judging LLM-as-a-Judge). Pin the judge model version, use temperature 0, and re-validate the judge against human labels when you change it.

Connect to evaluation frameworks for comprehensive coverage, and see our LLM-as-judge implementation guide for judge prompt design.


GitHub Actions Setup

Configure GitHub Actions to run evals on every PR.

yaml
# .github/workflows/eval-on-pr.yml
name: AI Evaluation on PR

on:
  pull_request:
    branches: [main, develop]
    paths:
      - 'src/**'
      - 'prompts/**'
      - 'evals/**'

env:
  OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
  EVAL_DATASET_VERSION: v1.0.0

jobs:
  run-evals:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'
          cache: 'pip'
      
      - name: Install dependencies
        run: |
          pip install -r requirements.txt
          pip install pytest pytest-asyncio pytest-html
      
      - name: Download eval datasets
        run: |
          # Download versioned datasets from storage
          aws s3 cp s3://my-bucket/eval-datasets/$EVAL_DATASET_VERSION/ evals/datasets/ --recursive
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
      
      - name: Run accuracy tests
        id: accuracy
        run: |
          pytest evals/test_accuracy.py -v --html=report.html --self-contained-html
        continue-on-error: true
      
      - name: Run hallucination tests
        id: hallucination
        run: |
          pytest evals/test_hallucination.py -v
        continue-on-error: true
      
      - name: Run performance tests
        id: performance
        run: |
          pytest evals/test_performance.py -v
        continue-on-error: true
      
      - name: Generate summary
        if: always()
        run: |
          echo "## AI Evaluation Results" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          
          if [ "${{ steps.accuracy.outcome }}" == "success" ]; then
            echo "✅ Accuracy tests passed" >> $GITHUB_STEP_SUMMARY
          else
            echo "❌ Accuracy tests failed" >> $GITHUB_STEP_SUMMARY
          fi
          
          if [ "${{ steps.hallucination.outcome }}" == "success" ]; then
            echo "✅ Hallucination tests passed" >> $GITHUB_STEP_SUMMARY
          else
            echo "❌ Hallucination tests failed" >> $GITHUB_STEP_SUMMARY
          fi
          
          if [ "${{ steps.performance.outcome }}" == "success" ]; then
            echo "✅ Performance tests passed" >> $GITHUB_STEP_SUMMARY
          else
            echo "❌ Performance tests failed" >> $GITHUB_STEP_SUMMARY
          fi
      
      - name: Upload test report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: eval-report
          path: report.html
      
      - name: Comment on PR
        if: always() && github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            
            let comment = '## 🤖 AI Evaluation Results\n\n';
            comment += `**Accuracy**: ${{ steps.accuracy.outcome }}\n`;
            comment += `**Hallucination**: ${{ steps.hallucination.outcome }}\n`;
            comment += `**Performance**: ${{ steps.performance.outcome }}\n\n`;
            comment += '[View detailed report](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})\n';
            
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: comment
            });
      
      - name: Fail if any test failed
        if: steps.accuracy.outcome != 'success' || steps.hallucination.outcome != 'success' || steps.performance.outcome != 'success'
        run: exit 1

# .github/workflows/eval-nightly.yml
name: Nightly Comprehensive Evaluation

on:
  schedule:
    - cron: '0 2 * * *'  # 2 AM UTC daily
  workflow_dispatch:

env:
  OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

jobs:
  comprehensive-eval:
    runs-on: ubuntu-latest
    timeout-minutes: 60
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      
      - name: Install dependencies
        run: pip install -r requirements.txt
      
      - name: Run full eval suite
        run: |
          pytest evals/ -v --html=nightly-report.html --self-contained-html
      
      - name: Upload metrics to monitoring
        run: |
          python scripts/upload_metrics.py nightly-report.html
        env:
          DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
      
      - name: Alert on failure
        if: failure()
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "🚨 Nightly AI evals failed!",
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "Nightly evaluation suite failed. [View run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})"
                  }
                }
              ]
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

GitHub Actions provides:

  • Free CI minutes for public repos, and 2,000+ minutes/month on paid private plans (see GitHub Actions docs for current limits)
  • Matrix builds for multiple Python versions or multiple model versions
  • Artifact uploads for reports
  • PR comments for results via github-script

A few workflow choices worth calling out:

  • paths: filter. The PR workflow only fires when src/, prompts/, or evals/ change. A README edit should not cost $1 in API calls.
  • continue-on-error: true on each test step, then a final step that fails the job. This lets the summary and PR comment always render, so a failing accuracy test still shows you the hallucination and latency results.
  • timeout-minutes: 15. LLM API calls hang. Without a timeout a stuck run burns CI minutes for six hours.
  • Secrets, not env files. OPENAI_API_KEY and ANTHROPIC_API_KEY live in repository or environment secrets. Use a GitHub environment with required reviewers for the production deploy job so the key is only exposed to approved runs.
  • concurrency: groups. Add concurrency: { group: eval-${{ github.ref }}, cancel-in-progress: true } so pushing a fix to a PR cancels the now-stale eval run instead of paying for both.

For agent testing, add agent-specific eval workflows.


Automated Evaluation Tests

Comprehensive test suite covering accuracy, safety, and performance.

python
# evals/test_comprehensive.py
import pytest
from typing import List, Dict, Any
import asyncio

class ComprehensiveEvalSuite:
    """Comprehensive evaluation test suite."""
    
    def __init__(self, system):
        self.system = system
    
    async def run_all_evals(
        self,
        eval_dataset: List[Dict[str, Any]],
    ) -> Dict[str, Any]:
        """Run all evaluation tests."""
        results = await asyncio.gather(
            self.eval_accuracy(eval_dataset),
            self.eval_hallucination(eval_dataset),
            self.eval_safety(eval_dataset),
            self.eval_consistency(eval_dataset),
            self.eval_latency(eval_dataset),
        )
        
        return {
            "accuracy": results[0],
            "hallucination": results[1],
            "safety": results[2],
            "consistency": results[3],
            "latency": results[4],
        }
    
    async def eval_accuracy(
        self,
        dataset: List[Dict[str, Any]],
    ) -> Dict[str, float]:
        """Evaluate accuracy metrics."""
        correct = 0
        total = len(dataset)
        
        for example in dataset:
            response = await self.system.query(example["input"])
            
            if self._is_correct(response, example["expected_output"]):
                correct += 1
        
        accuracy = correct / total
        
        return {
            "accuracy": accuracy,
            "correct": correct,
            "total": total,
        }
    
    async def eval_hallucination(
        self,
        dataset: List[Dict[str, Any]],
    ) -> Dict[str, float]:
        """Evaluate hallucination rate."""
        from evals.hallucination_detector import HallucinationDetector
        
        detector = HallucinationDetector()
        hallucinations = 0
        total = 0
        
        for example in dataset:
            if "context" not in example:
                continue
            
            response = await self.system.query(
                example["input"],
                context=example["context"],
            )
            
            result = await detector.detect(
                query=example["input"],
                response=response,
                context=example["context"],
            )
            
            if result.is_hallucination:
                hallucinations += 1
            
            total += 1
        
        rate = hallucinations / total if total > 0 else 0
        
        return {
            "hallucination_rate": rate,
            "hallucinations": hallucinations,
            "total": total,
        }
    
    async def eval_safety(
        self,
        dataset: List[Dict[str, Any]],
    ) -> Dict[str, float]:
        """Evaluate safety metrics."""
        unsafe = 0
        total = len(dataset)
        
        for example in dataset:
            if "safety_test" not in example:
                continue
            
            response = await self.system.query(example["input"])
            
            # Check if response is safe
            is_safe = await self._check_safety(response)
            
            if not is_safe:
                unsafe += 1
        
        safety_rate = 1 - (unsafe / total) if total > 0 else 1.0
        
        return {
            "safety_rate": safety_rate,
            "unsafe_responses": unsafe,
            "total": total,
        }
    
    async def eval_consistency(
        self,
        dataset: List[Dict[str, Any]],
    ) -> Dict[str, float]:
        """Evaluate response consistency."""
        from evals.consistency_checker import ConsistencyChecker
        
        checker = ConsistencyChecker()
        consistency_scores = []
        
        for example in dataset[:50]:  # Sample for speed
            score = await checker.check_consistency(
                query=example["input"],
                system=self.system,
            )
            consistency_scores.append(score)
        
        avg_consistency = sum(consistency_scores) / len(consistency_scores)
        
        return {
            "average_consistency": avg_consistency,
            "num_samples": len(consistency_scores),
        }
    
    async def eval_latency(
        self,
        dataset: List[Dict[str, Any]],
    ) -> Dict[str, float]:
        """Evaluate latency metrics."""
        import time
        import numpy as np
        
        latencies = []
        
        for example in dataset[:100]:  # Sample for speed
            start = time.perf_counter()
            await self.system.query(example["input"])
            latency_ms = (time.perf_counter() - start) * 1000
            
            latencies.append(latency_ms)
        
        return {
            "p50_latency_ms": float(np.percentile(latencies, 50)),
            "p95_latency_ms": float(np.percentile(latencies, 95)),
            "p99_latency_ms": float(np.percentile(latencies, 99)),
            "avg_latency_ms": float(np.mean(latencies)),
        }
    
    def _is_correct(self, response: str, expected: str) -> bool:
        """Check correctness."""
        # Domain-specific logic
        return True
    
    async def _check_safety(self, response: str) -> bool:
        """Check safety."""
        # Implement safety checks
        return True

@pytest.mark.asyncio
async def test_comprehensive_eval_suite():
    """Run comprehensive eval suite with assertions."""
    from src.ai_system import AISystem
    import json
    
    system = AISystem()
    suite = ComprehensiveEvalSuite(system)
    
    # Load dataset
    dataset_path = Path(__file__).parent / "datasets" / "accuracy_v1.jsonl"
    dataset = []
    with dataset_path.open("r") as f:
        for line in f:
            dataset.append(json.loads(line))
    
    # Run all evals
    results = await suite.run_all_evals(dataset)
    
    # Assert thresholds
    assert results["accuracy"]["accuracy"] >= 0.85, "Accuracy below 85%"
    assert results["hallucination"]["hallucination_rate"] <= 0.02, "Hallucination rate above 2%"
    assert results["safety"]["safety_rate"] >= 0.98, "Safety rate below 98%"
    assert results["consistency"]["average_consistency"] >= 0.80, "Consistency below 80%"
    assert results["latency"]["p95_latency_ms"] <= 2000, "P95 latency above 2s"
    
    print("\n✓ All eval metrics passed:")
    print(f"  Accuracy: {results['accuracy']['accuracy']:.2%}")
    print(f"  Hallucination: {results['hallucination']['hallucination_rate']:.2%}")
    print(f"  Safety: {results['safety']['safety_rate']:.2%}")
    print(f"  Consistency: {results['consistency']['average_consistency']:.2%}")
    print(f"  P95 Latency: {results['latency']['p95_latency_ms']:.0f}ms")

Comprehensive coverage prevents quality regressions. Each metric answers a different question, and they fail independently: a change can raise accuracy while doubling hallucination rate, or cut latency while breaking consistency.

MetricWhat it catchesHow it's scoredRecommended gate
AccuracyWrong answers on known-good inputsExact match, normalized match, or judge-scored correctnessBlocking, absolute floor + relative regression
Hallucination rateClaims not supported by provided contextJudge model grounding check per responseBlocking, absolute ceiling (1-3%)
Safety ratePolicy violations, unsafe completions on red-team inputsClassifier or judge modelBlocking, high floor (98%+)
ConsistencySame question, different answers across runsPairwise similarity across N samplesWarning on PR, blocking nightly
P95 latencySlow prompts, oversized context, retry stormsWall-clock timing over a fixed sampleWarning on PR, blocking on merge
Cost per requestToken bloat from prompt or retrieval changesToken usage × current pricingWarning, with a hard ceiling

For evaluation metrics, implement RAGAS-style scoring via the RAGAS library for RAG-specific faithfulness and answer relevancy.


Quality Gates and Thresholds

Automated gates block low-quality deployments.

python
# scripts/quality_gate.py
from typing import Dict, Any, List
from dataclasses import dataclass

@dataclass
class QualityThreshold:
    """Quality threshold definition."""
    metric: str
    operator: str  # >=, <=, ==
    threshold: float
    severity: str  # blocking, warning

class QualityGate:
    """Quality gate with configurable thresholds."""
    
    def __init__(self, thresholds: List[QualityThreshold]):
        self.thresholds = thresholds
    
    def evaluate(
        self,
        metrics: Dict[str, float],
    ) -> Dict[str, Any]:
        """Evaluate metrics against thresholds."""
        violations = []
        warnings = []
        
        for threshold in self.thresholds:
            metric_value = metrics.get(threshold.metric)
            
            if metric_value is None:
                continue
            
            # Check threshold
            passed = self._check_threshold(
                metric_value,
                threshold.operator,
                threshold.threshold,
            )
            
            if not passed:
                violation = {
                    "metric": threshold.metric,
                    "value": metric_value,
                    "threshold": threshold.threshold,
                    "operator": threshold.operator,
                }
                
                if threshold.severity == "blocking":
                    violations.append(violation)
                else:
                    warnings.append(violation)
        
        passed = len(violations) == 0
        
        return {
            "passed": passed,
            "violations": violations,
            "warnings": warnings,
        }
    
    def _check_threshold(
        self,
        value: float,
        operator: str,
        threshold: float,
    ) -> bool:
        """Check if value meets threshold."""
        if operator == ">=":
            return value >= threshold
        elif operator == "<=":
            return value <= threshold
        elif operator == "==":
            return abs(value - threshold) < 0.001
        else:
            raise ValueError(f"Unknown operator: {operator}")

# Usage in CI
if __name__ == "__main__":
    import sys
    import json
    
    # Load eval results
    with open("eval_results.json", "r") as f:
        metrics = json.load(f)
    
    # Define thresholds
    gate = QualityGate(thresholds=[
        QualityThreshold("accuracy", ">=", 0.85, "blocking"),
        QualityThreshold("hallucination_rate", "<=", 0.02, "blocking"),
        QualityThreshold("safety_rate", ">=", 0.98, "blocking"),
        QualityThreshold("p95_latency_ms", "<=", 2000, "warning"),
        QualityThreshold("average_consistency", ">=", 0.80, "warning"),
    ])
    
    # Evaluate
    result = gate.evaluate(metrics)
    
    if not result["passed"]:
        print("❌ Quality gate FAILED")
        print("\nBlocking violations:")
        for v in result["violations"]:
            print(f"  {v['metric']}: {v['value']:.4f} {v['operator']} {v['threshold']}")
        
        sys.exit(1)
    
    if result["warnings"]:
        print("⚠️  Quality gate PASSED with warnings")
        print("\nWarnings:")
        for w in result["warnings"]:
            print(f"  {w['metric']}: {w['value']:.4f} {w['operator']} {w['threshold']}")
    else:
        print("✅ Quality gate PASSED")
    
    sys.exit(0)

Integrate into GitHub Actions:

yaml
- name: Check quality gate
  run: |
    python scripts/quality_gate.py

Setting thresholds is where most teams get stuck. The practical approach:

  1. Run the eval suite against your current production system 5-10 times and record the distribution of each metric.
  2. Set the absolute floor 2-3 points below the observed mean (not the max). A gate set at the best run you've ever seen will block every PR.
  3. Set the regression tolerance from the observed run-to-run variance. If accuracy swings ±2% between identical runs, a 1% regression tolerance is noise, not a gate.
  4. Start with warning severity on new metrics and promote to blocking after two weeks of data.

For deployment gates, integrate with CD pipelines.


Regression Detection

Detect regressions by comparing against baseline.

python
# scripts/regression_detection.py
from typing import Dict, Any
import json
from pathlib import Path

class RegressionDetector:
    """Detect metric regressions against baseline."""
    
    def __init__(self, baseline_path: Path):
        self.baseline_path = baseline_path
        self.baseline_metrics = self._load_baseline()
    
    def _load_baseline(self) -> Dict[str, float]:
        """Load baseline metrics."""
        if not self.baseline_path.exists():
            return {}
        
        with self.baseline_path.open("r") as f:
            return json.load(f)
    
    def detect_regressions(
        self,
        current_metrics: Dict[str, float],
        max_regression: float = 0.03,  # 3% max drop
    ) -> Dict[str, Any]:
        """Detect regressions in metrics."""
        regressions = []
        improvements = []
        
        for metric, current_value in current_metrics.items():
            if metric not in self.baseline_metrics:
                continue
            
            baseline_value = self.baseline_metrics[metric]
            delta = current_value - baseline_value
            delta_pct = delta / baseline_value if baseline_value != 0 else 0
            
            # Determine if metric is "higher is better" or "lower is better"
            if metric in ["accuracy", "safety_rate", "consistency"]:
                # Higher is better
                if delta_pct < -max_regression:
                    regressions.append({
                        "metric": metric,
                        "baseline": baseline_value,
                        "current": current_value,
                        "delta": delta,
                        "delta_pct": delta_pct,
                    })
                elif delta_pct > 0.01:  # 1% improvement
                    improvements.append({
                        "metric": metric,
                        "baseline": baseline_value,
                        "current": current_value,
                        "delta": delta,
                        "delta_pct": delta_pct,
                    })
            
            elif metric in ["hallucination_rate", "p95_latency_ms"]:
                # Lower is better
                if delta_pct > max_regression:
                    regressions.append({
                        "metric": metric,
                        "baseline": baseline_value,
                        "current": current_value,
                        "delta": delta,
                        "delta_pct": delta_pct,
                    })
                elif delta_pct < -0.01:  # 1% improvement
                    improvements.append({
                        "metric": metric,
                        "baseline": baseline_value,
                        "current": current_value,
                        "delta": delta,
                        "delta_pct": delta_pct,
                    })
        
        return {
            "has_regressions": len(regressions) > 0,
            "regressions": regressions,
            "improvements": improvements,
        }
    
    def update_baseline(
        self,
        new_metrics: Dict[str, float],
    ) -> None:
        """Update baseline with new metrics."""
        with self.baseline_path.open("w") as f:
            json.dump(new_metrics, f, indent=2)
        
        print(f"✓ Updated baseline: {self.baseline_path}")

# Usage
if __name__ == "__main__":
    import sys
    
    detector = RegressionDetector(
        baseline_path=Path("evals/baselines/production_baseline.json")
    )
    
    # Load current metrics
    with open("eval_results.json", "r") as f:
        current_metrics = json.load(f)
    
    # Detect regressions
    result = detector.detect_regressions(current_metrics)
    
    if result["has_regressions"]:
        print("❌ REGRESSIONS DETECTED")
        print("\nRegressions:")
        for r in result["regressions"]:
            print(f"  {r['metric']}: {r['baseline']:.4f} → {r['current']:.4f} ({r['delta_pct']:+.1%})")
        
        sys.exit(1)
    
    if result["improvements"]:
        print("✅ No regressions (improvements detected)")
        print("\nImprovements:")
        for i in result["improvements"]:
            print(f"  {i['metric']}: {i['baseline']:.4f} → {i['current']:.4f} ({i['delta_pct']:+.1%})")
    else:
        print("✅ No regressions")
    
    # Update baseline on main branch
    if sys.argv[1] == "--update-baseline":
        detector.update_baseline(current_metrics)

Regression detection prevents quality degradation. The baseline lives in the repo (evals/baselines/production_baseline.json) and is committed by the deploy workflow on every successful production release, so the baseline always reflects what's actually serving traffic.

Handling non-determinism. A 3% regression threshold on a 100-example dataset means three examples flipping from correct to incorrect. With temperature > 0 that can happen between two identical runs. Mitigations, in order of effectiveness:

  • Temperature 0 and pinned model snapshots for everything under test and for the judge. Both the OpenAI API and the Anthropic API let you pin dated model versions; never eval against a floating alias.
  • Larger datasets. Variance shrinks with sample size. 500 examples is a reasonable floor for a blocking gate.
  • Run twice on failure. A regression that reproduces on a second run is real. pytest-rerunfailures handles this, but log both results so flakiness is visible.
  • Per-category thresholds. If your dataset has 20 categories, a 3% global regression can hide a 40% drop in one category. Track the breakdown.

For prompt versioning, track per-version baselines so you can compare any two prompt versions, not only current-vs-previous.


Performance Benchmarking

Benchmark latency and throughput in CI.

python
# evals/test_performance.py
import pytest
import time
import asyncio
from typing import List
import numpy as np

@pytest.mark.asyncio
async def test_latency_benchmarks():
    """Benchmark latency metrics."""
    from src.ai_system import AISystem
    
    system = AISystem()
    
    # Warm up
    await system.query("Hello")
    
    # Benchmark
    latencies = []
    num_requests = 100
    
    for i in range(num_requests):
        start = time.perf_counter()
        await system.query(f"Test query {i}")
        latency_ms = (time.perf_counter() - start) * 1000
        latencies.append(latency_ms)
    
    # Calculate percentiles
    p50 = np.percentile(latencies, 50)
    p95 = np.percentile(latencies, 95)
    p99 = np.percentile(latencies, 99)
    avg = np.mean(latencies)
    
    print(f"\nLatency benchmarks ({num_requests} requests):")
    print(f"  P50: {p50:.0f}ms")
    print(f"  P95: {p95:.0f}ms")
    print(f"  P99: {p99:.0f}ms")
    print(f"  Avg: {avg:.0f}ms")
    
    # Assert thresholds
    assert p50 <= 1000, f"P50 latency {p50:.0f}ms exceeds 1s"
    assert p95 <= 2000, f"P95 latency {p95:.0f}ms exceeds 2s"
    assert p99 <= 3000, f"P99 latency {p99:.0f}ms exceeds 3s"

@pytest.mark.asyncio
async def test_throughput_benchmark():
    """Benchmark throughput."""
    from src.ai_system import AISystem
    
    system = AISystem()
    
    num_requests = 50
    concurrency = 10
    
    async def worker(request_id: int):
        await system.query(f"Request {request_id}")
    
    start = time.perf_counter()
    
    # Run concurrent requests
    tasks = [worker(i) for i in range(num_requests)]
    await asyncio.gather(*tasks)
    
    duration = time.perf_counter() - start
    throughput = num_requests / duration
    
    print(f"\nThroughput benchmark:")
    print(f"  Requests: {num_requests}")
    print(f"  Duration: {duration:.2f}s")
    print(f"  Throughput: {throughput:.1f} req/s")
    
    # Assert minimum throughput
    MIN_THROUGHPUT = 5  # req/s
    assert throughput >= MIN_THROUGHPUT, (
        f"Throughput {throughput:.1f} req/s below minimum {MIN_THROUGHPUT} req/s"
    )

@pytest.mark.asyncio
async def test_cost_estimation():
    """Estimate cost per request."""
    from src.ai_system import AISystem
    
    system = AISystem()
    
    # Track token usage
    total_input_tokens = 0
    total_output_tokens = 0
    num_requests = 20
    
    for i in range(num_requests):
        response = await system.query(f"Test query {i}")
        
        # Extract token usage (implementation-specific)
        if hasattr(response, "usage"):
            total_input_tokens += response.usage.prompt_tokens
            total_output_tokens += response.usage.completion_tokens
    
    # Calculate cost (example pricing)
    INPUT_COST_PER_1M = 2.50  # $2.50 per 1M input tokens
    OUTPUT_COST_PER_1M = 10.00  # $10 per 1M output tokens
    
    avg_input_tokens = total_input_tokens / num_requests
    avg_output_tokens = total_output_tokens / num_requests
    
    cost_per_request = (
        (avg_input_tokens / 1_000_000) * INPUT_COST_PER_1M +
        (avg_output_tokens / 1_000_000) * OUTPUT_COST_PER_1M
    )
    
    print(f"\nCost estimation:")
    print(f"  Avg input tokens: {avg_input_tokens:.0f}")
    print(f"  Avg output tokens: {avg_output_tokens:.0f}")
    print(f"  Cost per request: ${cost_per_request:.4f}")
    
    # Assert cost threshold
    MAX_COST_PER_REQUEST = 0.05  # $0.05
    assert cost_per_request <= MAX_COST_PER_REQUEST, (
        f"Cost per request ${cost_per_request:.4f} exceeds ${MAX_COST_PER_REQUEST}"
    )

Performance benchmarks prevent latency regressions.

For cost optimization, track cost metrics in CI.


Cost Management

Control eval costs with sampling and caching.

python
# evals/cost_manager.py
from typing import Dict, Any, Optional
import os

class EvalCostManager:
    """Manage evaluation costs."""
    
    def __init__(self, max_cost_usd: float = 10.0):
        self.max_cost_usd = max_cost_usd
        self.current_cost = 0.0
    
    def sample_dataset(
        self,
        dataset: list,
        is_pr: bool = True,
    ) -> list:
        """Sample dataset based on context."""
        if is_pr:
            # Quick eval on PRs—sample 20%
            sample_size = max(int(len(dataset) * 0.2), 50)
            return dataset[:sample_size]
        else:
            # Full eval on main/nightly
            return dataset
    
    def should_run_expensive_eval(self) -> bool:
        """Check if expensive eval should run."""
        # Only run expensive evals on main branch or nightly
        is_main = os.getenv("GITHUB_REF") == "refs/heads/main"
        is_nightly = os.getenv("GITHUB_EVENT_NAME") == "schedule"
        
        return is_main or is_nightly
    
    def estimate_cost(
        self,
        num_requests: int,
        avg_tokens: int = 1000,
    ) -> float:
        """Estimate eval cost."""
        COST_PER_1K_TOKENS = 0.01  # Approximate
        
        return (num_requests * avg_tokens * COST_PER_1K_TOKENS) / 1000

# Usage in tests
def pytest_configure(config):
    """Pytest hook to configure eval runs."""
    cost_manager = EvalCostManager(max_cost_usd=10.0)
    
    # Store in config for tests to access
    config.cost_manager = cost_manager

def pytest_collection_modifyitems(config, items):
    """Pytest hook to skip expensive tests on PRs."""
    cost_manager = config.cost_manager
    
    if not cost_manager.should_run_expensive_eval():
        skip_expensive = pytest.mark.skip(reason="Expensive eval—skipping on PR")
        
        for item in items:
            if "expensive" in item.keywords:
                item.add_marker(skip_expensive)

Cost controls prevent expensive test runs. Beyond sampling, the biggest levers are:

  • Response caching keyed on (model, prompt, temperature, input). If the system prompt didn't change and the input is identical, the response for a temperature-0 call is identical. A file-based cache committed as a CI artifact can skip 60-90% of calls on PRs that only touch one component.
  • Small judge models. The judge doesn't need to be the strongest model available. Validate a small judge against human labels once, then use it everywhere except nightly.
  • Batch APIs for nightly runs. Both OpenAI and Anthropic offer batch endpoints at roughly 50% of on-demand pricing with a 24-hour SLA, which is fine for a scheduled job.
  • A hard budget cap. EvalCostManager above tracks spend; wire it to pytest.exit() when the cap is hit so a runaway test loop can't spend $200 overnight.

For batch processing, use Batch API in nightly evals.


Production Deployment

Deploy with confidence using eval gates.

yaml
# .github/workflows/deploy-production.yml
name: Deploy to Production

on:
  push:
    branches: [main]

jobs:
  eval-and-deploy:
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      
      - name: Run full eval suite
        run: |
          pip install -r requirements.txt
          pytest evals/ -v --json-report --json-report-file=eval_results.json
      
      - name: Check quality gate
        run: |
          python scripts/quality_gate.py
      
      - name: Detect regressions
        run: |
          python scripts/regression_detection.py
      
      - name: Deploy to staging
        run: |
          # Deploy to staging environment
          aws ecs update-service --cluster staging --service ai-system --force-new-deployment
      
      - name: Run smoke tests on staging
        run: |
          pytest evals/test_smoke.py --env=staging
      
      - name: Deploy to production
        if: success()
        run: |
          # Deploy to production
          aws ecs update-service --cluster production --service ai-system --force-new-deployment
      
      - name: Update baseline
        if: success()
        run: |
          python scripts/regression_detection.py --update-baseline
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add evals/baselines/
          git commit -m "Update eval baselines [skip ci]"
          git push

Safe deployment with staged rollout and smoke tests. The sequence is: full evals → quality gate → regression check → staging deploy → smoke tests → production deploy → baseline update. Every step is a separate job step so a failure is attributable, and the baseline only moves when the whole chain succeeds.

Pair this with shadow-mode deployment for changes where offline evals don't fully capture production traffic, and with prompt regression monitoring in production so drift that CI can't see still gets caught.

For cloud infrastructure, integrate with deployment pipelines.


Failure Modes and Fixes

These are the ways AI eval pipelines in CI/CD break in practice, and what fixes them.

The eval dataset stops representing production. Six months in, the dataset reflects the product as it was at launch. Fix: sample production traffic weekly (anonymized, PII-scrubbed), label a slice, and add it as accuracy_v2.jsonl. Keep old versions so baselines stay comparable.

Gates are tuned so tight nothing merges. Engineers start using --no-verify equivalents or bypass the check. Fix: derive thresholds from measured variance (above), and make override a visible, logged action that requires a reviewer.

The judge drifts. A judge model alias updates and now scores 4% differently. Every PR looks like a regression. Fix: pin the judge snapshot and store the judge version in the baseline file so mismatches fail loudly.

Evals pass but the agent still fails in production. Single-turn evals can't catch multi-step tool-calling failures. Fix: add trajectory-level tests, see how to test AI agents for scenario and tool-mock patterns.

CI runs cost more than the feature. Usually caused by running full evals on every push. Fix: paths: filters, sampling on PRs, concurrency cancellation, and caching.


Frequently Asked Questions

How do you run AI evals in a CI/CD pipeline?

Run them as a pytest suite triggered by GitHub Actions on pull requests, with a sampled dataset for PRs and the full dataset on merge and nightly. Each test asserts an absolute threshold and a maximum regression against a committed baseline, and a final quality-gate step fails the job if any blocking metric misses. The workflow files in this guide are a complete starting point.

How long do AI eval runs take in CI?

Typically 3-8 minutes for PR evals on a sampled dataset and 15-45 minutes for comprehensive nightly runs. The bottleneck is LLM API latency, not compute, so run examples concurrently with asyncio.gather and a semaphore sized to your rate limit. Use timeout-minutes so a hung API call can't block the pipeline indefinitely.

What does an eval run cost?

Roughly $0.50-1.50 per PR eval and $3-8 per nightly run for a few hundred examples at ~1k tokens each, using a small judge model. Costs scale linearly with dataset size, token count, and the model you evaluate against. Caching temperature-0 responses, sampling on PRs, and using batch APIs for nightly runs are the main cost reducers.

Should I run evals on every commit?

Run on every pull request, not every commit. Trigger on pull_request with a paths: filter so only changes to prompts, source, or eval data start a run, and add a concurrency group with cancel-in-progress so a new push cancels the stale run. Full-dataset evals belong on merge to main and on a nightly schedule.

How do I handle flaky eval tests?

Pin model snapshots, set temperature to 0, and derive your regression tolerance from measured run-to-run variance before you call anything flaky. If real variance remains, increase the dataset size and use pytest-rerunfailures to retry, but log both results so persistent flakiness is visible. A test that fails twice in a row is a regression, not flake.

Can I run evals locally?

Yes, pytest evals/ runs the same suite locally against the same versioned dataset. You will need the API keys in your environment and the baseline file checked out. CI adds the consistent environment, the PR comment, and the baseline update, but nothing in the suite depends on GitHub Actions.

What if evals block a critical hotfix?

Use a GitHub environment with required reviewers so a named approver can bypass the gate, and require the PR description to state why. Merge the hotfix, then open a follow-up PR that either fixes the regression or updates the dataset if the eval was wrong. Silent bypasses are how eval pipelines die.

Which metrics should be blocking versus warning?

Accuracy, hallucination rate, and safety rate should block, because a miss there ships a broken product. Latency, cost, and consistency start as warnings and become blocking once you have two weeks of baseline data showing they are stable. Never make a metric blocking before you know its normal variance.


Conclusion

AI evals in CI/CD enable confident iteration:

  • Automated testing catches regressions before production
  • Quality gates block low-quality deployments
  • Regression detection compares against baseline metrics
  • Performance benchmarking prevents latency degradation
  • Cost controls prevent expensive test runs
  • GitHub Actions integration provides free CI infrastructure

Evaluation in CI/CD is as essential as unit tests for AI systems. Start with one blocking metric on PRs, add the baseline file, and expand from there.

If you want help designing eval datasets, thresholds, and the pipeline around them, talk to our team about AI agent development and evaluation infrastructure.

Free consultation

Book a free consultation call on AI testing in CI/CD pipelines

30-minute call with the HinterBuild team. Discuss your project, architecture questions, or next steps — no obligation.

Book a meeting

Keep reading