Lesson 3 of 30
The workflow YAML vocabulary that everything else in GitHub Actions is built from — on, jobs, steps, and the load-bearing difference between uses and run.
15 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
A GitHub Actions workflow is one YAML file, and its top-level shape only ever has three keys that matter: on (what triggers it), jobs (what work it does), and inside each job, steps (the ordered list of individual actions). Everything else — matrices, caching, secrets, environments — attaches to one of these three.
The one distinction that actually trips people up is uses: vs. run:, because they look similar but do fundamentally different things:
run: executes a raw shell command on the runner, exactly like typing it into a terminal. You write the logic yourself.uses: invokes someone else's pre-packaged action — a versioned, reusable unit of automation published to the Marketplace (or written by your own org). You're not writing the logic; you're configuring theirs via with:.steps:
- uses: actions/checkout@v4
# A packaged action. Its whole job is "clone this repo's commit onto
# the runner." Nobody hand-writes git clone logic for this — it's a
# solved problem published as an action and versioned with a tag.
- uses: pnpm/action-setup@v4
with:
version: 11.5.0
# `with:` passes inputs into the action, the same way you'd pass
# arguments to a function. This action installs pnpm at exactly
# version 11.5.0 — pinned to match this repo's package.json
# "packageManager" field, so CI can never silently drift onto a
# newer pnpm than what's actually used locally.
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
# Installs Node 24, and — critically — `cache: pnpm` tells this
# action to automatically cache pnpm's store between runs, keyed off
# the lockfile. That's a whole caching strategy configured with one
# line of input, because it's implemented once inside the action.
- name: Install dependencies
run: pnpm install --frozen-lockfile
# A raw shell command. No action exists for "run this repo's own
# install command" because there's nothing generic to package —
# it's just pnpm, invoked directly.
- name: Type-check
run: pnpm exec tsc --noEmit
Every uses: line in this file names a specific version (@v4) — never a floating @latest. That's deliberate: an action's maintainer can push a new major version with breaking behavior, and pinning is what stops that from silently changing what your pipeline does between two runs of the exact same commit.
uses: and run: are mutually exclusive on the same step. A single step is either "call this packaged action" or "run this shell command" — never both. Configuration for a uses: action goes under a separate with: key, not inline in the same line.with: only exists under uses:, and its available keys are entirely defined by that specific action. version: 11.5.0 is meaningful to pnpm/action-setup because that action's own code reads a version input — it's not a generic YAML feature. You have to check each action's own docs (or its action.yml) to know what inputs it accepts.actions/setup-node@v4 is pinned, but without an explicit node-version:, it would install whatever Node version that action's current logic considers "latest" — which can change over time even though @v4 never does. This repo pins both: the action version (@v4) and the tool version (node-version: 24).Every one of these steps has to run somewhere — a specific machine with a specific OS and specific pre-installed tools. This workflow says runs-on: ubuntu-latest, but that's one choice among several, and it's the next lesson: what a runner actually is, hosted versus self-hosted, and why some teams run the exact same job across a matrix of several runners at once.