方寸 Portal

AI Engineer Portal

Your personal operating system for career transition.

Private mode
Back to Learning Paths
Lesson

CI/CD pipelines with eval gates

Add eval gates to your GitHub Actions pipeline so that prompt or model changes that degrade quality are caught before deployment.

45 min
ai-deployment-and-mlopsphase-1portfolio

Why this matters

Unit tests tell you your code runs. Eval gates tell you your AI system still behaves correctly. These are different things. A prompt change that passes all unit tests can silently halve your system's quality score. CI/CD with eval gates is the engineering practice that makes AI systems deployable with confidence rather than with hope.

Core concepts

Why unit tests are not enough for AI systems

Unit tests verify deterministic behavior: given input X, function returns Y. AI systems are not deterministic in that way. The behavior under test is quality: does the output satisfy the rubric? Does the classification accuracy stay above 85%? Does the retrieved context remain relevant?

Eval gates are a threshold-based quality check wired into the deployment gate. If quality drops below the threshold, the deployment is blocked.

The AI CI/CD pipeline shape

push / PR
  |
  v
lint (ruff, mypy)
  |
  v
unit tests (pytest)
  |
  v
eval gate (regression + quality thresholds)
  |
  v
deploy (only if eval passes)

The eval step sits between tests and deploy. It should be fast enough to run on every PR (under 5 minutes) and deterministic enough to not produce flaky failures.

GitHub Actions workflow with eval gate

# .github/workflows/ai_ci.yml
name: AI CI/CD

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install ruff mypy
      - run: ruff check .
      - run: mypy src/

  test:
    needs: lint
    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: pytest tests/ -v

  eval:
    needs: test
    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
      - name: Run eval gate
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: python evals/run_ci_eval.py --threshold 0.80 --warn-threshold 0.85
      - name: Upload eval report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: eval-report
          path: eval_report.json

  deploy:
    needs: eval
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        run: echo "Deploying..."  # replace with real deploy step

The eval gate script

# evals/run_ci_eval.py
import argparse
import json
import sys
from pathlib import Path


BLOCKING_THRESHOLD = 0.80   # fail the job below this
WARNING_THRESHOLD = 0.85    # warn but pass between this and blocking


def run_eval_gate(threshold: float, warn_threshold: float) -> None:
    golden = load_golden_set("evals/golden_set.jsonl")
    results, _ = run_regression(my_system_function, golden)

    total = len(results)
    passed = sum(1 for r in results if r.passed)
    pass_rate = passed / total if total else 0.0

    report = {"pass_rate": pass_rate, "passed": passed, "total": total}
    Path("eval_report.json").write_text(json.dumps(report, indent=2))

    if pass_rate < threshold:
        print(f"EVAL GATE FAILED: pass_rate={pass_rate:.2%} < threshold={threshold:.2%}", file=sys.stderr)
        sys.exit(1)
    elif pass_rate < warn_threshold:
        print(f"EVAL GATE WARNING: pass_rate={pass_rate:.2%} is below warn threshold={warn_threshold:.2%}")
    else:
        print(f"EVAL GATE PASSED: pass_rate={pass_rate:.2%}")


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--threshold", type=float, default=0.80)
    parser.add_argument("--warn-threshold", type=float, default=0.85)
    args = parser.parse_args()
    run_eval_gate(args.threshold, args.warn_threshold)

Blocking vs warning thresholds

Use two thresholds:

  • Blocking threshold (e.g. 0.80) — below this, the deployment is blocked. The change broke something important.
  • Warning threshold (e.g. 0.85) — above blocking but below this, the job passes but logs a warning. Worth reviewing before merging but not an emergency.

This prevents alert fatigue (every minor fluctuation blocks deploys) while still catching serious regressions.

Secrets management

API keys for evals go in GitHub repository secrets (ANTHROPIC_API_KEY), never in the repo. Access them via ${{ secrets.KEY_NAME }} in the workflow YAML. Consider using a dedicated low-quota key for CI evals to limit blast radius if the key is ever exposed.

Common mistakes

Running evals in the same job as tests — if an API call fails, you cannot tell whether the unit tests or the eval caused the job to fail. Keep them in separate jobs with clear names.

No artifact upload — the eval report tells you exactly which cases failed and why. If you do not upload it as a CI artifact, debugging a failed gate requires re-running locally.

Threshold too tight — setting the blocking threshold at 1.0 (100% pass rate) means any flaky LLM response blocks the deploy. Start at 0.80, tighten as the eval suite matures.

Hardcoding the threshold — pass thresholds as CLI arguments or environment variables so you can tighten them incrementally without modifying the script.

Try it yourself

Write a .github/workflows/ai_ci.yml that runs lint, tests, and an eval gate for a small AI function you own. The eval step should: load a golden set from evals/golden_set.jsonl, run the function against each case, compute pass rate, exit non-zero if pass rate is below 0.80, and upload eval_report.json as a CI artifact. Test it locally by running python evals/run_ci_eval.py --threshold 0.80 and verify the exit code matches the pass/fail result.

Lesson Deep Dive

Ask a follow-up question about this lesson and get an AI-powered explanation.

Loading previous questions...