MLOps for ML practitioners · Article 11 · series finale

CI/CD with GitHub Actions

Article 10 automated training and promotion. It never touched the Docker image or the live API. This article closes that last gap, and with it, the roadmap this series has been building toward since Article 0.

Part of the MLOps series · read Article 10 first

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
1The problem: the one thing still done by hand
2What CI/CD is, and what GitHub Actions specifically does
3Writing a real test suite for the churn project
4Anatomy of a workflow file
5The test job: running pytest on every pull request
6Secrets: never putting credentials in a YAML file
7The build-and-push job: Docker Hub
8Caching: the same idea, a third time
9AWS ECR, and why OIDC beats access keys
10Branch protection: making the check actually count
11The complete workflow
12Where 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.

pull_request opened/updated GitHub spins up a fresh runner job: test runs pytest job: build-and-push needs: test
Fig. 1 — build-and-push only runs if test succeeds first

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
Broken assertion SeniorCitizen constraint dropped pytest fails: 1 failed, 4 passed Broken environment pydnatic typo in requirements.txt pip install fails: exit code 1 Both stop the workflow before build-and-push ever runs
Fig. 2 — two different failure modes, caught the same automatic way

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, and on: gets silently read back as the boolean True, not the string "on". That's real, confirmed directly, and it's the YAML 1.1 spec's fault: bare on, off, yes, and no are boolean literals unless quoted. It doesn't affect GitHub's own parsing, which has always treated on: 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.

Git + DVCcode + data Dockerenvironment FastAPIserving MLflowtracking + registry Prefectscheduled automation GitHub Actionstest, build, ship — this article A pull request that proves itself before a human ever has to trust it by hand
Fig. 3 — every article in this series, one system

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.