Trace the churn project's actual lifecycle one more time. Prefect now pulls data, trains a handful of models, tracks every run in MLflow, and promotes the best one to @champion, every Monday at 2am, unattended. That's genuinely automated. What still isn't: the Dockerfile from Article 5, or main.py, or schemas.py, changing at all. Someone edits a file, opens a pull request, and from there, everything is manual again. Did the tests still pass? Does the Docker image still build? Nobody knows until a person checks, builds it locally, and pushes it themselves.
That's CI/CD, continuous integration and continuous delivery, and it's the last piece this series set out to cover. This article builds a real GitHub Actions workflow for the churn project: it triggers on every pull request, runs a real test suite, builds the Docker image, and pushes it to a container registry, automatically, before a human ever has to think about it.
What this article covers
| # | Topic |
|---|---|
| 1 | The problem: the one thing still done by hand |
| 2 | What CI/CD is, and what GitHub Actions specifically does |
| 3 | Writing a real test suite for the churn project |
| 4 | Anatomy of a workflow file |
| 5 | The test job: running pytest on every pull request |
| 6 | Secrets: never putting credentials in a YAML file |
| 7 | The build-and-push job: Docker Hub |
| 8 | Caching: the same idea, a third time |
| 9 | AWS ECR, and why OIDC beats access keys |
| 10 | Branch protection: making the check actually count |
| 11 | The complete workflow |
| 12 | Where this leaves the whole system |
Part 1 — The problem: the one thing still done by hand
Every other article in this series closed a specific gap. Git stopped code from being lost. DVC did the same for data and models. Docker stopped "works on my machine." FastAPI stopped bad input from reaching a model. MLflow stopped experiments from being forgotten. Prefect stopped the training-to-promotion sequence from depending on someone remembering to run it.
None of that touches what happens when the code itself changes. Someone fixes a bug in schemas.py, or updates a dependency in requirements.txt, or tweaks the Dockerfile. Right now, in this project, that means: hope the tests still pass, because nobody's required to run them; hope the Docker image still builds, because nobody's required to check; and manually run docker build and docker push if it does. That's exactly the kind of manual step this whole series has been in the business of removing.
Part 2 — What CI/CD is, and what GitHub Actions specifically does
Continuous integration means every code change gets automatically tested the moment it's proposed, not whenever someone remembers to. Continuous delivery extends that: once a change passes, the artifact it produces, here, a Docker image, gets built and published automatically too.
GitHub Actions is GitHub's own automation platform for exactly this: a workflow is a YAML file describing what to run and when. A workflow triggers on an event, a pull request being opened, code being pushed, a schedule, and runs one or more jobs, each made of steps, on a fresh virtual machine called a runner that GitHub provisions and tears down automatically. Individual steps are often prebuilt actions pulled from GitHub's marketplace, actions/checkout to get your code onto the runner, docker/build-push-action to build and push an image, rather than hand-written shell scripts for everything.
The needs: relationship in that diagram matters. A build-and-push job that depends on the test job simply doesn't run at all if testing fails, the same safety property Article 10 built into Prefect: a broken step should stop everything downstream, automatically, not rely on a person noticing.
Part 3 — Writing a real test suite for the churn project
Nothing in this series has had tests yet. Article 9's main.py and schemas.py have been correct, but "correct because I checked it once" isn't the same as "correct, verified automatically, every time someone touches this file."
"""tests/test_schemas.py — unit tests for the Pydantic request/response schemas."""
import pytest
from pydantic import ValidationError
from schemas import Customer, ChurnPrediction
VALID_CUSTOMER = {
"gender": "Female", "SeniorCitizen": 1, "Partner": "No", "Dependents": "No",
"tenure": 4, "PhoneService": "Yes", "MultipleLines": "Yes",
"InternetService": "Fiber optic", "OnlineSecurity": "No", "OnlineBackup": "No",
"DeviceProtection": "No", "TechSupport": "No", "StreamingTV": "Yes", "StreamingMovies": "Yes",
"Contract": "Month-to-month", "PaperlessBilling": "Yes", "PaymentMethod": "Electronic check",
"MonthlyCharges": 95.45, "TotalCharges": 396.1,
}
def test_valid_customer_is_accepted():
customer = Customer(**VALID_CUSTOMER)
assert customer.gender == "Female"
assert customer.tenure == 4
def test_invalid_gender_is_rejected():
bad_data = {**VALID_CUSTOMER, "gender": "Nonbinary"}
with pytest.raises(ValidationError):
Customer(**bad_data)
def test_senior_citizen_out_of_range_is_rejected():
bad_data = {**VALID_CUSTOMER, "SeniorCitizen": 2}
with pytest.raises(ValidationError):
Customer(**bad_data)
def test_negative_monthly_charges_is_rejected():
bad_data = {**VALID_CUSTOMER, "MonthlyCharges": -10.0}
with pytest.raises(ValidationError):
Customer(**bad_data)
def test_churn_prediction_response_shape():
response = ChurnPrediction(prediction=1, probability=0.8371, interpretation="Likely to churn")
assert response.prediction == 1
assert 0 <= response.probability <= 1
A second file goes one level deeper, testing the actual trained model, not just the schema, using the exact two example customers from Article 7.5 onward:
"""tests/test_model.py — sanity checks on the trained model, not just the schema."""
import joblib
import pandas as pd
from schemas import Customer
HIGH_RISK = {
"gender": "Female", "SeniorCitizen": 1, "Partner": "No", "Dependents": "No",
"tenure": 4, "PhoneService": "Yes", "MultipleLines": "Yes",
"InternetService": "Fiber optic", "OnlineSecurity": "No", "OnlineBackup": "No",
"DeviceProtection": "No", "TechSupport": "No", "StreamingTV": "Yes", "StreamingMovies": "Yes",
"Contract": "Month-to-month", "PaperlessBilling": "Yes", "PaymentMethod": "Electronic check",
"MonthlyCharges": 95.45, "TotalCharges": 396.1,
}
LOW_RISK = {
"gender": "Male", "SeniorCitizen": 0, "Partner": "No", "Dependents": "No",
"tenure": 34, "PhoneService": "Yes", "MultipleLines": "No",
"InternetService": "DSL", "OnlineSecurity": "Yes", "OnlineBackup": "No",
"DeviceProtection": "Yes", "TechSupport": "No", "StreamingTV": "No", "StreamingMovies": "No",
"Contract": "One year", "PaperlessBilling": "No", "PaymentMethod": "Mailed check",
"MonthlyCharges": 56.95, "TotalCharges": 1889.5,
}
def _predict(customer_dict):
model = joblib.load("models/model.pkl")
customer = Customer(**customer_dict)
input_df = pd.DataFrame([customer.model_dump()])
return int(model.predict(input_df)[0]), float(model.predict_proba(input_df)[0][1])
def test_high_risk_customer_predicts_churn():
prediction, probability = _predict(HIGH_RISK)
assert prediction == 1
assert probability > 0.5
def test_low_risk_customer_predicts_no_churn():
prediction, probability = _predict(LOW_RISK)
assert prediction == 0
assert probability < 0.5
pytest tests/ -v
tests/test_model.py::test_high_risk_customer_predicts_churn PASSED
tests/test_model.py::test_low_risk_customer_predicts_no_churn PASSED
tests/test_schemas.py::test_valid_customer_is_accepted PASSED
tests/test_schemas.py::test_invalid_gender_is_rejected PASSED
tests/test_schemas.py::test_senior_citizen_out_of_range_is_rejected PASSED
tests/test_schemas.py::test_negative_monthly_charges_is_rejected PASSED
tests/test_schemas.py::test_churn_prediction_response_shape PASSED
============================== 7 passed in 1.67s ===============================
All seven pass, confirmed by actually running them. Now watch what happens when someone accidentally weakens a constraint, dropping ge=0, le=1 from SeniorCitizen's Field(...), a genuinely easy typo to make while refactoring:
tests/test_schemas.py::test_senior_citizen_out_of_range_is_rejected FAILED
FAILURES
_________________ test_senior_citizen_out_of_range_is_rejected _________________
def test_senior_citizen_out_of_range_is_rejected():
bad_data = {**VALID_CUSTOMER, "SeniorCitizen": 2}
> with pytest.raises(ValidationError):
E Failed: DID NOT RAISE ValidationError
===================== 1 failed, 4 passed in 0.11s =====================
That's the entire value of this section. A real regression, caught immediately, before it ever reaches a pull request review, let alone production. This is exactly what Part 5's workflow will run on every single PR from here on.
It's worth seeing the other kind of CI failure too, one that has nothing to do with a test assertion. A dependency typo, pydnatic instead of pydantic in requirements.txt, is exactly the sort of thing that slips into a PR unnoticed:
ERROR: Could not find a version that satisfies the requirement pydnatic==2.6.0 (from versions: none)
ERROR: No matching distribution found for pydnatic==2.6.0
Confirmed directly: pip install exits with status 1 on a bad package name, the "Install dependencies" step fails before pytest ever runs, and the whole job stops there. Two different failure modes, a broken assertion and a broken environment, and the workflow in Part 5 catches both the same way, automatically, on every PR.
Part 4 — Anatomy of a workflow file
Workflow files live at .github/workflows/*.yml in the repository, and GitHub picks them up automatically, no separate registration step.
name: CI - Test, Build, and Push
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
name: is what shows up in GitHub's UI. on: declares the trigger, here, any pull request targeting main. jobs: holds one or more named jobs, runs-on: ubuntu-latest picks the runner's operating system, and steps: lists what happens, in order.
A genuine YAML quirk worth knowing. Try validating this file with a generic YAML parser like Python's
pyyaml, andon:gets silently read back as the booleanTrue, not the string"on". That's real, confirmed directly, and it's the YAML 1.1 spec's fault: bareon,off,yes, andnoare boolean literals unless quoted. It doesn't affect GitHub's own parsing, which has always treatedon:as the trigger key correctly, this only bites you if you write your own tooling to introspect workflow files.
Part 5 — The test job: running pytest on every pull request
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest
- name: Run unit tests
run: pytest tests/ -v
actions/checkout@v4 puts the repository's code onto the runner, the step every workflow needs first. actions/setup-python@v5 installs the Python version the project actually targets. The dependency install and test run are then just the same commands you'd type locally, pip install -r requirements.txt and pytest tests/ -v, run on a clean machine instead of your own. If any test fails, this job fails, and GitHub blocks the pull request from looking green, exactly the signal a reviewer needs before approving.
Part 6 — Secrets: never putting credentials in a YAML file
Pushing to a registry needs credentials, and a workflow file is not a safe place for them, it's committed to Git, visible to anyone with read access to the repo. GitHub Secrets solve this: stored encrypted at the repository level (Settings → Secrets and variables → Actions), referenced in a workflow as ${{ secrets.NAME }}, and never printed in logs, GitHub automatically redacts them if a step tries to echo one out.
For Docker Hub, two secrets: DOCKERHUB_USERNAME and DOCKERHUB_TOKEN, the token generated from Docker Hub's own account settings, not your actual account password.
Part 7 — The build-and-push job: Docker Hub
build-and-push:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/churn-api:pr-${{ github.event.pull_request.number }}
${{ secrets.DOCKERHUB_USERNAME }}/churn-api:${{ github.sha }}
needs: test is the whole safety mechanism from Part 2's diagram: this job simply does not start if the test job failed. docker/setup-buildx-action enables the modern BuildKit builder from Article 4, including its layer caching, the same "requirements before code" habit that article taught applies here too, just to the CI build now instead of a local one. docker/login-action authenticates using the secrets from Part 6, never a literal password. docker/build-push-action builds from the Dockerfile Article 5 wrote and pushes it, tagged two ways: pr-<number> for a reviewer who wants to actually pull and test this exact PR's image, and the commit SHA, a permanent, unambiguous reference back to precisely what code produced it.
Worth being honest about a real tradeoff here. Pushing a real image to the registry on every single PR, as shown above, is a fine, common pattern for a small team where every contributor is trusted. At larger scale, or with PRs from outside contributors, the more common convention is to build and test on every PR, but only actually push on merge to main, done by swapping push: true for push: ${{ github.event_name != 'pull_request' }}. Either is defensible; know which one you're choosing and why.
Part 8 — Caching: the same idea, a third time
Article 3 taught this for DVC, dvc repro skipping an unchanged stage. Article 10 taught it again for Prefect, a task returning a cached result instead of re-running. It shows up a third time here, because rebuilding the same unchanged dependencies and Docker layers on every single PR is exactly the kind of wasted, repeated work all three tools exist to avoid.
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip" # caches the pip download cache between runs, keyed on requirements.txt
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/churn-api:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache: "pip" on setup-python keys a cache of downloaded packages to requirements.txt's contents, change nothing in that file and the next run skips re-downloading every dependency. cache-from/cache-to: type=gha does the equivalent for the Docker build itself, storing built layers in GitHub's own cache so a PR that only touches main.py, and never touches requirements.txt, reuses the exact same dependency-installation layer from Article 4's Dockerfile instead of rebuilding it. Both YAML snippets validate and parse cleanly; the layer-reuse behavior itself is enforced by GitHub's own build infrastructure, the same way DVC's and Prefect's caching only actually shows its effect on GitHub's real runners, not something a local sandbox can fully demonstrate.
Part 9 — AWS ECR, and why OIDC beats access keys
Amazon ECR is the natural alternative to Docker Hub, and the authentication story is worth getting right, because the obvious approach is also the outdated one. Generating a long-lived AWS access key and storing it as a GitHub secret works, but it's a standing credential that lives forever until someone manually rotates it, and a leaked one is a real incident. The current, strongly recommended approach is OIDC, OpenID Connect: GitHub issues a short-lived identity token for the specific workflow run, AWS verifies it and hands back temporary credentials that expire when the job ends, and no AWS secret is stored in GitHub at all.1
build-and-push-ecr:
needs: test
runs-on: ubuntu-latest
permissions:
id-token: write # required for OIDC — nothing else is granted by this
contents: read
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::<ACCOUNT_ID>:role/github-actions-ecr-push
aws-region: us-east-1
- name: Log in to Amazon ECR
uses: aws-actions/amazon-ecr-login@v2
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: <ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com/churn-api:${{ github.sha }}
permissions: id-token: write is what lets the workflow request an OIDC token at all, and it grants nothing beyond that on its own. role-to-assume points at an IAM role configured, on the AWS side, to trust GitHub's OIDC provider for this specific repository, set up once, outside the workflow file, in IAM. From there, aws-actions/amazon-ecr-login handles the actual registry login using the temporary credentials configure-aws-credentials obtained. No AWS_SECRET_ACCESS_KEY anywhere in this file, because there isn't one to leak.
Part 10 — Branch protection: making the check actually count
Everything built so far runs the workflow and reports pass or fail on the pull request. On its own, that's advisory. Nothing stops someone from merging a PR with a big red X sitting right there, GitHub doesn't block anything by default.
Making it actually count is a repository setting, not a workflow file: Settings → Branches → add a branch protection rule for main → enable "Require status checks to pass before merging" → select the test job by name. From that point on, the merge button itself is disabled on any PR where the test job hasn't passed, not just discouraged, genuinely blocked. That one setting is what turns "we have CI" into "broken code cannot reach main."
Part 11 — The complete workflow
name: CI - Test, Build, and Push
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest
- name: Run unit tests
run: pytest tests/ -v
build-and-push:
needs: test
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/churn-api:pr-${{ github.event.pull_request.number }}
${{ secrets.DOCKERHUB_USERNAME }}/churn-api:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
Open a pull request against main in this repository, and this entire sequence, checkout, cached dependency install, the full test suite, a cache-accelerated Docker build, and a registry push, runs on its own, on a machine nobody had to provision, reporting a clean pass or an honest failure directly on the PR before a human ever reviews the code. Add the branch protection rule from Part 10, and a failing run doesn't just report, it blocks.
Part 12 — Where this leaves the whole system
Article 0 opened with a hypothetical: a subscription business's churn model, and a list of everything that goes wrong without proper tooling around it. Article 7.5 built that same model for real, for the first time, wiring together Git, DVC, Docker, and FastAPI into one unbroken chain. Every article since has closed one more gap in that chain. MLflow Tracking made experiments permanent. The Model Registry made "what's live" a managed fact instead of a memory. Prefect made the whole training-to-promotion sequence run itself, on schedule, with retries and a permanent history. This article makes the last piece automatic too: a pull request that changes code now proves itself, tests, build, and all, before anyone has to trust it by hand.
Look at what happens now, end to end, when a real change lands. A data scientist opens a PR tweaking a feature or bumping a dependency. GitHub Actions runs the full test suite in under a minute and builds the image to prove it still works. Once merged, that same commit's image sits in the registry, tagged and traceable back to the exact code, the exact test run, and, if the change touched the model, back through Prefect's flow to the exact MLflow run and the exact DVC-tracked data that produced it. Nothing in that sentence was true nine articles ago, and none of it required a person to remember a single step.
That's the system this series set out to build: not one clever tool, but a set of ordinary tools, each doing one job well, wired together so that the correct thing to do is also the automatic thing to do. The churn model from Article 0 was never really the point. This was.