Module 2: Your First Pipeline Pytest In Ci
2. Anatomy of a GitHub Actions workflow
Description
In lesson 1 you saw the whole tests.yml at a glance and learned to read it as three questions —when, where, what—. This lesson takes it apart screw by screw. By the end you'll know exactly where the file lives in your repository, how YAML works as a format (and why indentation isn't decoration but syntax), and what each of the four keys that structure every workflow does: name, on, jobs, and inside a job, runs-on and steps.
We're not going to run anything in this lesson —remember the module's rule: the workflow is content that gets explained—. But we are going to read it with the same seriousness you'd read the code of a function you're going to maintain, because that's what it is: a configuration file that other people on your team are going to read, copy, and modify. A workflow you don't understand is a workflow you can't fix when it fails, and it will fail; today's goal is for this file to stop being a black box and become something you read at a glance.
Connection to the module: this lesson assembles the skeleton of the workflow; the following ones fill each gap. Here you see the four keys and the complete minimal workflow, but we treat on: and the steps superficially on purpose: on: in depth is lesson 3, the preparation steps (checkout, setup-python) are lesson 4, installing dependencies is lesson 5, and the step that runs pytest is lesson 6. Think of this lesson as the floor plan of the house: today you see the rooms and how they connect; the furniture for each one arrives later.
A workflow is a cooking recipe
Imagine a cake recipe written on a card. It has a very recognizable structure, and that structure is almost identical to a workflow's.
At the top, the name: "Carrot cake". It changes nothing about how the cake is made; it's there so that, when you have a file of recipes, you know which is which at a glance. That's the workflow's name.
Then, when it's made: "for birthdays". The occasion that triggers someone to pull out this recipe and start cooking. That's the on.
Then, the work itself, which in turn has two parts. First, where and with what you cook: "in the kitchen, with an oven". The environment. That's the runs-on. And second, the steps in order: preheat the oven, mix the dry ingredients, beat the wet ones, combine, bake for 40 minutes. That numbered list, which has to be followed top to bottom because step 4 makes no sense without step 3, is the steps.
A workflow is exactly that: a name, an occasion, a place to work, and an ordered list of steps. If you know how to read a recipe, you already know how to read the shape of a workflow; you only need to learn how each part is written in the format GitHub understands. And that format has a quirk worth attending to before anything else: it's called YAML, and it cares a lot about how you align things.
Where the file lives (and why there)
Before the content, the location, because if the file isn't in the exact place, GitHub doesn't look at it and none of this happens.
Workflows live in a very specific folder inside your repository: .github/workflows/. Notice the details, because each one matters:
- The name of the outer folder starts with a dot:
.github. On Unix, a name that starts with a dot is a "hidden" file or folder (it doesn't show up in a normalls), and it's the convention for configuration things. GitHub looks for its configuration right there. - Inside it goes another folder,
workflows, plural. - And inside that, your
.yml(or.yaml, both extensions work) files. You choose the file name:tests.yml,ci.yml, whatever is descriptive. You can have several; GitHub runs all it finds there.
The full path of our file, then, is:
my-repository/
├── .github/
│ └── workflows/
│ └── tests.yml ← the workflow lives here
├── reservo/
│ ├── models.py
│ ├── pricing.py
│ └── ...
├── test_pricing.py
├── test_refunds.py
├── requirements.txt
└── README.md
GitHub checks that folder automatically: every time you push, it looks at which workflows are in .github/workflows/ and evaluates whether any should trigger. There's no need to "register" the workflow in any panel or activate anything; putting the file in that folder and pushing is all it takes for it to exist. That's why the number-one beginner mistake with Actions is putting the file in the wrong place —in the root, or in workflows/ without the .github, or with a typo in the path— and then wondering why "nothing happens" on each push. If your workflow seems ignored, the first thing you check is the exact path.
YAML: a format where indentation is the syntax
The file is written in YAML, a text format for structured data. Its charm is that it reads almost like a hand-written bulleted list, without the braces and quotes of other formats. Its trap is that indentation —the spaces at the start of each line— isn't aesthetic: it's part of the meaning. Two lines with the same indentation are "at the same level"; a more-indented line is "inside" the one above. Changing the indentation changes the structure, just like in Python.
YAML has only three constructs you need to recognize, and you already saw all of them in the workflow:
One: key-value pairs (a "map" or dictionary). A key, a colon, a value:
name: tests
runs-on: ubuntu-latest
name is the key, tests the value. Read it as "the name is tests". The space after the colon is mandatory.
Two: lists. Each item starts with a dash and a space:
on:
- push
- pull_request
That's "a list of two things: push and pull_request". There's a short form, on one line, in brackets, that means exactly the same:
on: [push, pull_request]
The two are identical; we use the short one when the list is brief and the long one when each item has details inside. You'll see it with the steps, which are a list where each item is itself a map.
Three: nesting by indentation. Here's the heart. When a key contains more structure, that structure goes indented below:
jobs:
test:
runs-on: ubuntu-latest
It reads from the inside out: runs-on: ubuntu-latest is inside test, which is inside jobs. It's "jobs contains a job called test, and that job runs on ubuntu-latest". The indentation (here, two spaces per level) is the only thing that expresses that "inside". If runs-on were at the same level as test, it would mean something completely different —or, more likely, it would be a syntax error and GitHub would reject the workflow—.
Two practical rules that save you 90% of YAML pain:
- Use spaces, never tabs. YAML forbids the tab for indenting. Configure your editor so the Tab key inserts spaces in
.ymlfiles. A hidden tab is the classic cause of an "invalid workflow" you can't find at a glance. - Be consistent with the number of spaces. Two spaces per level is the convention. What matters isn't the exact number, but not mixing: if one level uses two spaces and another uses four for no reason, sooner or later you'll get confused.
Worked example: the whole workflow, commented line by line
Here's the minimal workflow that runs the Reservo suite. It's the same one from lesson 1, now annotated. Read it slowly; then we go through it key by key.
# .github/workflows/tests.yml
# The name of the workflow, as it appears in GitHub's "Actions" tab.
name: tests
# WHEN to run: on every push and every pull request. (Lesson 3.)
on: [push, pull_request]
# WHAT jobs to run. A workflow has one or more "jobs".
jobs:
# We define a job and call it "test" (you choose the name).
test:
# WHERE to run this job: a clean, up-to-date Ubuntu machine.
runs-on: ubuntu-latest
# The job's STEPS, in order, top to bottom.
steps:
# Step 1: bring your code onto the runner. (Lesson 4.)
- uses: actions/checkout@v5
# Step 2: install the Python you asked for. (Lesson 4.)
- uses: actions/setup-python@v5
with:
python-version: "3.14"
# Step 3: upgrade pip. (Lesson 5.)
- run: python -m pip install --upgrade pip
# Step 4: install the project's dependencies. (Lesson 5.)
- run: pip install -r requirements.txt
# Step 5: run the test suite. (Lesson 6.)
- run: pytest
Lines that start with # are comments: YAML ignores them completely, they exist only for the human reading. You can put them wherever you want to explain to yourself or your team what each part does.
Now, the four keys that structure everything, top to bottom.
name — what the workflow is called
name: tests
It's the label this workflow appears under in the GitHub interface, in the "Actions" tab where all the runs are seen. It's purely cosmetic: if you delete it, the workflow still works the same (GitHub would use the file name as the label). But put it, because as soon as you have more than one workflow, a clear name is the difference between finding the run you're looking for and guessing. tests says what it does.
on — the trigger
on: [push, pull_request]
The when. It tells GitHub which events make this workflow run. Here, two: every push (every time you push commits) and every pull_request (every time a merge request is opened or updated). This single line is what turns the workflow into something automatic: without it, the file would sit there never triggering. Lesson 3 is entirely about this key —which events exist, why push + pull_request is the standard pair, and how to narrow it to specific branches—; for now keep in mind that on answers "when".
jobs — the work (or the works)
jobs:
test:
runs-on: ubuntu-latest
steps:
...
Here lives the muscle of the workflow. jobs is a map of one or more jobs, and each job is an independent execution unit that runs on its own machine. We gave it a single job and called it test —that name is yours to choose; it could be build, lint, unit-tests—. A workflow can have several jobs (for example, one that runs the tests and another that checks the code style) and, by default, they run in parallel, each on its own clean machine. In this module one is enough.
Inside the job there are two keys that define it: runs-on (where) and steps (which steps). Let's look at them.
runs-on — the machine where the job runs
runs-on: ubuntu-latest
The where. It specifies the operating system and version of the virtual machine —the runner— where this job will run. ubuntu-latest means "the most recent stable version of Ubuntu Linux GitHub offers". It's the most common option because it's fast, cheap (Linux runners consume less than Windows or macOS ones), and enough to run Python tests. There are other options —windows-latest, macos-latest— and running on several at once is precisely the topic of the matrix in module 4; here, Ubuntu is more than enough. The essential thing: this machine starts clean, without your code and without your dependencies. Everything it needs to run your suite, the steps give it.
steps — the ordered list of steps
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v5
with:
python-version: "3.14"
- run: python -m pip install --upgrade pip
- run: pip install -r requirements.txt
- run: pytest
The what, and the part where the real work happens. steps is a list —notice the dash at the start of each item— and GitHub runs it in order, top to bottom, on the same machine. That order is sacred: you can't run pytest (step 5) before installing the dependencies (step 4), nor install dependencies before bringing the code (step 1). It's the recipe's list, and skipping the order breaks it.
There are two types of step, and both already appear here:
- A step with
uses:runs a reusable action —a packaged block of work that someone else (often GitHub itself) wrote and published—.actions/checkout@v5is "use the official checkout action, version 5". You don't reinvent how to bring the code; you use the proven piece that already exists. The@v5pins the version, a detail lesson 4 explains carefully. - A step with
run:runs a literal terminal command, exactly as you'd type it in your shell.run: pytestis, word for word, runningpyteston the machine.run: pip install -r requirements.txtis that same pip command.
The mnemonic rule: uses is "bring a ready-made tool", run is "write a command myself". Bringing the code and setting up Python are common tasks that already have an official action (uses); installing your dependencies and running your tests are your own commands, specific to your project (run). Lesson 4 breaks down the two uses steps, and lesson 6 the run: pytest that's the heart of everything.
Common mistakes
Putting the file outside .github/workflows/ and believing CI is broken (location). What happens: someone creates tests.yml in the repo root, or in a workflows/ folder without the .github, pushes, and absolutely nothing happens —no run, no visible error—. Why it happens: GitHub only looks at that exact path; a workflow file anywhere else is, to GitHub, just a text file. How to spot it: if after a push the "Actions" tab shows no new run, suspect the location before the content. How to fix it: confirm the path character by character —.github/workflows/tests.yml, with the leading dot and workflows plural—. Total silence (neither green nor red) is almost always a problem of where the file is, not what it says.
Breaking the structure with inconsistent indentation or a tab (YAML syntax). What happens: someone aligns steps with four spaces on one side and two on another, or their editor inserts a tab, and GitHub reports "Invalid workflow file" with a syntax error that isn't visible at a glance. Why it happens: in YAML the indentation is the structure, and a tab —invisible— isn't the same as spaces. How to spot it: GitHub marks the workflow as invalid and usually points to the line; many editors show tabs if you enable "show invisible characters". How to fix it: always use spaces (never Tab) and be consistent with the amount per level. Configure your editor so the Tab key inserts spaces in .yml files, and the problem disappears at the root.
Confusing uses with run (step type). What happens: someone writes run: actions/checkout@v5 (as if it were a command) or uses: pytest (as if it were an action), and the step fails. Why it happens: both are "steps" and it's easy to mix up which goes with what. How to spot it: if a step that should bring the code or set up Python fails with "command not found", you probably put an action inside a run; if a pytest doesn't run, maybe you put it as uses. How to fix it: remember the rule —uses for reusable actions (checkout, setup-python), run for terminal commands (pip, pytest)—. If it's something you'd type in your shell, it's run; if it's a packaged piece with a name like owner/name@version, it's uses.
Exercises
Exercise 1 — Identify the four keys. Look at this workflow fragment and answer: what's the name, when does it trigger, on which machine does it run, and how many steps does it have? Also, say which step is of type uses and which of type run.
name: checks
on: [push]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- run: pytest -q
See solution
- Name:
checks(what the label in the Actions tab will say). - When: on every
push(push only; this one doesn't run on pull requests, unlike ours). - Machine:
ubuntu-latest, a clean Ubuntu. - Steps: two. The first,
uses: actions/checkout@v5, is of typeuses(it brings a reusable action to fetch the code). The second,run: pytest -q, is of typerun(a literal terminal command, here pytest in quiet mode with-q).
Notice that this workflow is missing something to really work with a project that has dependencies: there's no setup-python or pip install. It would run pytest on the machine with the Python Ubuntu ships by default and without installing anything from the project —a topic for lessons 4 and 5—. Structurally it's a valid workflow; functionally, incomplete.
Exercise 2 — Fix the indentation. This workflow is badly indented and GitHub would reject it. Without changing a single word, fix the structure so that runs-on and steps end up inside the test job, and the step inside steps.
name: tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: pytest
See solution
The problem is that runs-on, steps, and the step are all at the same level as test, when they should be inside it. Fixed with two additional spaces per level:
name: tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: pytest
What changed: runs-on and steps are now indented below and inside test (four spaces, two more than test), and the - run: pytest is inside steps (six spaces). Indentation is the only thing that expresses "this belongs to that", and without it GitHub doesn't know that runs-on is a property of the test job rather than something else loose. This is exactly the kind of error that produces an "Invalid workflow file", and that's why consistency in indentation is the first rule of YAML hygiene.
Exercise 3 — Translate the recipe into the four keys. A teammate describes what they want in prose: "A workflow called ci, that runs when someone pushes, on an Ubuntu machine, and that has three steps: bring the code with the checkout action, and then two commands of mine, pip install -r requirements.txt and pytest." Write the corresponding YAML. (Don't worry about setup-python; just translate what they asked for.)
See solution
name: ci
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- run: pip install -r requirements.txt
- run: pytest
Review the decisions:
name: ci— the name they asked for.on: [push]— push only (they didn't mention pull requests, so we don't add them; we could, and it would be better practice, but the exercise is to translate what was asked).jobs:with atestjob — the job name is ours to choose;testis descriptive.runs-on: ubuntu-latest— the Ubuntu machine.- Three steps in order:
checkoutwithuses(it's a reusable action), and then the two commands withrun(they're their terminal commands). The order matters: first bring the code, then install, then run.
This workflow is valid and almost complete; the only thing that would make it robust is a setup-python between the checkout and the pip install, to pin the Python version instead of depending on the one the runner ships. That's exactly what lesson 4 adds.
Summary and next step
In this lesson you took apart tests.yml piece by piece. You learned where it lives —in .github/workflows/, with the leading dot and the plural, an exact path whose most common mistake is getting it wrong and believing "CI doesn't work"—. You understood YAML as a format: key-value pairs, dash lists, and nesting by indentation, where the spaces are the syntax (never tabs, always consistent). And you went through the four keys that structure every workflow: name (what it's called, cosmetic), on (when it triggers), jobs (the jobs), and inside a job runs-on (the clean machine where it runs) and steps (the ordered list of steps). You also saw the distinction you'll use in every workflow: a step with uses brings a ready-made reusable action (checkout, setup-python); one with run runs a terminal command of yours (pip, pytest).
Before moving on you should be able to: write from memory the path where a workflow lives; explain why indentation matters in YAML and what breaks it; name the four keys and what each one answers; and classify a step as uses or run just by looking at it.
You have the skeleton. What's next is filling each gap, starting with the one that makes everything automatic. In lesson 3 we focus on the on: key —the triggers—: what push means exactly, what pull_request adds, why that pair is the standard that protects a team's main branch, and how, if you need it, to narrow the workflow to certain branches. It's the line that turns your file from "fourteen lines of configuration" into "a guardian that wakes up on its own on every change".
Resources
- Workflow syntax for GitHub Actions — the official and complete reference for all the keys of a workflow (
name,on,jobs,runs-on,steps, and many more). It's dense; consult it as a dictionary when you want the exact detail of a key, not straight through. - About YAML for GitHub Actions — the specific section on how GitHub uses YAML, with examples of maps, lists, and indentation. The exact complement to this lesson's part about the format.
- YAML specification (yaml.org) — the reference for the format itself, beyond GitHub. Useful if you want to understand YAML as a general tool (you'll find it in many other places in a developer's life, not just Actions).