Knowledgebase

Workflow, job, step, and the two kinds of variable in GitHub Actions

The three nested things every GitHub Actions workflow is made of, plus env and defaults, the two keys that set values around them. What each is, where it lives, what the editor calls it, and what gets you in trouble.

A workflow holds jobs, a job holds steps, and a step does one thing. Two more keys, env and defaults, set values around those three, and that is the whole page.

📄 Workflow

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

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

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@v6
  - 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

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"

That step prints 20. The job's value beat the workflow's, and nothing mentioned it.

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

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, behind Show every field; a command step carries the same two fields.

📚 Straight from GitHub