Thuta Learning
BasicDevOps & Toolsbeginner

Jobs, Steps, and Runners

What you'll walk away with

  • Explain the core ideas behind Jobs, Steps, and Runners
  • Read the diagram and trace how the pipeline flows and what gates each stage
  • Read the workflow file and predict which jobs run, in what order, and what gates them

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().

text
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

yaml
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
You should see
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 runsCI/CD with GitHub Actions

Easy traps

  • 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.
  • Validate a workflow on a branch or test repository before pointing it at a production deployment.

Exercise

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.

You'll know it worked when: 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.

Jobs, Steps, and Runners | Thuta Learning