GitHub Actions primitives
The GitHub Actions workflow, job, and step model behind a CI gate, built up to one runnable typecheck workflow.
In the previous chapter you wrote a branch-protection ruleset that blocks a merge until a set of status checks pass: typecheck, lint, test, build.
But naming a check doesn’t make it exist.
Nothing in the repository runs those commands and reports back under those names, so the gate enforces nothing: GitHub looks for four checks, finds none, and either waves the merge through or waits forever for results that never arrive.
A status check is produced by a GitHub Actions workflow.
This lesson covers the smallest slice of GitHub Actions you need to read a JavaScript project’s CI workflow, write one from scratch, and tell a toy workflow from a production-shaped one.
We stop at a single job that runs pnpm typecheck; the next lesson grows it into the four-check gate.
Workflows, jobs, steps, and runners
Section titled “Workflows, jobs, steps, and runners”GitHub Actions has four nouns. Once you have all four and how they relate, the rest is detail.
A workflow is a YAML file in the .github/workflows/ directory of your repo.
The convention is one concern per file: ci.yml for the pull-request gate you’re building now, deploy.yml for shipping to production later.
These files are version-controlled and reviewed in pull requests like any other code, which is what you want for a change to how your project is tested.
A workflow contains one or more jobs, and each job runs on its own runner : a clean Ubuntu virtual machine requested with runs-on: ubuntu-latest, with nothing carried over from any other job.
Fresh checkout, no installed dependencies, no leftover files.
This is why every job starts by cloning the repo and installing packages again, which looks wasteful but is isolation: if jobs shared a machine, one could lean on a file or an install another left behind, and break the day someone ran them in a different order.
The clean VM rules that out by forcing every job to declare everything it needs.
Jobs in a workflow run in parallel by default.
Drop three jobs into a file with no further instruction and GitHub starts all three at once on three separate machines.
When you need one job to wait for another, say a deploy that must not start until a build has succeeded, you add needs: to express that dependency.
Most CI jobs are independent, so parallelize by default and reach for needs: only when there’s a true dependency.
A job is an ordered list of steps, run top to bottom on that one runner.
Order matters: you can’t install dependencies before you’ve checked out the code.
A step is one of two things, never both: a run:, a shell command, the same thing you’d type in a terminal, or a uses:, a packaged, reusable unit called an action referenced by a path, like actions/checkout.
Because uses: pulls in someone else’s code, it’s where a supply-chain risk enters, which we return to near the end of the lesson.
Here is the sentence the rest of the chapter depends on: each job’s identifier becomes a status check on the pull request, and that check’s name is exactly what your ruleset matches against.
Name a job typecheck and GitHub reports a status check called typecheck.
That string is the contract.
Rename the job and the check vanishes: the ruleset goes looking for typecheck, can’t find it, and your gate silently stops protecting that case.
The branch-protection rule and the workflow are two halves of one mechanism, joined by a name.
- 1 checkout
- 2 setup
- 3 install
- 4 run
- 1 checkout
- 2 setup
- 3 install
- 4 run
The two job boxes sit side by side because they run at the same time. The steps inside each are stacked because they run in that order. Each box is its own machine, so nothing leaks between them, and the pill on each box is the name that lands on the pull request as a status check.
The parallel-versus-sequential rule is one you apply, not one you recite, so drill it.
Decide whether each pair of jobs runs at the same time or one after the other. Drag each item into the bucket it belongs to, then press Check.
test job and a lint job, neither mentioning the otherneeds: key on eitherdeploy job that declares needs: [build]needs: arrayWhich events trigger a workflow
Section titled “Which events trigger a workflow”A workflow does nothing until an event triggers it.
The on: key declares which events wake it up.
GitHub fires events for almost everything that happens in a repo, but a web app leans on five, and the CI gate uses two of them.
on: pushfires on any push to any branch.on: pull_requestfires when a pull request is opened, updated with new commits, or reopened. The “updated” event is namedsynchronize, worth knowing because it shows up in logs and docs and isn’t self-explanatory.on: schedulefires on a cron timetable. A later lesson’s weekly link-check uses this.on: workflow_dispatchadds a manual “Run workflow” button in the repo’s Actions tab, for jobs you fire by hand.on: workflow_callmakes a workflow callable from another workflow, useful at organization scale and ignorable for a single repo.
The CI gate pairs pull_request with push restricted to main.
The first runs the gate on every pull request, which is the whole point.
The second reruns it on the merge to main, and that one is worth explaining.
A pull request is tested against the state of main at the moment it was opened, and main can move underneath it.
Two pull requests can each pass in isolation yet break main once both land: one adds a call to a function the other just deleted, and no rebase warned anyone because neither change conflicts at the text level.
Running the gate again on the merge verifies the post-merge state, the code that will actually ship.
The pull_request run protects the branch; the push run protects main.
Reading a minimal workflow line by line
Section titled “Reading a minimal workflow line by line”Here’s a complete, runnable workflow, about twenty lines. It triggers on the pair we just discussed, locks down permissions, cancels superseded runs, and runs a single job that checks out the code, sets up pnpm and Node with caching, installs dependencies, and runs the type-checker.
name: CIon: pull_request: push: branches: [main]permissions: contents: readconcurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: truejobs: typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheckname: is the workflow’s display label in the Actions UI.
The on: block is the trigger pair from the previous section: every pull request, plus every push to main.
This is the “test the branch, then test main after the merge” pattern made literal.
name: CIon: pull_request: push: branches: [main]permissions: contents: readconcurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: truejobs: typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheckpermissions: contents: read sets the workflow’s token to read-only, the least-privilege floor.
A security reflex, covered in its own section below.
name: CIon: pull_request: push: branches: [main]permissions: contents: readconcurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: truejobs: typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheckThe concurrency: block cancels any in-progress run of this workflow on the same branch when a newer run starts.
Another reflex, covered below.
name: CIon: pull_request: push: branches: [main]permissions: contents: readconcurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: truejobs: typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheckjobs: opens the map, typecheck: is the one job, and runs-on: ubuntu-latest is its fresh runner.
typecheck is the job id, and therefore the exact status-check name the ruleset from the previous chapter matches against.
name: CIon: pull_request: push: branches: [main]permissions: contents: readconcurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: truejobs: typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheckactions/checkout@v6 clones the repo onto the runner at the commit under test, so the following steps have code to work with.
Without it the machine is empty.
name: CIon: pull_request: push: branches: [main]permissions: contents: readconcurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: truejobs: typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheckpnpm/action-setup@v4 installs pnpm, and actions/setup-node@v6 installs Node 24 and, via cache: pnpm, wires the dependency cache.
Order matters: the pnpm action must come before setup-node.
Caching is covered below.
name: CIon: pull_request: push: branches: [main]permissions: contents: readconcurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: truejobs: typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheckpnpm install --frozen-lockfile installs dependencies strictly from the committed lockfile.
Its own section explains the bug this prevents.
name: CIon: pull_request: push: branches: [main]permissions: contents: readconcurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: truejobs: typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheckpnpm typecheck is the command that is this job: it maps to the project’s tsc --noEmit script.
This command produces the green or red typecheck status check.
In the next lesson this single typecheck job grows into a four-job gate running typecheck, lint, test, and build in parallel, each shaped exactly like this one.
Two pieces deserve a closer look on their own: the trio of setup actions every JavaScript project copies into every workflow, and the two ${{ ... }} expressions in the concurrency block.
The three actions every JavaScript CI uses
Section titled “The three actions every JavaScript CI uses”Three actions appear in essentially every JavaScript workflow, in this order: actions/checkout, pnpm/action-setup, and actions/setup-node.
You’ll copy this block into every workflow you write, so understand it rather than paste it.
actions/checkout clones your repository onto the runner at the exact commit being tested.
The runner starts empty, so without checkout there’s no code and every later step has nothing to act on.
Current major: @v6.
pnpm/action-setup installs pnpm itself.
Notice there’s no version: input in the worked file, and that’s deliberate.
With no version specified, the action reads it from the packageManager field in your package.json, the same field that already pins pnpm for everyone on the team.
The pnpm version then lives in exactly one place and the workflow inherits it.
Hardcode a version in the workflow and you get a second source of truth to keep in sync, and two sources of truth drift.
Current major: @v4.
actions/setup-node installs Node, pinned with node-version: 24 to match the runtime your project runs on, and cache: pnpm wires up the built-in dependency cache keyed on your pnpm-lock.yaml.
That cache has a subtlety on v6 that the next section covers; for now, note that cache: pnpm is doing real work.
Current major: @v6.
One rule causes the most common setup bug: pnpm/action-setup must come before actions/setup-node.
The cache: pnpm integration in setup-node shells out to the pnpm command to find the store directory it caches, so pnpm has to be on the runner’s PATH before setup-node runs.
Reverse the two and the cache step fails with an error about a missing pnpm binary.
Pinning these by major version is fine: they’re first-party (actions/*) and trusted (pnpm/*) utilities, and the job holds no secrets.
The calculus changes for actions that can read secrets, which the security section covers.
Order the steps of a job's setup block so the dependency cache works. Drag the items into the correct order, then press Check.
actions/checkout pnpm/action-setup actions/setup-node pnpm install --frozen-lockfile Cache the pnpm store with setup-node
Section titled “Cache the pnpm store with setup-node”A workflow with no caching reinstalls every dependency from scratch on every run. For a real web app that’s roughly an eighty-second install on every push and pull request, multiplied across your whole team. Slow CI gets routed around: when the gate takes too long, people start merging without waiting for it, and a gate nobody waits for protects nothing. Fast feedback is what keeps the gate respected, so caching is the first thing you reach for.
The cache: pnpm input on setup-node handles this.
It caches pnpm’s content-addressable store, keyed on the hash of your pnpm-lock.yaml: it restores the cache before the install step and saves any new packages back after.
Change a dependency and the lockfile hash changes, so you get a fresh cache automatically and never run against a stale one.
As rough ballparks: a cold install runs around eighty seconds, a warm cache around thirty.
There’s one gotcha.
As of setup-node@v6, dependency caching auto-enables only for npm; for pnpm and yarn you have to opt in with the cache: input.
So cache: pnpm is not boilerplate you can drop.
Omit it and you silently get zero caching and pay the cold-install cost on every run, with no error to tell you.
A workflow copied from an older example written for setup-node v5, which auto-cached, regresses the moment it runs on v6.
One more reflex: don’t hand-roll caching.
There’s a general-purpose actions/cache action, and the temptation when you first learn it is to wire up a manual cache step for the pnpm store.
Resist it.
The built-in handles the common case correctly, while a hand-rolled cache with a slightly wrong key ships green CI on broken code.
Reach for manual actions/cache only when the built-in genuinely doesn’t cover your case, which is rare.
Why CI installs with --frozen-lockfile
Section titled “Why CI installs with --frozen-lockfile”Without --frozen-lockfile, when your package.json and pnpm-lock.yaml disagree, pnpm resolves a new dependency tree and rewrites the lockfile to match.
They disagree whenever someone bumps a dependency in package.json but forgets to commit the regenerated lockfile.
CI installs that fresh tree, tests against it, and passes, but the tree it tested is not the one committed to main.
You’ve shipped a green build that proves nothing about the code that will run in production: the canonical “works in CI, breaks in prod” failure.
--frozen-lockfile makes that impossible.
It installs strictly from pnpm-lock.yaml and refuses to mutate it.
When package.json and the lockfile disagree, the install fails loudly instead of silently inventing a new tree.
That is exactly what you want from CI: the dependency tree under test is guaranteed to equal the one that’s committed.
pnpm already enables frozen mode automatically when it detects the CI=true environment variable, so why write the flag?
To make the intent visible.
Anyone reading the workflow sees the install is frozen, and it behaves identically whether CI runs it or you run it by hand.
Your project already commits pnpm-lock.yaml; this wires the existing “CI runs --frozen-lockfile” convention into the machine that enforces it.
A teammate bumps a dependency’s version in package.json but forgets to commit the regenerated pnpm-lock.yaml. The CI install step runs without --frozen-lockfile. What happens?
package.json and the lockfile disagree.package.json, rewrites the lockfile in the runner, and proceeds. CI goes green — but it tested a dependency tree that was never committed. The loud abort (the first option) is exactly what --frozen-lockfile would have given you.Least-privilege permissions
Section titled “Least-privilege permissions”Every workflow run is handed an auto-provisioned token called the GITHUB_TOKEN , scoped to your repository.
Steps use it to talk to the GitHub API: to post a comment, push a tag, or create a release.
Historically its default grant was broad write access to the repo.
That means any step, including one inside a third-party action you pulled in with uses:, can use the token to push code, cut a release, or open a pull request.
A single compromised dependency in your CI inherits write access to your repository.
The fix is least privilege, as a rule: set the floor at the workflow level, raise it per job, never the reverse.
At the top of the file you write permissions: contents: read, the minimum a check needs to clone your code.
A typecheck, lint, test, or build job needs nothing more.
If one job needs to do more, say post a comment on the pull request, that job gets pull-requests: write, and only that job.
You never start broad and trim down; you start at the floor and grant up, one scope at a time, only where a job proves it needs it.
This is the permissions: contents: read block from the worked file.
Setting it explicitly does a second thing: it overrides whatever default your organization or repository settings impose, so the workflow documents its own privilege level instead of inheriting a setting someone changed elsewhere.
permissions: contents: read Cancelling superseded runs with concurrency
Section titled “Cancelling superseded runs with concurrency”A developer pushes a typo fix seconds after the first push to a pull request. Without concurrency control, both pushes kick off a full CI run and both run to completion. The first run is now burning a runner on obsolete code, the queue backs up behind two runs where one would do, and your CI minutes drain into superseded commits. Across a busy team, a meaningful slice of your CI budget goes to testing commits that were replaced seconds after they landed.
The concurrency: block fixes this, and you already have it in the worked file:
concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: trueThe group is a string that buckets runs together; when a new run lands in a group that already has one in progress, cancel-in-progress: true kills the older one.
The trick is choosing the key so that runs for the same branch share a bucket.
That’s what ${{ github.workflow }}-${{ github.ref }} does: github.workflow is this workflow’s name, and github.ref is the branch or pull-request ref that triggered the run.
A new push to a branch cancels the in-flight run for that branch, while runs for other branches are untouched.
Set this on every pull-request-triggered workflow.
One caveat, and it’s why this isn’t a blanket “always cancel” rule.
Once a workflow also runs production deploys, cancel-in-progress: true becomes dangerous: a fast follow-up push could cancel a deploy mid-flight, leaving production half-shipped.
With deploys in the picture you scope concurrency more tightly, so it cancels stale test runs but never interrupts a deploy.
We’ll handle that when we wire up deploys in the next chapter; for a pure CI workflow like this one, cancel freely.
Secrets, and pinning the actions that use them
Section titled “Secrets, and pinning the actions that use them”The chapter’s security thread comes due here, across two topics that are really one risk surface: an action you trust with your secrets can turn on you if its code changes underneath you.
A secret is a value you don’t want in your source code, such as a database URL or an API key.
Secrets live in your repo’s settings, under Secrets and variables → Actions.
You read one with ${{ secrets.NAME }} and inject it into a step as an environment variable:
- run: pnpm db:migrate env: DATABASE_URL: ${{ secrets.DATABASE_URL }}The typecheck job in the worked file needs no secrets, because type-checking touches no external service.
Every secret you hand a job is attack surface, so don’t grant one to a job that doesn’t use it.
And mind your logs: GitHub automatically masks known secret values, but it can’t mask a value it doesn’t recognize.
Echo a derived value, such as a JWT you signed with the secret or a connection string with the password embedded, and the secret leaks into the logs in plain sight.
For cloud credentials, like deploying to Vercel or assuming an AWS role, the modern move is to skip long-lived secrets entirely in favor of OIDC -issued short-lived tokens; the next chapter wires that up.
When you write uses: some/action@v1 or uses: some/action@main, that @v1 or @main is a moving reference: a tag or branch is just a pointer, and pointers can be repointed.
If an attacker takes over that action’s repository and force-pushes the tag to malicious code, every workflow that references it by that tag runs the attacker’s code on its next run, with whatever secrets and permissions that workflow holds.
The reference in your file didn’t change; the code it resolves to did.
In March 2025 the widely-used tj-actions/changed-files action was compromised, catalogued as CVE-2025-30066.
An attacker force-pushed every version tag, v1 through v45.0.7, to point at a single malicious commit that dumped the CI runner’s memory into the workflow logs.
Over twenty-three thousand repositories referenced the action by a mutable tag, and each leaked whatever secrets were in scope into logs the attacker could read.
The attack rewrote tags, so a repo pinned to a major tag like @v45 was hit just as hard as one pinned to @main: the tag itself was the thing that moved.
The rule is a gradient, not a single line:
- uses: tj-actions/changed-files@mainRuns whatever the ref points at today. A force-push to main or to a version tag silently swaps in new code on your next run. This is exactly how CVE-2025-30066 reached 23,000 repos.
- uses: actions/checkout@v6Readable, and fine for trusted first-party utility actions that touch no secrets. With nothing in the job to steal, the convenience wins, which is why the worked file pins checkout and setup-node this way.
- uses: tj-actions/changed-files@a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0Immutable, and required for any action that can read secrets or runs with elevated permissions. A SHA names one exact commit that can never be repointed, and it’s the only pin that would have stopped the tj-actions attack.
Pin trusted first-party utility actions (actions/*, pnpm/*) by major tag when they touch no secrets, the way the worked file pins checkout@v6 and setup-node@v6 in the secret-free typecheck job.
Pin any action that can read a secret or runs with elevated permissions by full 40-character commit SHA, the only reference an attacker can’t move; the same checkout and setup-node would get SHA-pinned the moment they joined a deploy job holding production credentials.
SHA pins don’t auto-update, so keeping them current is the trade-off, and that’s a job for Dependabot, set up in a later lesson.
Expressions: ${{ ... }}
Section titled “Expressions: ${{ ... }}”You’ve seen ${{ ... }} a few times now, in the concurrency group and in a secret reference.
It’s GitHub Actions expression interpolation: GitHub evaluates whatever’s inside before the step runs and substitutes the result.
The contexts you’ll actually reach for:
${{ github.* }}for facts about the event and repo:github.workflow,github.ref,github.sha,github.event.pull_request.number.${{ secrets.* }}for repository secrets.${{ env.* }}for environment variables defined in the workflow.${{ matrix.* }}for values from a build matrix.
You’ll meet these in if: conditions, env: values, and the concurrency group you already wrote.
The full context list is long, so look up a field when you need it.
What this workflow deliberately skips
Section titled “What this workflow deliberately skips”A few GitHub Actions features earn their place only past a certain threshold, and a single-repo web app stays below all of them. Naming them, and the threshold each crosses, means you won’t be surprised to meet them in someone else’s repo.
- Matrix strategy (
strategy: matrix:) runs a job once per combination of variables, most often the same test suite across several Node versions. That’s a library concern: a published package must work on every Node version its users run. A web app ships on one Node version and one OS, so it tests one configuration. Cut. - Reusable workflows and composite actions (
workflow_call, actions at.github/actions/) extract repeated setup into one shared reference. They pay off at roughly five or more repos that must keep their CI identical. For one repo, the inline form is clearer. Cut. - Runner image choice.
ubuntu-latestis a moving target: today it resolves to Ubuntu 24.04, but GitHub advances it, and an upgrade can occasionally break a build. You can pin toubuntu-24.04to shield against that, but pin only once an upgrade has actually burned the team. Don’t pin against a problem you haven’t had. - Self-hosted runners exist for specialized hardware or private-network access. The course runs on GitHub-hosted runners, so self-hosted is a platform-team concern. Cut.
External resources
Section titled “External resources”The official references for everything in this lesson, plus a video course covering the same ground.
The full reference for on, jobs, steps, permissions, concurrency — every key you saw in the worked file.
Every github.*, secrets.*, env.*, and matrix.* field available inside ${{ ... }}.
GitHub's own guidance on SHA-pinning, least-privilege permissions, and the GITHUB_TOKEN.
DevOps Directive's free video course — the core-features section walks the workflow/job/step model and pnpm-style caching with live runs.