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.
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.
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.
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"usesruns a published action, an already written step.actions/checkoutcopies the repository onto the runner, which does not happen on its own, andactions/setup-pythoninstalls 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 betweenv2andv4, which are several runner generations behind: checked today, the current major of bothactions/checkoutandactions/setup-pythonisv7. withpasses inputs to the action, the waypython-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.
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.
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 == 422pytest finds those tests because the file name and the function names begin with test_. Two settings make the imports work from the project root.
[pytest]
# the tests import app from the root and the sp package from src
pythonpath = . src
testpaths = testsRunning 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.
pytest -q... [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.
pytest -qapp.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.60sThe 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.
- name: Train the model
run: PYTHONPATH=src python -m sp.pipeline.train_pipelineThe workflow file in one piece
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 -qWhat 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
nameyou 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 machine | On the runner | |
|---|---|---|
| The code | Whatever is in your working directory | A clean clone of the pushed commit |
| The model | Already in artifacts/ | Absent until a step trains it |
| The dependencies | Installed once in your environment | Installed from scratch on every run |
| A failure | Seen by you | Marked on the commit and the pull request |
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.Related
- Previous: CI/CD for a model service
- Next: Building the image in CI
- The service these tests call: Serving the model with FastAPI
- Reference: Workflow syntax for GitHub Actions
- Break one assertion in
tests/test_service.py, runpytest -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 underonso the workflow can also be started by hand from the Actions tab.
Every expert started right here.