ap.Abhishek Patel

Search

Search notes, essays, projects, the library and the AI course. Use arrow keys to choose and Enter to open.

Lesson 5 of 6 30 min

CI/CD for AI apps

You can build a GitHub Actions pipeline that lints, runs model-free unit tests and a per-category eval gate on every pull request, requires review for prompt changes, and deploys from main with a smoke test.

Words in this lessonCI/CD · GitHub Actions · Eval gate · Regression suite · Prompt versioning · Smoke test
CI/CD
Automatically checking every change and releasing passing changes through one repeatable process. Like a bakery quality line where every tray passes the same checks in the same order, and nothing goes round the side door.
GitHub Actions
GitHub's built-in automation that runs your scripts when something happens in a repository, such as a new pull request. Like a standing instruction to the shop: "whenever a delivery arrives, check it against the order".
Eval gate
An automatic rule that blocks a change from shipping unless it passes agreed evaluation thresholds. Like a bakery’s quality check that no tray passes until it meets the weight and taste standards, however good the tray looks.
Regression suite
A set of automated tests that re-check old bugs, so that a fix stays fixed. Like a pilot’s checklist that grows a new line after every incident, so the same mistake is never made twice.
Prompt versioning
Giving every version of a prompt an ID and history, and recording which version produced each reply. Like dating and numbering every edition of a menu, so you can always tell which one a customer ordered from.
Smoke test
A quick check, right after a deploy, that the new version is actually running and answering. Like switching on a newly installed oven and checking it heats before baking the first batch.
On this page
  1. Arjun writes a checklist
  2. The quality line in the bakery
  3. The four kinds of check, cheapest first
  4. Following one pull request through the pipeline
  5. Opening the hood: GitHub Actions in five words
  6. Prompts are code
  7. Building the pipeline, step by step
  8. Where it breaks
  9. Try it yourself
  10. Common mistakes
  11. Recap
  12. Go deeper

Two weeks after the bot moved to the cloud, Arjun tidied up the system prompt. It had grown messy, so he reorganised it into neat sections, shortened a few rules and moved the cancellation policy to the bottom. It read much better. He deployed it from his laptop on a Friday evening with the same gcloud run deploy command as before.

On Saturday, fourteen customers were told they could cancel their Diwali hampers on the day of delivery for a full refund. Crumb’s actual policy is 48 hours’ notice, because the hampers are packed two days ahead. The shortened prompt had lost the words “48 hours”. Three customers cancelled on Saturday morning; their hampers, already packed, went to the staff room. That was ₹7,200 of hampers, plus fourteen awkward phone calls for Anita’s team.

Meera’s question was the one Priya had been waiting for. “We have tests now. We have evals. We have Priya checking things. How did a change nobody checked reach customers?”

Arjun knew the answer. All the checks existed. Nothing made anyone run them.

Important

The idea in one sentence: CI/CD turns “remember to run the checks” into a pipeline that runs them automatically on every change, so that code and prompts reach customers only after passing linting, tests and an eval gate, and only through one repeatable, logged deploy path.

Arjun writes a checklist

Arjun’s fix was a document. He added a “Release checklist” to the repository’s README:

Before every deploy:
1. Run the linter:   uv run ruff check .
2. Run unit tests:   uv run pytest
3. Run the evals:    uv run python evals/run_gate.py
4. Commit everything, then deploy from main.

He followed it carefully on Monday and Tuesday. On Wednesday at 6:15 p.m., with a hamper-page fix that “had to go out tonight”, he ran steps 1 and 2, skipped the evals (“it’s a one-word change, and they take four minutes”), and deployed. The one-word change was fine, as it happened. But Priya had seen it.

“A checklist that depends on a tired person at six in the evening isn’t a process,” she said. “It’s a hope. This time you were lucky.”

The quality line in the bakery

Priya took Arjun down to the Baner kitchen, where the evening’s trays were coming out.

“Watch what happens to every tray.”

They watched. Each tray went onto the same rack. First a quick look: right shape, right colour, nothing burnt. Then onto a scale: right weight. Then one item from each batch was cut open and tasted by the shift lead. Only then did the tray go to the shop counter.

“Does anyone ever carry a tray round the side, straight to the counter?”

