CI/CD for AI Apps

Automate the path from commit to production for AI apps: a pipeline of lint → tests → build image → eval gate → deploy, with the eval suite as a first-class gate, image versioning tied to git, registries, and safe deploy strategies (canary/blue-green) with rollback.

~3hcicddeploymentllmops
Learning objectives
  • Describe a CI/CD pipeline for an AI app: lint → tests → build image → eval gate → deploy.
  • Add the eval suite as a first-class gate (not just unit tests) so quality regressions block a merge.
  • Version images tied to git and push to a registry.
  • Deploy safely with canary/blue-green strategies and a fast rollback.
Note

For JavaScript developers: CI/CD here is the same GitHub Actions you'd use for a Node app — a YAML workflow of jobs (lint, test, build, deploy) triggered on push/PR. The AI-specific twist is one extra gate: the eval suite (Track 5) runs alongside unit tests, because a passing test suite doesn't prove the model still answers well.

The pipeline

CI/CD automates commit → production: CI (continuous integration) runs checks on every change; CD (continuous delivery/deployment) ships the artifact that passed. For an AI app the pipeline is:

push/PR → lint → unit tests → build & tag image → EVAL GATE → push to registry → deploy (canary → full)

Everything before deploy is a gate: fail any step and the change doesn't ship. The AI-specific insight is that unit tests are necessary but not sufficient — they verify your code, not your model's output quality. That's what the eval gate adds.

Here's a CI workflow (GitHub Actions) for the lint/test/build stages:

# .github/workflows/ci.yml
name: ci
on: [push, pull_request]                 # run on every push and PR
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4          # get the code
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install -r requirements.txt
      - run: ruff check .                  # lint (fast, catches style/errors) — a gate
      - run: mypy .                        # type-check — a gate
      - run: pytest                        # unit tests (Track 1) — a gate
      - run: docker build -t app:${{ github.sha }} .   # build image, tagged by the commit SHA
Tip

Exercise 1 has you write a CI workflow with lint + type-check + tests + build, and confirm a failing test blocks the pipeline — the safety net every deploy sits on.

The eval gate

This is what makes it AI CI/CD. Run the offline eval regression suite (Eval Design, Track 5) as a pipeline step, and fail the build if quality drops below threshold — including on weak slices. A prompt or model change that passes unit tests but tanks quality is caught here, not by users:

  eval:
    runs-on: ubuntu-latest
    needs: test                          # only run if unit tests passed
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements.txt
      - run: python run_eval.py --dataset golden_set.jsonl --min-score 0.85   # the eval gate
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}   # secret injected by the CI platform, never in code
# run_eval.py exits non-zero (fails the job) if overall or any weak-slice score is below threshold.
# run_eval.py — the script the gate runs
import sys
result = run_eval(feature, load_dataset())          # from Track 5
if result["overall"] < 0.85 or result["by_tag"].get("adversarial", 1) < 0.70:
    print(f"EVAL REGRESSION: {result}"); sys.exit(1)  # non-zero exit → the CI job fails → merge blocked
print(f"eval passed: {result}")
Watch out

Eval-gate steps call the model, so they cost tokens and add minutes, and they're non-deterministic — don't set the threshold so tight that normal variance flaps the build. Use a small, curated golden set, pin the model version (LLMOps), and set thresholds with a margin. Secrets come from the CI secret store (${{ secrets.* }}), never committed.

Tip

Exercise 2 has you add an eval-gate job that runs after unit tests and blocks the merge on a simulated quality regression — CI enforcing model quality, not just code correctness.

Versioning images & the registry

Every build produces an immutable image tagged to its git commit (app:<sha>), pushed to a container registry (Docker Hub, GitHub Container Registry, Azure Container Registry). Tagging by SHA means every deployed artifact traces back to exact code, and any past version can be redeployed — the basis of rollback:

  build-push:
    runs-on: ubuntu-latest
    needs: [test, eval]                  # only build the deployable image if BOTH gates passed
    steps:
      - uses: actions/checkout@v4
      - run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
      - run: |
          docker build -t ghcr.io/acme/app:${{ github.sha }} -t ghcr.io/acme/app:latest .
          docker push ghcr.io/acme/app:${{ github.sha }}     # immutable, SHA-tagged
          docker push ghcr.io/acme/app:latest                # moving 'latest' pointer

Tag by immutable SHA for anything you deploy or roll back to; latest is a convenience pointer, never the thing you pin in production (same lesson as pinning dated model snapshots in LLMOps).

Tip

Exercise 3 has you tag an image by git SHA, push it to a registry, and pull a specific SHA back — demonstrating the traceable, redeployable artifact rollback depends on.

Deploy strategies & rollback

CD ships the passed artifact. Two safe strategies (both from the LLMOps rollout discipline):

  • Blue-green — run two environments; deploy to the idle one (green), test it, then switch traffic from blue to green all at once. Instant rollback = switch back to blue.
  • Canary — route a small % of traffic to the new version, watch metrics (quality/cost/latency from Observability), then ramp up or roll back.
  deploy:
    runs-on: ubuntu-latest
    needs: build-push
    if: github.ref == 'refs/heads/main'   # only deploy from main, and only after all gates passed
    steps:
      - run: ./deploy.sh ghcr.io/acme/app:${{ github.sha }} --canary 10   # deploy this SHA to 10% first
      # promotion to 100% (or rollback) is gated on the canary's live metrics

