Release night at a logistics startup in Kakinada. Sindhu's teammate merges his branch at seven. "All tests passed on my machine," he says, and logs off. By nine the main branch cannot even train: his laptop held an extra file that Git never carried. Last sprint's suite was green — but only on hardware nobody else can inspect. The demo slips to Monday. Her lead closes the call with one line: a machine defends its owner. Hire a referee that owns nothing.
"It passed on my machine" is a claim nobody else can verify. The owner's laptop carries leftovers Git never sees: an untracked model file, a stale venv, an edit that was never committed. Continuous integration — the practice of letting a neutral machine build and test the repo on every push — takes the owner out of the verdict. The referee starts empty, takes only what Git carries, runs the 4.5 suite, and posts the result beside the commit. When it is red, the repo is broken, whatever any laptop says.
On GitHub the referee is called GitHub Actions, and you hire it with one file. A workflow — a YAML file under .github/workflows that states when to run and what to run — is that file. Its on: line picks the triggers, such as every push and every pull request. Its jobs: section groups the work, and each job is a list of steps that run in order. A runner — a fresh cloud machine that GitHub starts for each run and destroys after it — is where the steps execute.
Some steps you write, and some you borrow. An action is a packaged step from a public marketplace, pulled in with a uses: line. The checkout action copies your repo onto the runner, and setup-python installs the Python version you name. After those two lines, the runner replays 4.1's clone ritual: install the pins, train the model, run pytest. Your quality gate now fires on hardware that nobody owns and nothing can quietly pollute.
Here is the contrast that gives pull requests their teeth. The wrong way is the trust-me merge: a teammate announces that tests passed, and the merge button takes his word. The right way is branch protection — a repo setting that blocks any merge into main until the named checks pass. With it on, the green tick stops being a courtesy and becomes a lock. Nobody merges red — not you, not the lead, not the person in a hurry on release night.
Two habits keep the referee respected. Speed first: a slow pipeline gets skipped, so cache the pip downloads and keep a run under five minutes. Secrets next: a repository secret — an encrypted value saved in repo settings and handed to steps as an environment variable — replaces any key pasted into YAML. The log prints stars where the value would appear, and 3.2's law still holds: a key that touches anything public gets rotated the same minute.
The last stop is delivery, told honestly. A deploy job can wait for every test job, run only on main, and pause inside a named environment until a human approves. Today that job prints one line, because the real cloud target arrives in 4.7. The same run also builds the 4.3 Docker image, on a runner with full Docker rights. If your college or office laptop never allowed Docker, this is the promised catch-up: the referee builds the box your machine could not.
Work inside churn-repo with the 4.5 suite green locally (pytest -q prints 14 passed) and the repo pushed to GitHub as origin, the 0.2 way. Everything below runs on GitHub's free tier.
Step 1 — the workflow file, line by line. One new file, in a folder GitHub watches:
# .github/workflows/ci.yml — the referee's instructions
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"
- name: Install pinned packages
run: pip install -r requirements.txt
- name: Train the model (models/ is gitignored)
run: python train.py
- name: Run the 4.5 suite, gate included
run: pytest -q
Read it top to bottom. name: ci labels the workflow — this exact string returns in the badge and in branch protection. on: lists two triggers: every push and every pull request. One job called test runs on ubuntu-latest, a fresh Linux runner. The two uses: lines borrow marketplace actions: checkout clones the repo onto the runner, setup-python installs 3.12 on a machine that woke up with nothing. Then three run: lines replay 4.1's clone ritual. The train step is the line most teams forget: models/ is gitignored by 4.1's decision, so the runner has no joblib — yet the 4.5 gate and smoke tests load it. Delete that line and the run dies with FileNotFoundError, which is WR-106 in one sentence.
Step 2 — push it and watch the first run. Commit, push, then open your repo's Actions tab in the browser:
$ git add .github/workflows/ci.yml
$ git commit -m "Add CI: install, train, test on every push"
$ git push
# GitHub -> Actions tab -> run "Add CI: install, train, test..." -> job: test
✓ Set up job 2s
✓ Run actions/checkout@v4 1s
✓ Run actions/setup-python@v5 4s
✓ Install pinned packages 46s
✓ Train the model (models/ is gitignored) 9s
✓ Run the 4.5 suite, gate included 11s
.............. [100%]
14 passed in 4.18s
✓ Complete job 1s
Each line is one step from the YAML, with its wall-clock cost — the pip install dominates at 46 seconds, a number step 4 will attack. Click any step to unfold its full log. The last step's log is your 4.5 board, unchanged: 14 passed, the same fourteen promises, now verified on a machine you have never touched. Back on the repo's main page, the commit now carries a green tick — the referee's signature. Total run: about 1 minute 14 seconds.
Step 3 — break it on purpose, then read the failure down to the assert. Real failures arrive on branches, so stage this one properly. Branch off (git checkout -b break-ci), plant 4.5's divisor bug in src/features.py (/ 3 becomes / 30), commit, push, and open a pull request. The PR page grows a red X within two minutes. Click Details:
✗ Run the 4.5 suite, gate included
F............. [100%]
=================== FAILURES ===================
_______ test_known_customer_worked_by_hand _______
> assert out.loc[0, "spend_share"] == pytest.approx(0.025)
E assert 0.0025 == 0.025 ± 2.5e-08
tests/test_features.py:21: AssertionError
1 failed, 13 passed in 4.21s
Error: Process completed with exit code 1.
Read it in the 4.5 order: the scoreboard (F.............), the failing test's name, the > arrow on the stopping line, the E line holding both numbers, then file and line. The only new line is the last one — exit code 1 is pytest telling the runner it failed, and any non-zero exit turns the step, the job and the whole run red. Now give that red X teeth. In the repo: Settings → Branches → Add branch protection rule for main, tick "Require a pull request before merging" and "Require status checks to pass", and select the test check. On the free plan this is enforced on public repos — and churn-repo is safe to make public, because 4.1's gitignore kept the data, the model and every key out of history. Reload the PR: the merge button now reads "Merging is blocked". Fix the divisor, push, watch the X turn into a tick, and merge — the full green-before-merge loop you will drill in exercise 2.
Step 4 — cache pip, measure the speed-up. The 46-second install re-downloads the same wheels on every run. Tell setup-python to keep them:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip" # cache key = a hash of requirements.txt
Push once to fill the cache (this run still pays full price and ends with "Cache saved"). Push again and compare: the install step opens with Cache restored from key: setup-python-...-requirements.txt, the install drops from 46 s to 6 s, and the whole run from 1 m 14 s to about 34 s. The comment explains the rule: the cache key is a hash of requirements.txt, so code edits reuse the cache forever, while changing a pin rebuilds it exactly once. Two more hygiene habits while you are here. A failed step skips everything after it, so keep cheap steps before expensive ones — red should arrive early. And when a library must support several Python versions, one matrix: block runs the same job on each version in parallel; you do not need it today, but name it in interviews.
Step 5 — a secret in CI, done right. In the repo: Settings → Secrets and variables → Actions → New repository secret. Name it PROVIDER_API_KEY, paste a practice value (a real one arrives with 4.7). Then let a step prove the key arrives without ever showing it:
- name: Prove the key arrives - never print it
env:
PROVIDER_API_KEY: ${{ secrets.PROVIDER_API_KEY }}
run: python -c "import os; print('key length:', len(os.environ['PROVIDER_API_KEY']))"
The env: line is the only bridge: GitHub decrypts the secret and hands it to this one step as an environment variable — the same door your .env file used in 3.2, in a new home. The log prints key length: 21 and nothing else. If a step does echo the value, Actions masks it to *** in the log — but treat masking as a seatbelt, not a licence: print the string reversed or encoded and the mask misses it completely. So the rule stays behavioural: secrets live in repository secrets, never in YAML, and never in a print. If a real key ever lands in a log, it is public — rotate first, in WR-006's order. That is WR-107's whole story.
Step 6 — the referee builds the 4.3 box, and the deploy gate waits. Each job gets its own fresh runner, so the joblib the test job trained died with its machine. An artifact — a file a job uploads so later jobs, or a human, can download it — is how the model travels. Append an upload to the test job, then add two jobs:
# appended to the test job — hand the trained model forward:
- uses: actions/upload-artifact@v4
with:
name: churn-model
path: models/churn_pipeline.joblib
# two new jobs, below test:
docker:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: churn-model
path: models/
- run: docker build -t churn-service .
- name: Smoke the box
run: |
docker run -d -p 8000:8000 churn-service
sleep 5
curl --fail http://localhost:8000/health
deploy:
needs: docker
if: github.ref == 'refs/heads/main'
environment: production
runs-on: ubuntu-latest
steps:
- run: echo "Green across the board. The real deploy lands in 4.7."
needs: draws the pipeline: test, then docker, then deploy — a red test job cancels everything after it. The docker job downloads the artifact into models/, exactly where 4.3's Dockerfile COPY expects the file, then builds churn-service and curls /health inside the runner: {"status":"ok","model_loaded":true}. If your locked-down laptop never got Docker rights, this is your catch-up — the runner ships with Docker preinstalled and full rights, and it builds the 458 MB box your machine could not. The deploy job is the honest CD story: it waits for docker, runs only on main, and its environment: production can be configured (Settings → Environments) to pause until a named human clicks approve. Today its one step prints a sentence; in 4.7 the same slot deploys to AWS. The gate is finished before the destination exists — that is deliberate.
Work with your own churn-repo on GitHub, 4.5 suite green locally before you start. Where a check names a number, your number must match it.
Hire the referee. Write .github/workflows/ci.yml exactly as watch step 1 built it, commit and push. Open the Actions tab and watch the run live; unfold every step's log and find your 14 passed line. Then put the verdict on the front door: add the badge line to the top of README.md —  — and push again. Check: the Actions tab shows two green runs; the pytest step's log ends with 14 passed; the README on GitHub renders a passing badge; you write two sentences naming the most expensive step in the run and why it costs what it costs (ours: the 46-second uncached pip install).
The full discipline loop. Make the repo public if it is private (state in one sentence why 4.1's gitignore makes that safe), then protect main: require a pull request and require the test check. Now run the loop: branch break-ci, plant the divisor bug (/ 3 to / 30 in src/features.py), push, open a PR, and wait for the red X. Confirm the merge button is locked and record its exact wording. Read the log down to the E line and copy it into your notes. Fix the bug on the same branch, push, watch green return, and merge through the PR. Finally, try to push a trivial commit straight to main and record what Git says. Check: the PR timeline shows a failed check followed by a passing one on the same PR; your notes hold the E line with 0.0025 on the left and 0.025 on the right, plus the blocked-merge wording; main's history shows the fix arriving via the merged PR; the direct push to main is rejected with a protected-branch error.
Fast, secret, boxed. Three upgrades to the same workflow. First, speed: add cache: "pip" to setup-python and push twice; record the install-step time and total run time for an uncached run and a cached run in a small table. Second, secrets: create repository secret PROVIDER_API_KEY with a made-up value, add watch step 5's key-length step, and confirm the log shows a length only; then, on a throwaway branch, deliberately echo the secret, observe the *** masking in the log, delete the branch, and write two sentences on why masking is not a licence and what the next move would be if that value were real. Third, the box: add the upload-artifact lines and the docker and deploy jobs from watch step 6, push to a branch, open a PR and merge it. Check: your table shows the install step dropping sharply (ours: 46 s to 6 s) with "Cache restored" in the cached run's log; the secrets step prints a length, your echo test shows ***, and your sentences name rotation as the response to a real leak; the docker job's curl prints model_loaded true on the runner; the deploy job ran only after the merge to main and printed the 4.7 message; one final sentence states what the artifact carried between the two jobs and why the docker job could not have found the joblib without it.
Three CI failures cover most of what this module meets at work. Each has a War Room entry with the full diagnosis path:
FileNotFoundError. CI is the truth: whatever the runner cannot build from the repo alone does not exist.Continuous integration puts a neutral machine between your laptop and main: a fresh runner clones the repo, installs the pins, trains, and runs the 4.5 suite on every push. The workflow YAML names the triggers, the jobs and the steps, with checkout and setup-python borrowed from the marketplace. Branch protection turns the green tick into a lock, so nothing merges into main while a required check is red. Caching pip keeps runs fast enough that nobody routes around them, and a repository secret carries a key into a step without ever printing it. A deploy job gated on green, an environment and a manual approval complete the pipeline — 4.7 gives it a real destination.
Answer these before opening the gate assessment. Each question names its kind.
type=output-prediction — A teammate deletes the python train.py line from ci.yml "because a test job should not train" and pushes. Predict the run step by step: which steps stay green, which step goes red, which of the fourteen tests fail, and the exact error class their logs share.type=debug — CI fails at the install step with ERROR: Could not find a version that satisfies the requirement scikit-learn==1.6.1, while pip install -r requirements.txt succeeds on your laptop. The workflow pins python-version: "3.8". Name the cause, explain why the laptop never showed it, and give the one-line fix.type=interpretation — A run summary shows the test job green in 34 s with "Cache restored from key", and the docker job red at download-artifact with Artifact not found: churn-model. Read the pairing: what did the test job definitely do, what did it definitely not do, and which lines of the YAML do you open first?type=scenario-judgement — Demo week, and the quality gate keeps failing a retrained model. A teammate with admin rights proposes switching branch protection off "until Friday". Judge the proposal, name the 4.5 War Room habit it recreates, and state what the red gate is actually telling the team.type=trade-off — Trigger the suite on every push to every branch versus only on pull requests into main: give one concrete gain and one concrete cost of each, pick one for your churn repo today, and name the signal that would make you switch.Explain to a friend who follows cricket why teams accept the third umpire on a run-out: every fielder is honest but sees from an angle that favours his side, the replay machine stands with neither team, everyone agreed before the match that its verdict outranks any player's word, and play continues only after the decision appears on the big screen. Use at most 4 sentences and no software words. If your friend can say why "the fielder was sure" is not evidence but the replay is, you own this module.