“No,” said the shift lead, overhearing. “Even if it’s the owner’s birthday cake.”

“Why is the quick look first and the tasting last?” Priya asked Arjun.

He thought. “The look takes a second and catches burnt trays. Tasting takes longer and uses up a croissant. No point tasting a burnt tray.”

“That’s a pipeline. Cheap checks first, expensive checks last, every tray, same order, no side door.”

In software this is called CI/CD. Continuous integration (CI) means every proposed change is automatically checked as soon as it is pushed: linted, tested, evaluated. Continuous delivery or deployment (CD) means changes that pass are released by an automated, repeatable process instead of by a person typing commands on a laptop. Together they form the quality line for code.

Where the analogy breaks: a bakery tray is checked once. Software has a second danger the bakery doesn’t: changes interacting. Two changes that each pass alone can fail together, which is why CI runs on the combined result, every time anything changes.

The four kinds of check, cheapest first

Arjun drew the line for Crumb’s bot:

Check What it catches Time Cost
Lint Typos, unused imports, obvious bugs, style ~5 s Free
Unit tests Broken logic in your own code: business rules, parsing, routing ~15 s Free
Eval gate Changes in the model’s behaviour: answers, safety, tone ~4 min ~₹70
Smoke test after deploy “Is the new version actually up and answering?” ~10 s A paisa

Each row exists because of the one above it. Lint cannot tell you the refund rule broke; unit tests can. Unit tests cannot tell you the prompt stopped mentioning 48 hours, because they deliberately don’t call the model; the eval gate can. None of them can tell you the deploy itself worked; the smoke test can.

Note

Pause and think. Unit tests in CI deliberately don’t call the real model, while the eval gate deliberately does. Why not call the real model in the unit tests too, to be more realistic?

Show the answer

They answer different questions. Unit tests check your code: given this model reply, does the refund rule reject it? They should be fast, free, run on every push, and give the same result every time. A real model call makes them slow, costly and occasionally different between runs, so a red test might mean “your code broke” or “the model said something new”, and you can’t tell which. Evals check the model’s behaviour with your prompt, where variation is the point. Keeping them separate means a failure tells you exactly where to look.

Following one pull request through the pipeline

Priya set up the pipeline, then asked Arjun to redo his cancellation change through it, so they could follow one change from start to finish.

9:02 a.m. Arjun creates a branch, edits prompts/support.md to shorten the cancellation section, and opens pull request #142, “Shorter cancellation replies”. A pull request (PR) is a proposal to merge a branch into main, where teammates review it and automated checks run. Opening it triggers the pipeline.

9:02, job checks, lint. 4 seconds. Fails: F401 'json' imported but unused in a helper file he touched. He removes the line and pushes again. The pipeline restarts on its own.

9:04, lint again. Passes. Unit tests: 38 tests, 11 seconds, all pass. Nothing in Arjun’s Python code changed, so this is expected.

9:05, job evals. The eval runner sends all 236 eval cases through the bot with the new prompt, using the real model. It takes 3 minutes 40 seconds and about 800,000 tokens, roughly ₹70. It compares the results with main’s last results:

Category main PR #142 Rule Verdict
Allergy 12 / 12 12 / 12 Must be 100% Pass
Refund 18 / 18 18 / 18 Must be 100% Pass
Cancellation 18 / 18 15 / 18 Must be 100% Fail
Overall 95.3% 94.9% Max drop 1 point Pass

9:09. The job fails and writes the table, plus the three failing cases, to the PR page:

cancel-03  "Can I cancel my hamper for tomorrow?"
  expected to contain: "48 hours"
  reply: "Of course! You can cancel anytime before delivery for a full refund."

The overall score dropped only 0.4 points, well within the allowed range. On its own, that number would have looked fine. The per-category rule caught exactly the bug that cost ₹7,200 on Saturday, before a single customer saw it.

9:15. Arjun puts the 48-hour sentence back, shorter. Pushes. The pipeline reruns everything: lint, tests, evals. Cancellation: 18 / 18. All green.

9:16. GitHub automatically requests Priya’s review, because a rule says any change under prompts/ needs her approval. She reads the diff and the eval table, and approves.