The through-line with LLMOps (Track 5): the pipeline encodes the discipline — gates block bad changes, SHA-tagged images make rollback instant, and canary/blue-green contain a bad deploy. CI/CD is where those principles become automation.

Tip

Exercise 4 has you design the deploy stage: choose canary vs. blue-green for a scenario and specify the rollback trigger and mechanism.

Pitfalls

  • Unit tests as the only gate. They check code, not model quality — add the eval gate or you ship silent regressions.
  • Deploying latest. Pin the SHA-tagged image so you know (and can roll back to) exactly what's running.
  • Secrets in the repo. Use the CI platform's secret store; never commit keys or bake them into images.
  • Flaky eval gate. Non-deterministic evals with too-tight thresholds block good changes — curate the set, pin the model, add margin.
  • No rollback path. A pipeline that can deploy but not instantly roll back turns a bad release into an outage.

Recap

  • CI/CD automates commit → production through gates: lint → tests → build/tag image → eval gate → registry → deploy.
  • The eval suite is a first-class gate — unit tests verify code, evals verify model quality; fail the build on regression (incl. weak slices).
  • Version images by git SHA and push to a registry so every deploy is traceable and any version is redeployable.
  • Deploy with canary or blue-green and keep rollback instant (switch back / redeploy the prior SHA).
  • CI/CD is where the LLMOps discipline becomes automation — gates, versioning, and safe rollout, enforced.
  • Worked examples map to the exercises: a CI workflow (Ex 1), an eval gate (Ex 2), SHA image versioning (Ex 3), a deploy/rollback strategy (Ex 4).

CI/CD for AI Apps — Exercises

Use GitHub Actions (or your CI of choice — the shapes translate). Reuse the eval harness from Track 5 and the Dockerfile from the last topic.


Exercise 1 — A CI workflow with gates

Write .github/workflows/ci.yml that lints, type-checks, runs unit tests, and builds the image. Push a failing test and confirm the pipeline blocks.

Reference solution
name: ci
on: [push, pull_request]
jobs:
  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
      - run: ruff check .
      - run: mypy .
      - run: pytest
      - run: docker build -t app:${{ github.sha }} .

Each run is a gate — a non-zero exit (a failing lint/type/test) fails the job and blocks the merge. The image tag ${{ github.sha }} ties the build to the exact commit. This is the safety net every deploy sits on.


Exercise 2 — Add the eval gate

Add an eval job that runs after test and fails if the golden-set score drops below threshold. Simulate a regression (e.g. a worse prompt) and confirm the gate blocks the merge.

Reference solution
  eval:
    runs-on: ubuntu-latest
    needs: test
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements.txt
      - run: python run_eval.py --min-score 0.85
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
# run_eval.py
import sys
r = run_eval(feature, load_dataset())
if r["overall"] < 0.85 or r["by_tag"].get("adversarial", 1) < 0.70:
    print("EVAL REGRESSION:", r); sys.exit(1)   # non-zero -> job fails -> merge blocked
print("eval passed:", r)

Unit tests would pass a worse prompt (the code still runs); only the eval gate catches the quality drop. Keep the golden set small, pin the model version, and set thresholds with margin so non-deterministic variance doesn't flap the build. The key from the CI secret store, never committed.


Exercise 3 — Version an image by git SHA

Tag an image with the commit SHA, push it to a registry (GHCR), then pull that specific SHA back. Explain why you deploy the SHA, not latest.

Reference solution
docker build -t ghcr.io/acme/app:$GIT_SHA -t ghcr.io/acme/app:latest .
echo "$REGISTRY_TOKEN" | docker login ghcr.io -u "$USER" --password-stdin
docker push ghcr.io/acme/app:$GIT_SHA
docker push ghcr.io/acme/app:latest
docker pull ghcr.io/acme/app:$GIT_SHA        # redeploy any exact past version

latest is a moving pointer — what it references changes with each push, so "roll back to latest" is meaningless. A SHA-tagged image is immutable and traces to exact code, so you can pin production to a known version and redeploy any prior SHA instantly. That traceability is what makes rollback real (same lesson as pinning dated model snapshots).


Exercise 4 — Design the deploy + rollback

For a customer-facing AI support app, choose canary or blue-green, and specify the rollback trigger and mechanism. Justify against the risk.

Reference solution

For a customer-facing app, canary is a strong default:

  • Deploy: route ~10% of traffic to the new SHA; keep 90% on the current version.
  • Rollback trigger: online metrics from Observability — a drop in the quality signal, or a spike in error rate / p95 latency / cost — versus the current version.
  • Mechanism: flip the traffic split back to 100% current (the old version is still running), then investigate. Instant, because you never tore down the previous version.
  • Promote: if the canary holds over a set window, ramp to 100%.

Blue-green is the alternative when you can't split traffic granularly: deploy to an idle environment, test, switch all traffic at once, and roll back by switching environments. Either way the principle is the same as LLMOps: keep the previous version instantly reachable so a bad deploy is contained, not an outage.