Running Multiple Jobs in a Workflow
Every workflow so far has had exactly one job. That’s about to change — and the way multiple jobs behave by default is different enough from how steps behave that it’s worth working through deliberately, with a real second job rather than just a description of the concept.
Jobs Don’t Share A Runner
This is the core idea to hold onto for the rest of this chapter: every job gets its own fresh runner. Steps within a job share one machine and one filesystem, as you saw a couple of chapters back — but the moment you add a second job, that job starts from a completely empty VM, with no knowledge of anything the first job did. No checked-out code, no installed dependencies, nothing carried over automatically.
Adding A Second Job
Your hello.yml has been doing double duty — running the app and running tests in the same job. Split those responsibilities properly by adding a lint job alongside a renamed test job. Rewrite the workflow:
name: CI
on:
push:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install pytest
- name: Run tests
run: pytest
- name: Write summary
run: |
echo "### Test Run Complete" >> "$GITHUB_STEP_SUMMARY"
echo "- Python version: 3.12" >> "$GITHUB_STEP_SUMMARY"
echo "- Status: all tests passed" >> "$GITHUB_STEP_SUMMARY"
lint:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install flake8
- name: Run flake8
run: flake8 app.py test_app.pyNotice lint repeats the checkout and Python setup steps almost identically to test. That’s not an oversight — it’s the direct consequence of jobs not sharing a runner. If lint skipped the checkout step, there’d be no app.py on its machine to lint at all, for exactly the same reason a runner starts empty that you saw a few chapters back.
Push this and open the run. You’ll see two job boxes in the run’s graph, test and lint, both starting at roughly the same time.
git add .github/workflows/hello.yml
git commit -m "Split into test and lint jobs"
git pushParallel By Default
Unless you tell GitHub otherwise, every job in a workflow starts at the same time, on separate runners, and runs independently. test and lint aren’t racing each other or blocking each other — they’re two completely separate pipelines that happen to be defined in the same file and triggered by the same event.
flowchart LR
A[push event] --> B[test job<br/>own runner]
A --> C[lint job<br/>own runner]
This is usually exactly what you want: independent checks should run independently, so a slow test suite doesn’t hold up a fast lint check, and vice versa. The overall workflow isn’t considered fully successful until every job in it succeeds — if lint fails while test passes, the workflow as a whole still shows as failed, even though the two jobs never interacted.
Note
flake8 is a common Python linter — it checks code for style issues and simple bugs without actually running it, unlike pytest which executes your code’s logic. It’s a good stand-in “second, independent check” for this example precisely because it doesn’t need or care about anything test did.
When You’d Want Something Different
Parallel-by-default is the right behavior when jobs are genuinely independent, which test and lint are — either could fail without the other’s result meaning anything different. But sometimes one job’s output is meaningless without another job succeeding first, or one job depends on something the previous one produced. Parallel execution can’t express that on its own — which is exactly the gap the next chapter closes.