9:21. Arjun merges. The push to main triggers job deploy: it builds the container image, deploys it to Cloud Run as a new revision, then runs a smoke test against the live service, GET /health and one canned chat message. Both pass.

9:24. Live. Twenty-two minutes from PR to production, zero commands typed on Arjun’s laptop, and a permanent record on the PR of who changed what, what the evals said, and who approved it. That record is also the approval trail the governance lesson asked for.

flowchart LR
  PR["Pull request"] --> L["Lint"]
  L --> U["Unit tests"]
  U --> E["Eval gate"]
  E --> R["Review"]
  R --> M["Merge to main"]
  M --> B["Build image"]
  B --> D["Deploy"]
  D --> S["Smoke test"]

Opening the hood: GitHub Actions in five words

GitHub Actions is GitHub’s built-in automation service. It runs your pipeline on fresh, temporary machines whenever something happens in your repository. Five words cover most of it:

  • Workflow: a YAML file in .github/workflows/ describing an automation. YAML is a plain-text format where indentation shows structure.
  • Trigger (on:): the event that starts it, such as a pull request being opened or updated, or a push to main.
  • Job: a group of steps that runs on one fresh machine. Jobs run in parallel unless one needs another.
  • Step: one command (run:) or one ready-made action from the marketplace (uses:), such as “check out the code” or “install uv”.
  • Runner: the temporary machine. ubuntu-latest is a clean Linux machine created for the job and destroyed afterwards, so nothing leaks between runs.

Two more ideas make it safe:

  • Secrets: encrypted values stored in the repository’s settings, such as ANTHROPIC_API_KEY, available to steps as ${{ secrets.NAME }} and hidden in logs.
  • Environments: a named target such as production that can require a person’s approval before any job deploys to it, and holds its own secrets.

Prompts are code

The Saturday incident came from a prompt change, not a code change. So prompts get every protection code gets:

  • They live in the repository, in prompts/, never only in a dashboard or on a server.
  • Every change goes through a PR, with the eval table attached automatically.
  • A CODEOWNERS file names who must review changes to prompts/ and evals/.
  • Branch protection on main requires the checks and evals jobs to pass before merging, and forbids pushing to main directly.
  • The deploy job is the only way to production. Nobody, including Priya, deploys from a laptop.

Building the pipeline, step by step

Step 1: a unit test that needs no model. It checks the business rules from the moderation lesson using a fake reply.

uv add --dev ruff pytest
# tests/unit/test_rules.py: fast, free, same result every time
from crumb.rules import SupportReply, problems

def test_refund_over_order_total_is_rejected():
    reply = SupportReply(message="I'll pass this to the team.", needs_human=True,
                         refund_requested_inr=5000)
    assert "refund larger than order total" in problems(reply, order_total_inr=2400)

def test_normal_reply_passes():
    reply = SupportReply(message="Your order is out for delivery.")
    assert problems(reply, order_total_inr=2400) == []

Step 2: an eval runner that can fail the build. It runs the cases, scores each category, applies the gate rules from the LLMOps lesson, writes a summary table for GitHub to show, and exits with an error code if the gate fails. A non-zero exit code is how any program tells CI “stop the line”.

# evals/run_gate.py
import json, os, sys
from collections import defaultdict
from pathlib import Path

from crumb.support import answer          # the real bot, real model

MUST_BE_PERFECT = {"allergy", "refund", "cancellation"}
MAX_OVERALL_DROP = 1.0

cases = [json.loads(l) for l in Path("evals/cases.jsonl").read_text(encoding="utf-8").splitlines() if l.strip()]
baseline = json.loads(Path("evals/baseline.json").read_text(encoding="utf-8"))  # main's last results

passed, total, failures = defaultdict(int), defaultdict(int), []
for case in cases:
    reply = answer(case["input"]).lower()
    ok = (all(p.lower() in reply for p in case.get("must_contain", []))
          and not any(p.lower() in reply for p in case.get("must_not_contain", [])))
    total[case["category"]] += 1
    passed[case["category"]] += ok
    if not ok:
        failures.append(f"{case['id']}: {reply[:120]}")

