GitHub Actions: workflows, jobs, steps
A workflow is a YAML file under .github/workflows triggered by on: events. It holds jobs that run in parallel on fresh runner VMs; each job has steps that either use an action or run a shell command.
A developer opens a pull request and watches the Actions tab. Tests pass. They merge — and the deploy job, which built its own copy of the app in a different runner, fails because the artifact it expected was never produced: the test job built it in its VM and that VM was thrown away the moment the job ended. Nothing was shared. The model is unforgiving about this: every job is a clean machine that knows nothing about the others unless you wire them together. Learn the four nouns — workflow, job, step, runner — and most CI confusion evaporates.
The four nouns: workflow, job, step, runner
Everything in GitHub Actions is one of four things. A workflow is a single YAML file living under .github/workflows/ in your repository — ci.yml, deploy.yml, one file per workflow. A workflow contains one or more jobs. Each job runs on a runner: a virtual machine (GitHub-hosted ubuntu-latest, or your own self-hosted box) that is provisioned fresh for that job and destroyed when it finishes. Inside a job is an ordered list of steps, and a step does exactly one of two things — it either uses: a published action or run:s a shell command.
The trigger sits at the top. The on: key declares which events start the workflow. The four you reach for constantly: push (a commit landed on a branch), pull_request (a PR was opened or updated), workflow_dispatch (a human clicked “Run workflow” — a manual button), and schedule (a cron expression, e.g. nightly). One workflow can listen to several events at once.
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch: # manual run button
schedule:
- cron: '0 3 * * *' # nightly at 03:00 UTC
jobs:
test:
runs-on: ubuntu-latest # the runner: a fresh VM
steps:
- uses: actions/checkout@v4 # an action: clone the repo
- uses: actions/setup-node@v4 # an action: install Node
with:
node-version: 22
cache: npm
- run: npm ci # a shell command
- run: npm testThat is a complete, real CI workflow: on every push to main and every PR, spin up a fresh Ubuntu VM, check out the code, install Node 22, install dependencies deterministically with npm ci, and run the tests. actions/checkout and actions/setup-node are the canonical first two steps of almost every Node workflow — the runner starts empty, so you must explicitly clone your own repository and install your toolchain before anything else can run.
Jobs are parallel and isolated by default
This is the rule that surprises people. List two jobs in one workflow and they run at the same time, on separate runners. GitHub does not serialize them for you. That is a feature — your lint, type-check, and test jobs finish in the time of the slowest one, not their sum — but it means no job can see another’s filesystem, environment, or output. Each runner is a clean VM that ends the moment its job does.
To impose order, use needs:. A job with needs: [test] will not start until test succeeds, turning the flat set of jobs into a directed acyclic graph (DAG). This is how you express “lint and test in parallel, then deploy only if both pass.”
jobs:
lint:
runs-on: ubuntu-latest
steps: [{ uses: actions/checkout@v4 }, { run: npm run lint }]
test:
runs-on: ubuntu-latest
steps: [{ uses: actions/checkout@v4 }, { run: npm test }]
deploy:
needs: [lint, test] # waits for both; runs only if both pass
runs-on: ubuntu-latest
steps: [{ run: ./deploy.sh }]Because each job is its own VM, you cannot just hand a file from one job to the next. If build produces a dist/ folder that deploy needs, the build job must upload it with actions/upload-artifact and the deploy job must download it with actions/download-artifact. Likewise, the per-runner cache (actions/cache, or the cache: shortcut on setup-node) persists things like node_modules across runs over time — it is not a channel for passing data between jobs in the same run. Artifacts move data sideways within a run; cache moves data forward across runs.
▸Why this works
Why does ${{ github.sha }} work but bare github.sha does not? GitHub Actions has its own expression language, evaluated before the runner executes anything. ${{ }} is the marker that says “evaluate this expression and substitute the result.” Inside it you read contexts — structured objects like github (event metadata: github.sha, github.ref, github.actor), env, secrets, matrix, and runner. Outside the braces, github.sha is just literal text. So run: echo ${{ github.sha }} prints the commit hash, while run: echo github.sha prints the words. Contexts are how a workflow learns what triggered it and adapts.
The flow of one run
When an event fires, GitHub reads the matching workflow file, builds the job graph from needs:, and dispatches each ready job to a runner. Inside each runner, steps execute top to bottom, and the first failing step (non-zero exit) fails the job unless you opt out. Here is the whole chain end to end.
| Noun | What it is | Analogy |
|---|---|---|
workflow | One YAML file in .github/workflows/, triggered by on: events | The recipe book opened by an occasion (a push, a schedule) |
job | A unit of work; parallel by default, ordered with needs: | One dish — several cook at once unless one waits on another |
step | One action (uses:) or one shell command (run:), in order | A single instruction in the recipe |
runner | The fresh VM (runs-on:) a job executes on, destroyed after | A disposable kitchen, scrubbed and discarded each dish |
▸Why this works
Senior hygiene that turns a working workflow into a safe one. Pin third-party actions to a full commit SHA, not a moving tag: uses: some/action@a1b2c3d… is immutable, whereas @v3 can be re-pointed by whoever owns the tag — a supply-chain hole. Set least-privilege permissions:, ideally read-only by default and elevated per job only where needed, so a compromised step can’t push to your repo with the auto-provided GITHUB_TOKEN. Add concurrency: with cancel-in-progress so a new push cancels the now-stale run on the same branch instead of wasting a runner on superseded code.
A workflow has a build job and a deploy job. deploy fails because the dist/ folder build produced is missing. Why?
You need a step that clones your repository onto the fresh runner before tests can read the code. Pick the right mechanism.
- 01Define workflow, job, step, and runner, and explain how a single run flows through all four.
- 02Why do jobs run in parallel, what does needs: change, and why can't you just share files between jobs?
GitHub Actions reduces to four nouns. A workflow is a single YAML file under .github/workflows/, started by the events you list under on: — push, pull_request, the manual workflow_dispatch, or a schedule cron. A workflow holds jobs, and jobs run in parallel by default, each on its own runner: a fresh virtual machine named by runs-on: that is created for the job and destroyed when it finishes. A job is an ordered list of steps, where every step either uses: a published action — actions/checkout and actions/setup-node are the canonical first two, because the runner starts empty — or run:s a shell command. A minimal CI is exactly that: checkout, set up the toolchain, install with npm ci, run the tests. Two consequences define the model. First, jobs are isolated: use needs: to order them into a DAG, and move data with artifacts (sideways within a run) or cache (forward across runs), never by assuming a shared disk. Second, workflows read their own context through the expression syntax ${{ github.sha }}, which the engine evaluates before steps run. Add senior hygiene — pin actions to a commit SHA, grant least-privilege permissions:, and cancel superseded runs with concurrency: — and a working pipeline becomes a safe one. Now when you see a deploy job fail with “no such file,” your first question is whether an artifact upload/download is missing between jobs — not whether the build step ran.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.