Anatomy of a Workflow
A workflow file looks intimidating until you see it’s just a recipe: when this happens, on this kind of machine, do these steps in this order. This lecture takes the file apart noun by noun, then you write one from a blank page.
🎯 Learning objectives
By the end you can:
- Write the three-part skeleton of any workflow (
name,on,jobs) from memory.- Choose the right event (
push,pull_request,schedule,workflow_dispatch) for a trigger.- Explain the difference between a
run:step (shell command) and auses:step (action).- Describe the runner lifecycle and why steps in a job share files but jobs don’t.
- Author and push a working CI workflow that builds and tests on every push.
Mental model: a recipe card
NAME: "Bake a cake" ← what humans call this workflow
WHEN: someone shouts "birthday!" ← the EVENT
KITCHEN: a brand-new rented kitchen ← the RUNNER
STEPS:
1. get the ingredients ← a STEP (an ACTION: "checkout")
2. preheat oven ← a STEP (a command: "run: ...")
3. mix and bake
4. take a photo of the result ← a STEP (an ACTION: "upload artifact")
YAML is just how we write that recipe in a format the robot can read. Indentation is meaningful in YAML (like Python) — two spaces, never tabs. That’s the #1 source of beginner errors.
The skeleton — every workflow has these three keys
name: My First Workflow # 1. a human-friendly label (optional but do it)
on: push # 2. WHEN should this run? (the event)
jobs: # 3. WHAT should it do? (one or more jobs)
say-hello: # job id (you pick the name)
runs-on: ubuntu-latest # which runner OS
steps:
- run: echo "Hello, world!"
Save that as .github/workflows/hello.yml, push it, and GitHub runs it. That’s a complete, valid workflow. Everything else in this lecture is elaboration on these three keys.
Where the file must live
It must be in
.github/workflows/at the repo root, and end in.ymlor.yaml. GitHub only looks there. A workflow file anywhere else is just a text file that does nothing.
Key 2, deep: events (on:)
The on: key answers “what makes this run?” The most common triggers:
on:
push: # someone pushed commits
branches: [main] # ...but only to the main branch
pull_request: # someone opened/updated a PR
workflow_dispatch: # a manual "Run" button in the Actions tab
schedule:
- cron: '0 3 * * *' # every day at 03:00 UTC (a "cron" timer)
| Event | Fires when… | Classic use |
|---|---|---|
push | Commits land on a branch | Build + test + deploy |
pull_request | A PR is opened or updated | Run checks before merging |
workflow_dispatch | You click “Run workflow” | Manual deploys, one-off jobs |
schedule | A cron time arrives | Nightly backups, link-checkers |
Filters narrow the trigger
pushalone fires on every branch. Addingbranches: [main]means “only pushes to main.” You can also filter bypaths:(only when certain files change) — e.g. don’t rebuild the site when only the README changed.
Key 3, deep: jobs
A job is a set of steps that run together on one runner. A workflow can have many jobs, and here’s the crucial default:
Jobs run in PARALLEL by default
If you list three jobs, GitHub runs all three at the same time on three separate machines. That’s fast — but if
deploymust wait forbuildto finish, you have to say so explicitly withneeds:.
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo "building..."
deploy:
needs: build # ⬅️ wait for `build` to succeed first
runs-on: ubuntu-latest
steps:
- run: echo "deploying..."
needs: build turns two parallel jobs into a pipeline: build → then deploy. This is exactly the shape of your site’s workflow.
runs-on — picking the machine
runs-on: ubuntu-latest # Linux — fastest, cheapest, the default choice
# runs-on: windows-latest # when you need Windows
# runs-on: macos-latest # when you need macOS (e.g. building iOS apps)
Use ubuntu-latest unless you have a specific reason not to — it’s the cheapest in minutes and starts fastest.
Steps: the two kinds
Inside a job, steps: is an ordered list. Every step is one of two things:
steps:
# KIND 1 — run: a shell command on the runner
- name: Install dependencies
run: npm ci
# KIND 2 — uses: a prebuilt ACTION (someone else's reusable step)
- name: Check out my code
uses: actions/checkout@v4
run: | uses: | |
|---|---|---|
| What it is | A shell command you write | A packaged, reusable action |
| Example | run: npm test | uses: actions/checkout@v4 |
| Analogy | Cooking from scratch | Using a kitchen appliance |
| When | Project-specific commands | Common tasks others already solved |
What is an “action” really?
An action is reusable code someone published so you don’t reinvent it. actions/checkout@v4 means:
actions/checkout→ the action’s name (orgactions, repocheckout)@v4→ the version you’re pinning to
There’s a whole Marketplace of them: check out code, set up Node/Python/Java, cache files, upload artifacts, deploy to AWS, post to Slack. Using actions is how a 20-line workflow does an hour of work.
Always pin a version
uses: actions/checkout@v4pins to major version 4. Never use an action with no version — you’d silently run whatever code its author pushes next, which is both a stability risk and a security risk (Lecture 3 covers pinning to a full commit SHA for untrusted actions).
The runner lifecycle — the model that prevents 90% of confusion
When a job starts, GitHub:
1. Boots a BRAND-NEW virtual machine (the runner)
2. Runs your steps top-to-bottom, all in the SAME working folder
3. Tears the machine down and deletes EVERYTHING
Two consequences you must internalize:
Within a job: steps share a workspace
Step 1 does
git checkout→ the files land in the working folder. Step 2 doesnpm ci→ it can see those files. Step 3 doesnpm run build→ it sees the installed packages. Steps in one job share the same disk. That’s why order matters: checkout before build.
Between jobs: nothing is shared
buildanddeployrun on different machines. The filesbuildcreated are gone whendeploystarts. To hand data from one job to the next, you must explicitly upload an artifact inbuildand download it indeploy(Lecture 3). This is the single most common “but the file was right there!” confusion.
🧪 Lab — write your first real workflow
Goal: a CI workflow that, on every push, checks out your code, installs deps, and builds the site — proving the build isn’t broken before you rely on it.
Create .github/workflows/ci.yml:
name: CI — Build Check
on:
push:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out the code
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Build the site
run: npm run build
- name: Say it worked
run: echo "✅ Build passed — safe to deploy."
Then:
git add .github/workflows/ci.yml
git commit -m "[ci]: add build-check workflow"
git push
Open the Actions tab and watch it run. Now break it on purpose: introduce a typo in a doc link (your config throws on broken links), push, and watch the build go ❌ red — and notice it tells you exactly which step failed. Fix it, push, watch it go ✅. That red→green loop is CI.
Why
with:?
with:passes inputs to an action — like function arguments.actions/setup-nodeacceptsnode-versionandcache. Each action’s README documents its inputs.
⚠️ Common misconceptions
Warning
- “Tabs are fine in YAML.” They are not. YAML forbids tabs for indentation — use 2 spaces. Most “invalid workflow” errors are this.
- “Jobs run in order top to bottom.” No — parallel by default. Use
needs:to sequence them.- “The next job can see my built files.” No — new machine, empty disk. Use artifacts.
- “
runandusescan be in the same step.” No — a step is either arunor auses, never both.- “
actions/checkouthappens automatically.” It does not. If you forget it, the runner has no copy of your code and everything downstream fails. It’s almost always step 1.
✅ Self-check
1. What are the three top-level keys every workflow has?
name (label), on (the event/trigger), and jobs (what to do). Only on and jobs are strictly required.
2. You have jobs `test` and `deploy`. You want deploy to run only after test passes. What do you add?
needs: test inside the deploy job. Without it, they’d run in parallel on separate machines.
3. Step 2 runs `npm ci` but fails with "no package.json found." What did you forget?
actions/checkout@v4 as an earlier step. Without checkout, the runner never pulled your code, so there’s no package.json on the empty machine.
4. Why can't a `deploy` job simply read the files that the `build` job created?
Because each job runs on its own fresh runner with its own disk, destroyed after the job. To pass files between jobs you upload them as an artifact in build and download it in deploy.
5. What does `@v4` mean in `uses: actions/checkout@v4`?
It pins the action to major version 4 — a specific, stable line of releases — instead of silently running whatever the author publishes next.
📚 Graded resources
- 🟢 Beginner — Workflow syntax reference — bookmark this; it’s the dictionary for every key.
- 🟢 Beginner — Events that trigger workflows — the full menu of
on:triggers. - 🟡 Intermediate — crontab.guru — interactively decode/build the
cronstrings forschedule:. - 🟡 Intermediate — Choosing the runner /
runs-on— labels, OS choices, and what’s preinstalled. - 🔴 Advanced — GitHub Actions starter workflows — real, production-grade templates for dozens of languages. Read them like worked examples.
Why AI can’t do this for you
AI generates flawless-looking YAML. But it can’t feel the difference between “jobs run in parallel” and “jobs run in order” until your deploy ships before your build finished and breaks production. The recipe is easy to copy; knowing why each line is there — so you can change it safely — is what you’re building here.
🔑 Key terms
| Term | Meaning |
|---|---|
| YAML | The indentation-based config format workflows are written in (2 spaces, no tabs). |
on: | The key that declares the triggering event(s). |
jobs: | The key listing the units of work. |
runs-on | Which runner OS a job uses (ubuntu-latest, etc.). |
needs: | Declares that a job must wait for another to succeed. |
steps: | The ordered list of instructions in a job. |
run: | A step that executes a shell command. |
uses: | A step that runs a reusable action. |
with: | Inputs (arguments) passed to an action. |
| Artifact | A file bundle saved from a job, downloadable by later jobs (or you). |
| cron | The time-schedule syntax used by schedule: triggers. |
Previous: ← Overview · Next: Advanced + Dissecting This Site →