reasons = [f"{c}: {passed[c]}/{total[c]}" for c in MUST_BE_PERFECT if passed[c] < total[c]]
overall = 100 * sum(passed.values()) / sum(total.values())
if baseline["overall"] - overall > MAX_OVERALL_DROP:
    reasons.append(f"overall dropped from {baseline['overall']:.1f}% to {overall:.1f}%")

summary = ["| Category | Passed |", "|---|---|"]
summary += [f"| {c} | {passed[c]}/{total[c]} |" for c in sorted(total)]
summary += ["", f"Overall: {overall:.1f}% (main: {baseline['overall']:.1f}%)", ""]
summary += ["**Blocked:** " + "; ".join(reasons)] if reasons else ["**Gate passed.**"]
summary += ["", "Failures:"] + [f"- {f}" for f in failures]

Path(os.environ.get("GITHUB_STEP_SUMMARY", "eval_summary.md")).write_text("\n".join(summary), encoding="utf-8")
print("\n".join(summary))
sys.exit(1 if reasons else 0)

GITHUB_STEP_SUMMARY is a file path GitHub provides to every step; Markdown written there appears on the run’s summary page. When you run the script locally, it writes eval_summary.md instead. When a PR’s change should become the new normal, update evals/baseline.json in that same PR, so reviewers see the new numbers.

Step 3: the workflow. Save as .github/workflows/ci.yml.

name: CI

on:
  pull_request:          # every PR, every push to a PR
  push:
    branches: [main]     # every merge to main

jobs:
  checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4          # get the code
      - uses: astral-sh/setup-uv@v6        # install uv
      - run: uv sync --locked              # exact versions from uv.lock
      - run: uv run ruff check .           # lint
      - run: uv run pytest -q tests/unit   # unit tests, no model calls

  evals:
    needs: checks                          # only if lint and tests passed
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v6
      - run: uv sync --locked
      - name: Eval gate
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: uv run python evals/run_gate.py

Action version numbers like @v4 and @v6 move over time; check each action’s page for the current major version. You should now see a “CI” run appear on every PR, with checks and evals as separate ticks or crosses.

Step 4: the deploy job. Add this under jobs:. It runs only on pushes to main, waits for checks, and logs in to Google Cloud without storing any long-lived key, using workload identity federation: GitHub proves to Google which repository and branch is asking, and Google hands back a short-lived token.

  deploy:
    needs: checks
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production                # can require a human approval
    permissions:
      contents: read
      id-token: write                      # lets GitHub prove its identity to Google
    steps:
      - uses: actions/checkout@v4
      - uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER }}
          service_account: ${{ secrets.GCP_DEPLOY_SA }}
      - uses: google-github-actions/setup-gcloud@v2
      - name: Deploy
        run: gcloud run deploy crumb-bot --source . --region asia-south1 --quiet
      - name: Smoke test
        run: |
          URL=$(gcloud run services describe crumb-bot --region asia-south1 --format='value(status.url)')
          curl --fail --silent "$URL/health"
          curl --fail --silent -X POST "$URL/chat" -H "Content-Type: application/json" \
               -d '{"message": "What time does the Aundh store open?"}'

curl --fail makes the step fail if the service answers with an error, so a broken deploy turns the run red and alerts you. The service keeps its secrets and settings from the first deploy, so this command only needs to ship new code. Setting up the identity provider is a one-time task described in the google-github-actions/auth documentation.

Step 5: make prompts reviewable. Save as .github/CODEOWNERS (use your team’s real GitHub usernames):

/prompts/  @priya-crumb
/evals/    @priya-crumb

Then, in the repository’s settings, protect main: require pull requests, require review from code owners, and require the checks and evals jobs to pass.

Note

Pause and think. The eval job costs about ₹70 per run, and in a busy week Arjun pushes to PRs 40 times. Is ₹2,800 a week worth it? What could you do if it weren’t?

Show the answer

Compare it with one Saturday incident: ₹7,200 of wasted hampers, plus staff time and customer trust. For Crumb it is clearly worth it. If the cost did matter, there are options: run the full eval set only when files under prompts/, evals/ or the bot’s code change (GitHub Actions can filter triggers by path); run a fast critical subset (allergy, refund, cancellation) on every push and the full set once, right before merging; or use cheaper settings for the judge parts of the evals. What you should not do is make evals optional.

