GitHub Actions: CI/CD for ML

MLOps, Session 2: from a push to a published image

Lambert Fatoux

Your terminal on someone else’s computer

A workflow is a list of shell commands that GitHub runs for you, on a clean machine, every time something happens in your repository.

Why automate it?

  • No more “works on my laptop”: the runner has nothing but your commit
  • Same checks on every commit, not only when someone remembers to run them
  • The verdict is public: a red cross sits next to the commit, visible to the team
  • It is free to start: public repositories are free on standard runners; private ones get a monthly allowance

Business corner

GitHub’s Octoverse 2025 counts 180 million developers, 43.2 million pull requests merged per month and almost 1 billion commits in a year. Each of them can start a workflow.

In the browser: the Actions tab

Every row is one run. Green check: passed. Red cross: failed. Orange dot: waiting for a machine. Example: the public repository of uv itself.

Six words

This is the workflow you write in the lab, read from the file itself.

The workflow of today’s lab

name: docker
on:
  push:
    branches: [main]
permissions:
  contents: read
  packages: write
jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v7
      - name: Build
        run: docker build -t fraud-scorer:ci .
      - name: Smoke test
        run: docker run --rm -v "$PWD:/data" fraud-scorer:ci ...

The same file, in plain English

  1. When: someone pushes to main
  2. Rights: the run may read the code and write packages, nothing else
  3. Where: one job on a fresh Ubuntu machine, killed after 15 minutes at most
  4. Get the code: copy the commit onto the machine
  5. Build: turn the Dockerfile into an image
  6. Smoke test: run the image on sample.csv; if it crashes, the job turns red and stops there

A step fails when its command exits with an error. That is the only rule.

A workflow file on GitHub

Same keywords as your lab. On line 30 the action is pinned to a commit, with its version in a comment.

Borrowed steps: the Marketplace

actions/checkout is the step every workflow starts with. The page tells you the latest version (v7) and what changed in it.

What starts a workflow?

  • push: every commit on a branch. Build and publish the image from main
  • pull_request: before merging. Lint, tests, data validation
  • schedule: a cron time. Nightly drift check on fresh data
  • workflow_dispatch: a button, or an API call. Retrain on demand; Airflow can call it
  • release: a tag. Promote an image to production

Business corner

Since March 2026, a scheduled workflow can run in your own timezone, so “08:00 in Paris” no longer drifts with daylight saving.

Jobs: in parallel, or one after another

  • Each job gets its own machine: files do not carry over from one job to the next
  • That is why the lab builds, tests and pushes in a single job: the image is already on the disk

Inside a run

The summary shows who triggered it, the commit, the status and the duration. The left column has one line per job; click one to read its steps and logs.

Matrix: one job, many inputs

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        python: ["3.11", "3.12", "3.13"]
    steps:
      - uses: actions/checkout@<sha>
      - uses: astral-sh/setup-uv@<sha>
        with:
          python-version: ${{ matrix.python }}
      - run: uv run pytest

GitHub starts three jobs in parallel, one per value. For ML: the same tests on several Python versions, or the same model check on several data slices.

Artifacts: keep what a job produces

- run: uv run python evaluate.py        # writes report.html
- uses: actions/upload-artifact@<sha>
  with:
    name: eval-report
    path: report.html
    retention-days: 14
  • An artifact is a file that outlives the runner: download it from the run page
  • A later job can fetch it with actions/download-artifact
  • ML uses: evaluation reports, plots, a candidate model, test coverage

A fresh machine starts cold

In session 2 you saw CACHED when you rebuilt an image. On a runner it will not appear: the machine is new every time.

- uses: astral-sh/setup-uv@<sha>      # pin it, see the security slide
  with:
    enable-cache: true                # reuse downloaded packages
- uses: docker/build-push-action@<sha>
  with:
    cache-from: type=gha              # reuse Docker layers
    cache-to: type=gha,mode=max       # ... and save them for next time

The cache lives in GitHub, outside the runner, and survives it.

Secrets and permissions

  • secrets.NAME: stored encrypted in the repo settings, injected into one step, masked in logs
  • GITHUB_TOKEN: a free token that exists for one run; this is what pushed your image to GHCR
  • permissions:: shrink that token to what the job needs; the lab asks for contents: read and packages: write
  • Never echo a secret, never put one in the repo, never bake one into an image

Masking is best effort: a secret that is transformed (base64, split) is no longer recognised in the log.

No cloud key to steal: OIDC

The workflow needs id-token: write. The cloud trusts a repository and a branch, not a password.

Pin what you borrow

An action is someone else’s code running with your secrets.

  • March 2025: attackers moved the version tags of tj-actions/changed-files to a malicious commit; tens of thousands of workflows pulled it
  • A tag such as @v4 can be moved; a full commit SHA cannot
- uses: actions/checkout@<40-character commit sha>   # v7
  • Keep the version in a comment and let Dependabot or Renovate propose updates
  • 2026: checkout v7 refuses to check out fork pull requests under pull_request_target; workflow lockfiles are in preview

CI/CD for ML: the course pipeline

Code is tested on pull requests, models are trained and gated, images are built and approved, retraining is triggered by drift.

