MLOpsThe MLOps lifecycle · MLflow 3.17 · DVC 3.67 · FastAPI · Docker · GitHub Actions · Evidently · Python 3.11+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
50 small wins to finish your pathNext lesson →

A GitHub Actions workflow

A GitHub Actions workflow is a YAML file in .github/workflows/ that GitHub runs when an event you name happens in the repository: it starts one or more jobs, gives each job a machine, and runs that job's steps in order on it.

Last updated: 19 Oct, 2026 · MLOps

The CI/CD for a model service lesson described the pipeline. This lesson writes the first half of it: a workflow that runs the test suite of the student performance service on every push and every pull request to main. The file is read key by key, because a handful of keys carry the whole idea.

name, on, jobs, runs-on, steps — the first job line by line · from the MLOPS Tutorial- Automating Workflow Of CI/CD for Dockerized Flask App Using Github Action · 19:46 to 23:40

The clip pins actions/checkout@v3 and actions/setup-python@v4, both of which run on a Node runtime GitHub has since deprecated, so this workflow pins the current majors; the keys themselves, name, on.push.branches, jobs, runs-on, steps, uses and run, are unchanged.

Naming the workflow and the events that trigger it

A workflow file starts with a display name and an on block. Everything under on is an event: a push to a branch, a pull request against it, a schedule, a manual run. The keys and their values are listed in the workflow syntax reference.

yaml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

Declaring a job and the machine it runs on

Under jobs, each key is a job id you choose. runs-on picks the runner the job gets, and ubuntu-latest asks GitHub for one of its own Linux machines. Each job of a workflow gets a new virtual machine, so nothing a job writes survives into the next one.

yaml
jobs:
  test:                     # the job id, and the name shown on the run page
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-python@v7
        with:
          python-version: "3.12"
  • uses runs a published action, an already written step. actions/checkout copies the repository onto the runner, which does not happen on its own, and actions/setup-python installs the Python version you ask for.
  • The part after @ is the version of that action, and pinning it is what keeps a workflow from changing under you. The 2024 videos pin majors between v2 and v4, which are several runner generations behind: checked today, the current major of both actions/checkout and actions/setup-python is v7.
  • with passes inputs to the action, the way python-version: "3.12" does here. Quote the version, or YAML reads 3.10 as the number 3.1.

Writing the tests the job will run

The suite the workflow runs is a plain pytest file that calls the service through FastAPI's TestClient, so no server has to be started. It opens with the client and one record.

python
from fastapi.testclient import TestClient

from app import app

client = TestClient(app)

# the first row of stud.csv, so a reader can check the answer against the data
RECORD = {
    "gender": "female", "race_ethnicity": "group B",
    "parental_level_of_education": "bachelor's degree", "lunch": "standard",
    "test_preparation_course": "none", "reading_score": 72, "writing_score": 74,
}

Then the three checks: the health route answers, a prediction comes back as a number in a sane range, and a reading score above 100 is turned away by the schema.

python
def test_health_reports_ok():
    assert client.get("/health").json() == {"status": "ok"}


def test_predict_returns_a_score_in_range():
    score = client.post("/predict", json=RECORD).json()["math_score"]
    assert isinstance(score, float)
    assert 0 <= score <= 100


def test_a_reading_score_above_100_is_rejected():
    # the schema caps reading_score at 100, so the answer comes before the model
    bad = dict(RECORD, reading_score=150)
    assert client.post("/predict", json=bad).status_code == 422

pytest finds those tests because the file name and the function names begin with test_. Two settings make the imports work from the project root.

ini
[pytest]
# the tests import app from the root and the sp package from src
pythonpath = . src
testpaths = tests

Running the suite before the workflow does

Run it once in the project root. A suite that fails on your own machine will fail on the runner too, and reading the failure locally is faster.

bash
pytest -q
Captured from a real run
...                                                                      [100%]
3 passed in 1.85s

The failure a fresh checkout produces

The runner starts from a clean clone of the repository, and artifacts/ is not in the repository: it holds the preprocessor and the trained model, which are build outputs. The same command on a checkout with no artifacts never reaches a test.

bash
pytest -q
Captured from a real run
app.py:7: in <module>
    pipeline = PredictPipeline()
src/sp/pipeline/predict_pipeline.py:14: in __init__
    self.pre = load_object(preprocessor_path)
...
E   FileNotFoundError: [Errno 2] No such file or directory: 'artifacts/data_transformation/preprocessor.joblib'
=========================== short test summary info ============================
ERROR tests/test_service.py - FileNotFoundError: [Errno 2] No such file or di...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.60s

The import of app builds a PredictPipeline, which loads the preprocessor from disk, so the error arrives during collection and all three tests are reported as one error. The fix is a step that trains the model before the tests run, which also proves the training pipeline still works.

yaml
      - name: Train the model
        run: PYTHONPATH=src python -m sp.pipeline.train_pipeline

The workflow file in one piece

yaml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-python@v7
        with:
          python-version: "3.12"

      # the machine starts bare: the project's own dependencies go on first
      - name: Install dependencies
        run: |
          pip install --upgrade pip
          pip install -r requirements.txt pytest==9.1.1

      # artifacts/ is not in git, so the job trains the model the tests load
      - name: Train the model
        run: PYTHONPATH=src python -m sp.pipeline.train_pipeline

      - name: Run the tests
        run: pytest -q

What the run page shows

The run happens on GitHub, so what follows is what the workflow run logs documentation says the page holds, rather than a run captured here.

  • Two steps you did not write. The runner's own setup and teardown are logged under the names "Set up job" and "Complete job", around the steps from your file.
  • One entry per step, in file order. A step shows the name you gave it, and the time it took.
  • Failed steps open themselves. A step that fails is expanded automatically, and the log search box only matches steps that are expanded.
  • The whole log can be downloaded from the run, which is the quickest way to read a long install step.

The local run against the same run in CI

On your machineOn the runner
The codeWhatever is in your working directoryA clean clone of the pushed commit
The modelAlready in artifacts/Absent until a step trains it
The dependenciesInstalled once in your environmentInstalled from scratch on every run
A failureSeen by youMarked on the commit and the pull request
Watch out. ubuntu-latest follows whichever image GitHub currently calls stable, so the operating system under your job moves without a commit from you. Pin the Python version in setup-python and the library versions in requirements.txt, and a pipeline that passed yesterday keeps passing for a reason you can name.
Try it yourself
  • Break one assertion in tests/test_service.py, run pytest -q, and read which test name is reported.
  • Delete the training step from the workflow and work out which of the three tests would still have a chance of passing.
  • Add a workflow_dispatch: line under on so the workflow can also be started by hand from the Actions tab.

Every expert started right here.