Where it breaks

Flaky evals. LLM replies vary. A case that passes nine runs in ten will eventually block a harmless PR, and people learn to click “re-run” until it is green, which defeats the gate. Make critical cases strict and stable (clear prompts, checks on facts rather than exact wording), run critical cases several times, and track which cases flip-flop.

Secrets and forks. For security, GitHub does not give repository secrets to workflows triggered by PRs from forks (copies of the repository owned by other people). In a private company repository this rarely matters; in an open-source project, the eval job will fail for outside contributors unless a maintainer runs it.

Leaking secrets in logs. GitHub masks known secret values in logs, but not if they are transformed (encoded, split). Never echo a secret, and never print the full request your app sends to the model in CI logs.

A green pipeline is not a safe release. The eval set only covers what it covers. Keep the staged rollout, monitoring and kill switch from the earlier lessons; CI/CD decides what may ship, not what will happen in production.

Deploying something other than what was tested. If the deploy job builds from a different commit, or someone deploys from a laptop “only this once”, the pipeline’s guarantees vanish. One path to production, always.

Warning

Give the deploy pipeline its own identity with only the permissions it needs to deploy this one service, and prefer short-lived credentials (like workload identity federation) over a long-lived key pasted into secrets. A leaked deploy key lets anyone replace your production bot.

Try it yourself

In any repository with a Python AI app from this course:

  1. Add ruff and pytest as dev dependencies and write two unit tests that need no model calls.
  2. Add the checks job from step 3 and open a PR. Watch it run.
  3. Deliberately add an unused import, push, and watch lint fail. Remove it.
  4. Add an evals job with five eval cases, one of which your current prompt fails. Confirm the PR is blocked, then fix the prompt and watch it pass.
Show the answer

You should see a red cross on the PR within a minute for step 3, with F401 ... imported but unused in the log. For step 4, the most common snag is the missing secret: the eval job fails with an authentication error until you add ANTHROPIC_API_KEY under the repository’s Settings, then Secrets and variables, then Actions. Once the job runs, the summary table appears on the run page, and the PR shows the evals check as failed until your prompt fix lands.

Common mistakes

  • Checklists instead of pipelines. Anything that depends on someone remembering will eventually be skipped under pressure.
  • Treating prompts as configuration, not code. Prompt changes need review, evals and history exactly like code changes; Saturday’s incident was a prompt edit.
  • One overall eval number. The cancellation slice failed while the overall score barely moved. Gate per category.
  • Deploying from laptops. It skips every check and leaves no record. Make the pipeline the only path to production.
  • No smoke test. A deploy that “succeeded” can still serve errors. Check the live service at the end of every deploy.

Recap

  • CI checks every change automatically; CD releases passing changes through one repeatable path.
  • Order checks from cheap to expensive: lint, unit tests (no model), eval gate (real model), then a smoke test after deploy.
  • GitHub Actions runs workflows of jobs and steps on fresh runners, triggered by PRs and pushes, with encrypted secrets.
  • Prompts are code: stored in the repository, reviewed by named owners, blocked by the eval gate when behaviour regresses.
  • The pipeline is the only way to production, with short-lived credentials, a smoke test and the earlier safety nets still in place.

Arjun explained the new setup to Meera with the PR #142 page open. “Every change, code or prompt, now goes through the same line: a quick check, our own tests, then 236 real questions to the bot, including every cancellation and allergy case. If any of those get worse, the change is blocked automatically, like Saturday’s would have been. Priya approves anything that touches what the bot says. Then it deploys itself and checks it’s working. Nobody deploys from a laptop any more, including me.”

Meera scrolled through the PR, reading the eval table. “Good. I can actually see who checked what.” Then she looked up. “Kabir says you’re entering a hackathon this weekend. Something about an AI that reads stock market news?” Arjun grinned. That is the bonus project.

Go deeper

Check your understanding

3 quick questions
1. Why do the unit tests in CI avoid calling the real model while the eval gate does call it?
2. PR #142 dropped the overall eval score by only 0.4 points. Why was it still blocked?
3. Which practice makes prompts "treated like code"?