Build the mental model
A runner is the machine that executes one job. When you write runs-on: ubuntu-latest, GitHub provisions a brand-new virtual machine for that job. It arrives with preinstalled tools - git, Node, Python, Docker - and none of your repository on it. That is why actions/checkout is not optional boilerplate but a real step that does real work. When the job finishes, that machine is destroyed with everything on it.
That one fact explains almost everything else. Two jobs live on two separate machines, so they share no filesystem whatsoever. The node_modules your build job installed does not exist for your test job, and the dist/ folder one job produced is invisible to the job that wants to deploy it. Anything that must cross a job boundary has to be handed over explicitly: artifacts for files, job outputs for small strings. Later lessons cover both; what matters now is that no correct-looking YAML will make a file appear on a machine that never had it.
When you do want order, you ask for it with needs. A job declaring needs: [lint, test] waits until both succeed before it starts. If either one fails, the dependent job is marked skipped rather than failed, which is why a broken pipeline shows one red circle and several grey ones.
Inside a single job, steps run sequentially, and the first step that exits non-zero fails the job and skips every step after it. Usually that is what you want - deploying a build that did not build is worse than deploying nothing. But sometimes a later step must run regardless, such as uploading a coverage report after failing tests. That step needs if: always().
THREE JOBS, THREE RUNNERS, ONE GATE
-----------------------------------
trigger: push to main
|
+---------------+---------------+
v v
+--------------------+ +--------------------+
| job: lint | | job: test |
| runner A | | runner B |
| fresh VM, empty | | fresh VM, empty |
| 1 checkout | | 1 checkout |
| 2 npm ci | | 2 npm ci |
| 3 npm run lint | | 3 npm test |
+--------------------+ +--------------------+
| |
+---------------+---------------+
v
both succeeded?
no | yes
skipped | v
+--------------------------+
| job: deploy |
| needs: [lint, test] |
| runner C, fresh VM |
+--------------------------+
runner A, B and C share NO files. Nothing carries over.
A destroyed VM takes node_modules, dist/ and /tmp with it.Connect it to a real scenario
The workflow below has three jobs. lint and test start together; deploy waits because of needs: [lint, test]. Notice that deploy checks out the repository again. Runner C has never seen runner A or B - it does not even have your deploy script until checkout puts it there. Beginners delete that step because it "already ran", and the job fails on a missing file.
Look at the last step of the test job. Normally, when npm test exits non-zero, every step after it is skipped - which means the coverage report would never be uploaded on exactly the runs where you most want to read it. if: always() forces that step to run whatever happened before it. Its relatives: if: failure() runs only when something already failed, which is how you post a notification, and if: success() is the implicit default.
There is also continue-on-error: true, which lets a step fail without failing its job. Treat it with suspicion: used to paper over a flaky test, it produces a pipeline that is reliably green and protects nothing.
Finally, deploy declares environment: production. If you configure required reviewers on that environment in repository settings, the job pauses and waits for a human to approve it. That single line is the concrete difference between Continuous Delivery and Continuous Deployment from lesson one.
Try the working example
name: Jobs and Runners
on:
push:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- name: Lint
run: npm run lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- name: Unit tests
run: npm test -- --coverage
- name: Upload coverage even when the tests failed
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
retention-days: 7
deploy:
needs: [lint, test]
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Deploy
run: ./scripts/deploy.sh
A push to main starts lint and test at the same time on separate runners. The deploy job starts only after both succeed; if either fails, deploy is marked skipped rather than failed. Inside test, a failing npm test fails the job, but because the upload step carries if: always() the coverage artifact is still produced and attached to the run. The deploy job declares environment: production, so if that environment has required reviewers configured, the job pauses and waits for a human approval before any step runs. Because the deploy job gets a brand-new machine, its checkout step is what puts scripts/deploy.sh on disk; without it the job fails with a file-not-found error.5-minute try-it
Add a step to the test job that writes a file (run: echo built > out.txt), and a step to the deploy job that reads it (run: cat out.txt). Since deploy already declares needs, it looks like this should work - push it and explain in your own words why it fails. Then remove if: always() from the coverage upload step, deliberately break one test, and check whether the artifact still appears on the run summary page. Put the line back and confirm the difference.
One important caution
Assuming needs also transfers files. It only orders jobs; the dependent job still starts on an empty machine and must fetch what it needs via artifacts.
Reaching for continue-on-error: true to get past a failing step. The job reports green while the check it was supposed to enforce no longer enforces anything.
GitHub Docs - Choosing where your workflow runs — CI/CD with GitHub Actions