Build the mental model
A workflow file must live in the .github/workflows/ directory of your repository, with a .yml or .yaml extension. That location is not configurable - GitHub scans that one directory and nowhere else. Put the file in workflows/ci.yml or .github/ci.yml and you get no error message, no warning, and no runs. It is the most expensive hour a beginner loses.
The structure has three layers. At the top sit name (what the Actions tab labels the workflow; if omitted GitHub shows the file path), on (which events trigger it), and jobs (the actual work). In YAML the unquoted key on: is exactly right here - some editors will suggest quoting it, and you do not need to.
Under jobs, each key you invent is a job id, such as lint or test. Every job must declare runs-on, the runner image it executes on, and steps, an ordered list. A step is either a uses step or a run step, never both. uses pulls in a reusable action that somebody else already wrote, and with supplies that action's inputs. run executes shell commands directly on the runner. name is a human-readable label that shows up as the collapsible heading in the log, and it is worth writing on anything non-obvious, because a log full of "Run npm ci" headings is hard to scan under pressure.
Here is the fact people get wrong most often: by default jobs run in parallel, each on its own fresh virtual machine. Writing one job above another in the file does not sequence them. Nothing a job installs, builds, or downloads is visible to any other job. If you want order, you must ask for it explicitly with needs.
WORKFLOW > JOBS > STEPS
-----------------------
file: .github/workflows/ci.yml
+---------------------------------------------------------------+
| name: CI on: [push, pull_request] |
| |
| jobs: |
| +--------------------------+ +--------------------------+ |
| | lint: | | test: | |
| | runs-on: ubuntu-latest | | runs-on: ubuntu-latest | |
| | steps: | | steps: | |
| | 1 uses: checkout@v4 | | 1 uses: checkout@v4 | |
| | 2 uses: setup-node@v4 | | 2 uses: setup-node@v4 | |
| | 3 run: npm ci | | 3 run: npm ci | |
| | 4 run: npm run lint | | 4 run: npm test | |
| +--------------------------+ +--------------------------+ |
| fresh VM #1 fresh VM #2 |
| |
| these two start AT THE SAME TIME and share no files |
+---------------------------------------------------------------+
uses: -> run somebody else's reusable action, inputs via with:
run: -> run shell commands on this runnerConnect it to a real scenario
The workflow below defines two jobs, lint and test. lint appears first in the file, but that does not make it run first. GitHub starts both at once and hands each its own runner. That is why checkout and setup-node are repeated in both jobs: the test job cannot see a single file the lint job produced. The duplication looks wasteful the first time you see it, and it is a direct consequence of the isolation model, not a mistake.
Look closely at the steps. uses: actions/checkout@v4 takes no inputs, so it has no with block. uses: actions/setup-node@v4 needs one, so it carries with: node-version: '20'. Quote that version. Unquoted, YAML reads 20 as a number, and something like 20.10 becomes a float that loses its trailing zero and silently asks for the wrong Node. Quote every version string.
run: steps execute directly in the runner's shell, bash on Linux images. Each step is a new shell process, so a cd in one step does not carry into the next; use working-directory on the step instead. Each step also becomes its own collapsible heading in the log, which is the practical reason to write name: on anything non-obvious - when a run fails at 2am you want to scan headings, not read raw output.
Try the working example
name: Anatomy Demo
on: [push]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- name: Lint the source
run: npm run lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- name: Run unit tests in the api package
working-directory: packages/api
run: npm test
Every push runs this workflow, which starts both jobs, lint and test, at the same time, each on its own fresh ubuntu-latest runner. In the Actions tab they appear side by side, and if one fails the other keeps running to completion. Within a job, steps run in order, and the first step that exits non-zero fails the job and skips the remaining steps in it. The test job's npm test executes inside packages/api because of working-directory, while its earlier npm ci ran at the repository root. The overall run is green only if both jobs succeed.5-minute try-it
Copy the workflow above, then delete the actions/checkout@v4 step from the lint job only. Push and watch how it fails in the Actions tab - work out why the message looks like a missing-file error rather than a configuration error. Next, add a step to the lint job that writes a file (for example run: echo hi > shared.txt) and a step to the test job that tries to read it. Predict what happens before you run it, then run it and check whether you were right.
One important caution
Saving the file outside .github/workflows/, or misspelling it as .github/workflow/ without the s. GitHub ignores it silently - no error, no run, no hint.
Assuming jobs run top to bottom and that a file built in one job exists in the next. Without needs and artifacts, the second job starts on an empty machine.
GitHub Docs - Workflow syntax for GitHub Actions — CI/CD with GitHub Actions