📖 Lecture — CI, CD, and Building an ML Pipeline with GitHub Actions

Up to now you've built and run containers and trained models by hand — write code, build an image, test locally, push somewhere. That's fine solo, but the moment a team is involved, or you want confidence that "works on my machine" means "works everywhere," you need automation around that workflow. That's what CI/CD gives you, and for ML systems it needs to check more than just code.

CI, CD, and Deployment are three different things. These terms get used interchangeably even by working engineers, but the distinctions matter:

| Term | What it means | Human involved? | |---|---|---| | Continuous Integration (CI) | Every push/PR automatically triggers a build and test suite | No — fully automatic | | Continuous Delivery (CD) | Every change that passes CI is packaged into a release that's always ready to deploy | Yes — manual approval before release | | Continuous Deployment | Every change that passes CI is automatically released to production | No — no manual gate |

CI is about integration — merging and testing constantly. Delivery and Deployment both happen after CI passes; the only difference is whether a human clicks "go."

GitHub Actions mechanics. GitHub Actions is GitHub's built-in CI/CD engine: you define a workflow as YAML, run on GitHub-managed virtual machines, and the file must live at .github/workflows/main.ymlworkflows, plural. Create .github/workflow/ (singular) instead and GitHub will not run it, warn you, or error — it just sits inert. This is the single most common beginner mistake in CI/CD. A minimal workflow has three sections: name (label), on (trigger — push, pull_request, schedule, or manual workflow_dispatch), and jobs (one or more jobs of sequential steps on runs-on: ubuntu-latest). Inside a job, a step either uses a prebuilt Action (e.g., actions/checkout@v4) or runs a raw shell command. A common trap: many example workflows only trigger on: push to main, so pushing to a feature branch produces nothing in the Actions tab unless you list that branch or also trigger on pull_request. Public repos get 2,000+ free Actions minutes/month — effectively unlimited for practice.

Secrets. Never hardcode credentials in YAML — it's committed and permanently in git history. Store them in GitHub Secrets and reference ${{ secrets.MY_SECRET }}; GitHub tries to mask them in logs, but masking is not a guarantee: very short secrets (1–3 chars) aren't reliably masked, and a base64-encoded (or otherwise transformed) copy of a secret bypasses masking entirely, since GitHub only recognizes the exact registered string. A more secure pattern than long-lived secrets is OIDC: your cloud provider trusts GitHub Actions as an identity provider, GitHub mints a short-lived JWT per run, and your workflow exchanges it for temporary cloud credentials — nothing long-lived to leak.

ML CI extends software CI — it doesn't replace it. A pipeline can pass every unit test and still ship a model trained on leaked data, evaluated only on data it already memorized, or packaged in an image that silently resolves different dependencies tomorrow. ML CI inserts two new gates before packaging, and one new packaging discipline:

| Stage | Traditional CI | ML CI adds | |---|---|---| | Build | Install deps, compile | Same, plus pin exact versions | | Code test | pytest on functions/classes | Same | | Data test | — | Schema/quality checks (Great Expectations-style) on data | | Model test | — | Offline eval on held-out data; fail the build below a metric threshold | | Package | Build a Docker image | Build an immutable, version-tagged image |

Data tests are the stage most teams skip. Pytest tests your code's logic; a tool like Great Expectations tests your data — declarative "expectations" such as expect_column_values_to_be_between("age", 0, 120) or expect_column_values_to_not_be_null("passenger_id"), each acting as a unit test for a dataframe instead of a function. A mature pipeline runs both, back to back, and fails the build if either fails.

Model evaluation is a CI gate, not a notebook exercise. Evaluating a model only on its training data is overly optimistic — 99% accuracy on training data may just mean memorization, not generalization. Enforce a real split between training, validation, and test data, check for leakage (no row in both train and test, no feature derived from the label or the future), and only then let the metric gate the build.

Packaging must be immutable. Tagging an image only :latest makes deployments non-reproducible, because :latest is a mutable pointer that can silently repoint tomorrow. Tag every build with something immutable — the git SHA (myapp:a3f9c21) and/or a semver — and treat :latest as a convenience alias only. Use multi-stage, python:slim-based Dockerfiles with a .dockerignore, and copy requirements.txt and install dependencies before copying application code, so Docker's layer cache is reused whenever code changes but dependencies don't — turning a five-minute build into fifteen seconds. Pin exact versions in requirements.txt, and add a HEALTHCHECK so orchestrators can detect an unhealthy-but-running container.

Put together: ML CI is the same discipline as software CI, applied one layer deeper — to the data and the model artifact, not just the code.