Approval before production

deploy-prod:
  needs: deploy-staging
  environment: production      # rules set in the repo settings

Rules live in Settings, Environments: required reviewers, a wait timer, allowed branches. The same image goes to staging and production: only the approval differs.

Best practices for ML pipelines

  • Test the container, not only the build: the smoke test caught what docker build could not
  • Install from the lock: uv sync --locked fails when pyproject.toml and uv.lock disagree
  • Tag with the commit, never latest: an image maps back to the code and data that made it
  • Gate on a metric: no promotion if AUC on the golden set or P95 latency is below the bar
  • Keep heavy training off every push: fast checks on pull requests, training on demand or on a schedule
  • One workflow, one job to fail: a red cross must say what broke

Make green mandatory

A workflow that nobody has to respect protects nothing.

  • Settings, Branches (or Rulesets): protect main
  • Require a pull request before merging
  • Require status checks to pass: pick the jobs by name (ruff, test, build)
  • Optionally require one review and an up-to-date branch

Result: no green check, no merge. The workflow of the lab becomes a rule of the team.

A red run

The red cross sits on the test job; the jobs above it are green. The run page tells you exactly where to look first.

Reading a red run

  1. Open the run, click the red job in the left column
  2. Find the first red step: everything after it was skipped
  3. Read the last lines of its log: the error is almost always there
  4. Reproduce locally with the same command; fix; push
  5. Re-run only when the failure is clearly not your code (network, flaky test)

What does CI cost?

A team pushes 20 times a day and a run takes 3 minutes:

3 min x 20 pushes x 22 working days = 1,320 minutes a month
1,320 min x 0.006 dollars per Linux minute = 7.92 dollars

Three lines keep the bill small:

on:
  push:
    paths: ["src/**", "Dockerfile", "uv.lock"]   # skip doc-only commits
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true                        # a new push cancels the old run
jobs:
  build:
    timeout-minutes: 15                           # a hung job stops costing

News: money and machines

  • 1 January 2026: GitHub-hosted runner prices cut by up to 39%
  • December 2025: GitHub announced a fee of 0.002 dollars per minute for self-hosted runners from 1 March 2026, then postponed it to “re-evaluate”: not in force, not cancelled
  • February 2026: a runner scale set client builds autoscaling runners without Kubernetes

Business corner

Self-hosted runners are the way to run CI on your own GPU machines. A fee on them would change the build-or-buy maths, so watch this one.

Where the image lands: GHCR

The Packages page of a repository lists the image tags and the pull command. In your lab, the tag is the first 7 characters of the commit hash, so each image points back to its code.

Your lab, in three steps

  1. Build and test (step 6): fill the blanks of docker.yml, push, open the Actions tab and read each step’s log
  2. Publish (step 7): add permissions and the GHCR step; the Packages sidebar shows an image tagged with the commit
  3. Break it (step 8): rename a folder in the Dockerfile and watch the build fail; make predict.py crash and watch the smoke test fail

Deliverable: a green run, a package in GHCR, and the sentence “the build passed but the smoke test failed” explained in your own words.

Quiz: what is wrong with this workflow?

on: push
jobs:
  train:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@main
      - uses: some-user/fast-deploy@v1
      - run: pip install pandas scikit-learn
      - run: python train.py
      - run: echo ${{ secrets.AWS_KEY }}
      - run: docker build -t fraud:latest .

Find at least six problems.

Answer

  • on: push: every branch, every commit; no paths, no concurrency
  • @main and @v1: moving references, one of them from an unknown author; pin a SHA
  • pip install without versions: use uv sync --locked
  • Training on every push: slow and costly; run it on demand
  • echo of a secret leaks the key into the logs
  • latest: tag with the commit; and no test, no timeout-minutes, no permissions

GitLab CI/CD

build:
  image: docker:27
  services: [docker:27-dind]
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER"
        --password-stdin "$CI_REGISTRY"
    - docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" .
    - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  • One file at the root: .gitlab-ci.yml; jobs are grouped in stages
  • The container registry is built in, with ready-made variables
  • Free plan: 400 compute minutes a month; your own runners do not count

Bitbucket Pipelines

image: atlassian/default-image:4
pipelines:
  branches:
    main:
      - step:
          name: Build and push
          services: [docker]
          script:
            - IMAGE="$DH_USER/fraud-scorer:${BITBUCKET_COMMIT:0:7}"
            - docker build -t "$IMAGE" .
            - echo "$DH_TOKEN" | docker login -u "$DH_USER" --password-stdin
            - docker push "$IMAGE"
  • One file at the root: bitbucket-pipelines.yml; steps run in order
  • No built-in registry: push to Docker Hub, ECR or another, with repository variables
  • Free plan: 50 build minutes a month; the smallest of the three

Same ideas, three names

What you learn here transfers: a trigger, a clean machine, commands in order, a verdict.

Takeaways

  • A workflow is your terminal on a clean machine, started by an event
  • A red cross on a pull request is the feature, not the problem
  • Test the container, tag with the commit, install from the lock
  • Pin what you borrow, store no long-lived keys, cap the minutes
  • GitLab and Bitbucket differ in syntax, not in ideas