labs · ai-assisted programming
Two halves. Build a pipeline that checks code which behaves the same every time, then build one that checks something that answers differently on every run — because any app with an AI feature in it contains both.
Open a Codespace on your own copy of the module repo.
Move into this lab and install its dependencies:
cd lectures-and-labs/week10/cicd_lab
pip install -r requirements.txt
Confirm the sample app's test passes, then run the app and open the port the Codespace forwards:
python -m pytest
python -m flask --app startup run
hello_app/ is a small Flask application with a working test. It is
deliberately ordinary — the lab is about what you build around it.
Section 3 sends a diff to a hosted model from GitHub Actions. It uses the
same free API key you got for the RAG lab, stored in your copy of the repo
as a repository secret named LLM_API_KEY — nothing in this lab costs
money, but on the free tier Google may use what you send to improve its
products, so send it nothing personal. Sections 1, 2 and 4 need no key at
all.
A pipeline is a script that runs on a trigger. The only thing that makes it different from running the commands yourself is that it runs whether or not you remembered.
.github/workflows/ci.yml in your own copy of the repo.push.lectures-and-labs/week10/cicd_lab/requirements.txt, and run python -m pytest from lectures-and-labs/week10/cicd_lab.Expected output
Run python -m pytest
========================= test session starts =========================
collected 1 item
tests/test_app.py . [100%]
========================== 1 passed in 0.4s ===========================
Step 5 matters more than step 4. A pipeline you have never seen fail is a pipeline you have no evidence works — plenty of workflows pass because they silently run nothing.
If the run fails with No module named 'hello_app', check the working
directory, and that you ran python -m pytest rather than pytest: Actions
starts at the repo root, not in the lab folder, and only python -m puts
the current folder on the import path.
Most people add tests first because tests are what CI is "for". Value per minute says otherwise.
Add three checks to your pipeline, in this order:
Secret scanning — GitHub's own scanner is for public repos and paid
plans, and your copy is private, so add gitleaks to ci.yml instead.
Give the checkout step fetch-depth: 0 and add the gitleaks step:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v3
Dependency scanning — enable Dependabot alerts (Settings → Advanced Security).
A linter — add a ruff or pylint step to ci.yml.
For each, record in findings.md: what it caught, or that it caught
nothing.
Answer: which of these three could you not undo by pushing a fix?
What you should have
Three checks in place — gitleaks and a linter in ci.yml, Dependabot
alerts on — and a written answer to step 5 with your reasoning.
Step 5 is the whole point of the section. A failing test costs you a red build; push a fix and it is gone. A leaked credential is not undone by pushing a fix — the key is out, and it must be rotated.
That asymmetry is why irreversibility, not sophistication, is the right ordering principle for what you automate.
fetch-depth: 0 matters: gitleaks scans every commit you push, so it needs
the history. Without it, a push of more than one commit turns the step red
and its log says unknown revision.
DIY 1 triggers ci.yml on push only. If you add pull_request to its
triggers, also give the job permissions: with contents: read and
pull-requests: read: on a pull request gitleaks lists the request's
commits, and the default token of a private repo is not allowed to.
gitleaks-action needs no licence key in a copy under your own account. In a
copy that belongs to an organisation it asks for a free one, passed to the
step as GITLEAKS_LICENSE.
Expect the linter to go red on its first run: it finds real things in the
starter app. If you use ruff, point it at the lab folder
(ruff check lectures-and-labs/week10/cicd_lab), because ruff check .
from the repo root lints every other lab too, and do not clear the
findings with ruff check --fix. Three of the unused imports, in
hello_app/webapp.py and startup.py, are there on purpose, and removing
them breaks the app and its test. Mark those with # noqa: F401 instead.
A linter finds style. A compiler finds type errors. Neither can tell you that a function's name no longer describes what it does.
review_diff.py in this folder is the starter for this section. It reads
a diff, sends it to a model with a prompt that asks only for the things a
compiler cannot check, prints the findings — and exits non-zero on
any failure (no key, no diff, an HTTP error, an empty answer), because a
review that could not run must go red, not green.
Add the free API key from the RAG lab (or make one at
aistudio.google.com/apikey) to
your copy of the repo as a repository secret named LLM_API_KEY
(Settings → Secrets and variables → Actions).
Add a second workflow, .github/workflows/review.yml, that runs on
pull requests, diffs the branch against its base, and runs the starter
on that diff:
name: review
on: [pull_request]
jobs:
review:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Review the diff, and fail if the review could not run
env:
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
shell: bash
run: |
git fetch --quiet origin ${{ github.base_ref }}
git diff origin/${{ github.base_ref }}...HEAD > pr.diff
python lectures-and-labs/week10/cicd_lab/review_diff.py pr.diff | tee review.md
- name: Post the findings as a comment
env:
GH_TOKEN: ${{ github.token }}
run: gh pr comment ${{ github.event.pull_request.number }} --body-file review.md
It posts its findings as a comment, not a commit — the last step above. Keep it that way.
Open a pull request with a deliberately badly-named function and confirm the review notices.
Now break the secret on purpose — rename it, or set it to nonsense — and confirm the job goes red rather than reporting a clean review.
What you should have
A review job that comments on pull requests, and evidence from step 5 that a broken run goes red instead of green.
Step 5 is the one that separates a useful review job from a decorative
one. The failure mode is a job that catches its own exception, writes the
error text into the report, and exits zero — leaving a green tick over a
check that never ran. The starter refuses to do that; if you write your
own, keep that property. Actions turns pipefail on only for a step that
says shell: bash, which is why the review step does: without it
python review_diff.py | tee exits with tee's status, 0, and the step
stays green when the script fails.
Never ask it whether the code compiles. A compiler answers that exactly; a model guesses.
If you used another provider in the RAG lab, add LLM_BASE_URL and
LLM_MODEL as repository secrets too and pass them in the env: block
next to the key, the same way. Add them only if you made the secrets: a
missing secret reaches the script as an empty string, and an empty
LLM_BASE_URL or LLM_MODEL beats the built-in default.
summarise.py in this folder wraps a small summarising feature. Same
input, different output. Everything you know about testing assumes that
cannot happen.
tests/test_summary.py, write test_exact: a test asserting the
summary of samples/article_1.txt equals a fixed string you got from
one run.Expected output
FAILED tests/test_summary.py::test_exact
AssertionError: assert 'Revenue rose 12% on renewals.'
== 'Revenue grew 12% year on year.'
Both strings are correct summaries. The test is not detecting a bug — it is asserting something that was never true: that the output is fixed.
This is the moment the rest of the section exists to solve. Do not fix the test yet.
Replace that test with property checks that are true of any acceptable summary.
evals/cases.json, each
with an input and the properties its output must satisfy.evals/run_evals.py to score the set and print a pass rate,
not pass/fail.Expected output
Running 8 cases
case-01 PASS
case-02 PASS
case-03 FAIL (missing key figure: "12%")
...
Pass rate: 7/8 (87.5%)
Property checks are cheap, deterministic and repeatable, and they catch the failures that actually happen: empty output, runaway length, the prompt echoed back, the one number that mattered dropped, "fell" where the article said "rose". Run the set more than once — a rate from one run of a non-deterministic feature is a single sample.
Reach for a model-as-judge only for the residue no property can express — and if you do, ask it for the reason as well as the score.
PROMPT at the top of summarise.py — try to improve it. The
stand-in model understands a short list of instructions, documented at
the top of that file: mention the figure, mention the direction, set a
word limit, ask for two sentences. A real model understands far more,
far less predictably — which is why you measure.What you should have
Two pass rates and an honest verdict, including any case that got worse while others improved.
The interesting result is when the rate is unchanged but different cases fail. That is exactly what tuning against two or three examples does to you, and it is invisible without a set. A word limit is the classic case: the figure survives and the qualifier a case wanted does not.
If your rate went to 100%, add harder cases. An eval suite everything passes has stopped telling you anything.