Reference

What are a workflow, a job, and a step in GitHub Actions?

A workflow holds jobs, a job holds steps, and a step does one thing. What each is, where it lives, what env and defaults set around them, what the editor calls each one, and what gets you in trouble.

A workflow holds a job, and the job holds three steps, checkout, run tests, and deploy.

🧭 The short answer

A workflow is a file in .github/workflows that says when to run and what to do. It holds jobs, and each job gets its own machine. A job holds steps, and a step does one thing: a command you write, or an action someone else wrote.

Two more keys set values around those three. env names variables, defaults names the shell and folder a command starts in. Five keys, and that is the whole file.

📄 Workflow

  • It is a file in .github/workflows that says when to run and what to do. One file is one workflow, and a repository can hold as many as it likes.
  • Reach for it when something should happen without anyone typing: on a push, on a label, every fifteen minutes.
  • Lives at the top of the file. Everything else on this page sits inside it.
name: Ship the oldest issue

on:
  schedule:
    - cron: "7,22,37,52 * * * *"

jobs:
  ship:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - run: echo "one job, one step, four times an hour"
  • Gets you in trouble when
    • the file sits anywhere but straight under .github/workflows. A subfolder is not read, and nothing says so.
    • it has no on. A workflow nothing triggers never runs, and the Actions tab stays quiet about it.
    • you edit a schedule on a branch. A scheduled run reads the default branch only, every time.
  • In the editor it is the card at the top of the Visual tab, This workflow, with its triggers under When it runs.

🧱 Job

  • It is a batch of steps that runs on one machine. Jobs run at the same time unless you say otherwise.
  • Reach for it when work wants its own machine, its own permissions, or has to wait for other work: build, then test, then deploy.
  • Lives under jobs, named by the key you give it. That key is its id, and it is what needs points at.
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: npm run build
  test:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: npm test
  • Gets you in trouble when
    • two jobs expect to share a disk. Each gets a fresh machine, so build leaves nothing for test to find. Hand things over with an artifact or an output.
    • you assume they run in the order written. They start together; only needs makes one wait.
    • one fails. Every job that needs it is skipped, unless its if says otherwise.
  • In the editor each job is a card under What it does, headed by its name; Runs on is the machine, Waits for is needs.

👣 Step

  • It is one thing done in order: a command you write, or an action someone else wrote.
  • Reach for it when the job has a next thing to do: check out the code, install, build, test. One thing per step keeps the log readable.
  • Lives in a job's steps list, so each one starts with a dash.
steps:
  - uses: actions/checkout@v7
  - name: Test
    run: npm test
  • Gets you in trouble when
    • one fails. The steps after it do not run and the job goes red, unless a step says continue-on-error or if: always().
    • a step needs a file an earlier step made, and the two are in different jobs. Steps share a machine; jobs do not.
    • a step has both run and uses. It is one or the other, and GitHub refuses the file.
  • In the editor steps are the numbered rail under Steps, in order on each job card; each one Runs a command or Uses an action.

🏷️ env

  • It is a set of values every step can read, set on the workflow, a job, or a step. The closest one wins.
  • Reach for it when a value is used in more than one place, or should be said once at the top: a version, a folder, a flag.
  • Lives at any of the three levels. A command reads one as $NAME; an expression reads it as env.NAME.
env:
  NODE_VERSION: "22"

jobs:
  test:
    runs-on: ubuntu-latest
    env:
      NODE_VERSION: "20"
    steps:
      - run: echo "$NODE_VERSION"
  • Gets you in trouble when
    • a job sets a name the workflow already set. The job's value wins, quietly, and you spend an hour debugging the workflow's.
    • you paste a real password into it. The file lives in the repository, so now the password does too. Point at a secret instead, as ${{ secrets.NAME }}.
    • you expect a value to reach the next job. It does not; every job starts with its own env.
  • In the editor it is the Environment variables card for the workflow, and the section of the same name on a job or a step.

⚙️ defaults

  • It is what a step gets when it says nothing: which shell, and which folder to start in.
  • Reach for it when every command in the file should run in the same folder or under the same shell, so no step has to repeat it.
  • Lives on the workflow or on a job, under defaults.run. A step naming its own shell or folder wins.
defaults:
  run:
    shell: bash
    working-directory: src/web
  • Gets you in trouble when
    • the folder does not exist until a step creates it. Every run step starts there, the first one included.
    • you expect it to reach a uses step. It touches run steps only; an action does not read it.
    • a command names no shell and you assumed bash. On a Windows runner that is PowerShell, and the syntax is not the same.
  • In the editor it is What every command here runs under on the This workflow card; a command step carries the same two fields.

📚 Straight from GitHub