# Your First Pipeline (GitHub Actions)

> What a GitHub Actions workflow actually is - events trigger jobs made of steps that run on a runner - then a real ci.yml decoded line by line, plus caching, matrices, secrets, and required checks.


---

# Your First Pipeline (GitHub Actions)

You've seen the green check mark next to a pull request. You've also seen the red X - usually right when you were sure your change was fine - and felt that small drop in your stomach. Somewhere behind those marks, a machine you've never met ran your tests and made a verdict. This guide is about that machine and the file that tells it what to do.

By the end you'll have a real `.github/workflows/ci.yml` you understand line by line, the mental model to *reason* about any workflow you meet, and enough of the advanced moves - caching, matrices, secrets - to make CI fast and trustworthy instead of a mysterious gatekeeper.

📝 **Terminology.** **CI** is *continuous integration*: every time you push code, an automated build runs your checks (tests, linters, type-checks) so problems surface in minutes, not in someone else's afternoon. GitHub Actions is GitHub's built-in way to run CI (and more). If "what is CI even for" is fuzzy, read [What CI/CD Does](/guides/what-cicd-does) first, then come back here for the *how*.

## How to read this

- **Want it to finally make sense?** Read in order. Phase 1 installs the mental model, Phase 2 builds a real workflow on top of it, Phase 3 makes it fast and safe.
- **Already have a workflow and just need one concept?** Jump straight to [Phase 3: Beyond the Basics](03-beyond-the-basics.md) for caching, matrices, and secrets.

## The phases

1. **[The Anatomy of a Workflow](01-anatomy-of-a-workflow.md)** - the mental model: events trigger workflows, made of jobs, made of steps, that run on a runner. The YAML structure decoded so it stops looking like magic.
2. **[Building It Up](02-building-it-up.md)** - a real `ci.yml` that checks out your code, sets up your language, installs dependencies, and runs your tests - every line explained, with both a passing and a failing run log.
3. **[Beyond the Basics](03-beyond-the-basics.md)** - caching dependencies for speed, a build matrix to test multiple versions, secrets done safely, and required checks that block a bad merge.

> Deliberately deferred to follow-up guides: deployment (the "CD" half - shipping to a server or registry), reusable/composite workflows, and self-hosted runners. This guide gets you a solid *integration* pipeline first; shipping comes once that's rock-solid.


---

# The Anatomy of a Workflow

A workflow file looks intimidating the first time: a wall of indented YAML with words like `on`, `jobs`, `runs-on`, `uses`, `steps`. The instinct is to copy one from Stack Overflow, change a line until the check goes green, and never look at it again - that works right up until it breaks, and then you're editing a config you don't understand, at the worst possible moment.

So before a single line of YAML, let's install the mental model. There are really only five ideas. Once they click, every workflow you ever read is just those five ideas in a slightly different arrangement.

## The five ideas, top to bottom

**What it actually is.** A GitHub Actions pipeline is a chain of nested things, each living inside the one above it:

```mermaid
flowchart TD
  Event[Event<br/>you pushed; someone opened a PR] -->|triggers| Workflow[Workflow<br/>one .yml in .github/workflows/]
  Workflow --> Job1[Job<br/>own fresh machine]
  Workflow --> Job2[Job<br/>runs in parallel]
  Job1 --> Step1[Step: command or action]
  Job1 --> Step2[Step: command or action]
  Job1 -->|runs on| Runner[Runner<br/>throwaway machine, e.g. ubuntu-latest]
```

Read it as a sentence: *an **event** triggers a **workflow**, which contains one or more **jobs**, each of which runs a list of **steps** on a fresh **runner**.* That's the whole model. Now let's give each word a real definition.

## 1. The event - what wakes the pipeline up

**What it actually is.** An event is a thing that happens in your repository: a push, a pull request being opened, a tag being created, a scheduled time arriving, or someone clicking a "Run" button. The event is the *trigger* - nothing runs until one fires.

**What it does in real life.** In the YAML, the event lives under the key `on`. The two you'll use constantly:

```yaml
on:
  push:
    branches: [main]
  pull_request:
```

*What just happened:* You told GitHub two things: run this workflow whenever someone pushes commits to `main`, and run it whenever someone opens or updates a pull request (against any branch). That `pull_request` trigger is the one producing the green check or red X you see on PRs - the pipeline runs against the proposed change *before* anyone merges it.

📝 **Terminology.** `push` and `pull_request` are *event types*. There are many (`schedule`, `workflow_dispatch` for a manual button, `release`, and more), but these two cover the everyday "test my code when it changes" job.

## 2. The workflow - the file itself

**What it actually is.** A workflow is one YAML file living in the special folder `.github/workflows/`. GitHub watches that folder; any `.yml` or `.yaml` file in it is a workflow it will run when the matching event fires.

**Why people get this wrong.** People assume the *filename* matters, or that there's one magic workflow per repo. Neither is true - you can have ten workflow files (`ci.yml`, `lint.yml`, `deploy.yml`), each independent with its own `on:` triggers. The `name:` field at the top is just the label you see in the Actions tab; the filename is yours to choose.

```yaml
name: CI
on:
  push:
    branches: [main]
  pull_request:
```

*What just happened:* This is the top of a workflow file. `name: CI` is what shows up in GitHub's Actions tab. The `on:` block (from idea 1) says when it runs. Everything after this will be the actual work.

## 3. The job - a unit of work on its own machine

**What it actually is.** A job is a named group of steps that run together on *one fresh machine*. The most important and most surprising fact about a job:

💡 **Key point.** Every job starts on a brand-new, empty machine that is thrown away when the job finishes. Nothing you do in one job survives to the next unless you explicitly pass it along. The runner has no memory of your last run, your laptop, or any other job.

**Why people get this wrong.** Newcomers assume the runner is "their computer in the cloud" with their code already on it. It isn't - it's a blank Ubuntu (or Windows, or macOS) box with common tools pre-installed and *nothing of yours*. That's exactly why the first step in almost every job is "check out my code": you have to fetch it onto the empty machine. (That step is the star of Phase 2.)

**What it does in real life.** A job declares which kind of machine it wants with `runs-on`:

```yaml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - ...
```

*What just happened:* You declared one job, named `test` (the name is yours to pick). `runs-on: ubuntu-latest` asks GitHub for a fresh Ubuntu Linux machine - that's the **runner**. The `steps:` list (next) is the actual work this job does.

📝 **Terminology.** A **runner** is the machine that executes a job. GitHub-hosted runners (`ubuntu-latest`, `windows-latest`, `macos-latest`) are free for public repos and metered for private ones. Each is spun up clean for your job and destroyed after.

When you have multiple jobs, they run **in parallel** by default - each on its own separate machine. That's great for speed (lint and test at the same time) but it's the second half of why jobs can't see each other's files: they're literally different computers running at once.

## 4. The step - one thing, done in order

**What it actually is.** A step is a single unit inside a job. Steps run *one at a time, top to bottom*, on the same machine. A step is one of exactly two things:

- **A `run:` step** - a shell command you write yourself, e.g. `run: npm test`.
- **A `uses:` step** - a prebuilt, shareable action someone published, e.g. `uses: actions/checkout@v4`.

```yaml
    steps:
      - uses: actions/checkout@v4          # a prebuilt action
      - run: echo "Hello from the runner"  # a shell command
```

*What just happened:* Two steps, run in order on the same runner. The first *uses* a published action (we'll meet `actions/checkout` properly next phase - it copies your repo onto the machine). The second *runs* a plain shell command. Mix and match these two kinds and you can describe almost any pipeline.

📝 **Terminology.** An **action** (singular) is a reusable, packaged step - like a function someone else wrote that you call with `uses:`. "GitHub Actions" (the product) is the system that runs them. Yes, the naming is confusing; everyone trips on it once.

## 5. Putting the words together

Here's the smallest complete, valid workflow - all five ideas in one file:

```yaml
name: CI
on: [push]                        # EVENT: run on every push
jobs:                             # the WORKFLOW's jobs
  greet:                          # one JOB named "greet"
    runs-on: ubuntu-latest        # its RUNNER
    steps:                        # its STEPS, in order
      - run: echo "Pipeline is alive"
```

*What just happened:* On every push, GitHub provisions a fresh Ubuntu runner, runs the single job `greet`, which runs its single step: printing a line. It's useless work, but it's a *real* pipeline - and you can now name every part of it. Everything in Phase 2 is this same skeleton with more useful steps hung on it.

## ⚠️ The gotcha that bites everyone: indentation

YAML decides structure entirely by indentation, using **spaces, never tabs**. A step nested one level too shallow or one level too deep is a *different meaning* to YAML, and the error message you get is rarely "your indentation is wrong" - it's something cryptic about an unexpected key.

```text
jobs:
  test:                  ← 2 spaces:  "test" is a job
    runs-on: ubuntu-latest   ← 4 spaces:  a setting OF the test job
    steps:                   ← 4 spaces:  also a setting OF the test job
      - run: npm test        ← 6 spaces:  an item IN the steps list
```

The rule of thumb: each level of nesting is **two more spaces** than its parent, and a list item starts with `- ` (dash, space). Configure your editor to show whitespace and to insert spaces when you press Tab. We'll hit this again in Phase 2, because it's the single most common reason a brand-new workflow won't even start.

## Recap

1. An **event** (push, pull_request, …) under `on:` triggers everything.
2. A **workflow** is one YAML file in `.github/workflows/`.
3. A **job** runs on a fresh, throwaway **runner** and remembers nothing.
4. **Steps** run in order on that machine; each is a `run:` command or a `uses:` action.
5. Jobs run in parallel by default; YAML structure is set by **space** indentation.

You can now read any workflow as "event → jobs → steps on a runner." Next, we'll build a real one that actually tests your project.


---

# Building It Up

With the mental model in hand - event → jobs → steps on a runner - let's build a workflow that earns its place: one that actually runs your test suite on every push and pull request. We'll write it one step at a time, explaining what each line *means*, not just what to type. Then we'll read a passing run and a failing run, because reading the log is half the skill.

The example uses a Node.js project, because it's the most common starting point and the steps are short. The *shape* is identical for any language - only the "set up the language" and "install dependencies" steps change, and we'll note the swaps as we go.

## The whole file first

Here's the complete `.github/workflows/ci.yml`. Don't worry about the details yet - we'll take it apart line by line right after. Seeing the destination first makes the pieces easier to place.

```yaml
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Check out the code
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test
```

*What just happened:* That's a real, working CI pipeline. On every push to `main` and every pull request, GitHub spins up a fresh Ubuntu runner and runs four steps in order: get the code, install Node, install the project's packages, run the tests. Tests pass and the job goes green; any step exits with an error and the job goes red and stops. Now let's understand each step well enough that you could have written it yourself.

## The header - when and where (lines 1–10)

```yaml
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
```

*What just happened:* This is everything from Phase 1, now doing real work. `name: CI` is the label in the Actions tab. The `on:` block triggers on pushes to `main` and on any pull request. `jobs:` opens the list of jobs; `test:` is our one job; `runs-on: ubuntu-latest` asks for a fresh Ubuntu runner. The four steps below all run on that one machine, in order.

## Step 1 - check out the code (the one nobody can skip)

```yaml
      - name: Check out the code
        uses: actions/checkout@v4
```

**What it actually is.** Remember from Phase 1: the runner starts *empty*. It does not have your repository on it. `actions/checkout` is the official action whose entire job is to clone your repo onto the runner and switch it to the exact commit being tested.

**Why people get this wrong.** This is the number-one "why is my workflow failing" mystery for beginners. They write `run: npm test` as the first step, the runner says `package.json not found`, and they're baffled - *the file is right there in my repo!* It's there in your repo, yes, but not yet on this blank machine. Without a checkout step, the runner has nothing of yours to test.

*What just happened:* `uses: actions/checkout@v4` runs the version-4 release of the official checkout action. The `@v4` part pins which version you want - always pin to a major version like `@v4` so a future breaking change to the action doesn't silently alter your pipeline. After this step, your project's files exist on the runner and everything after it can see them. The `name:` line is optional but makes the log readable; without it the step is labeled with the raw `uses:` value.

📝 **Terminology.** `actions/checkout` reads as *owner/repo* on GitHub - it's an action published in the `actions` organization. `with:` (which the next step uses) passes inputs *to* an action, like arguments to a function.

## Step 2 - set up the language

```yaml
      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"
```

**What it actually is.** The runner comes with *some* version of Node already, but you don't want to depend on whatever happens to be there - it can change without warning. `actions/setup-node` installs the exact Node version you ask for and puts it on the runner's `PATH`, so the commands you run next use *that* version.

*What just happened:* This installs Node.js 20 on the runner and makes `node` and `npm` point to it. The `with:` block passes the input `node-version: "20"` to the action - quote the version so YAML reads it as text, not a number (an unquoted `20.10` would be misread). This is the step that changes per language: Python uses `actions/setup-python` with `python-version`, Go uses `actions/setup-go`, and so on. Same idea every time - pin the language version so your CI is reproducible.

⚠️ **Gotcha.** Pin a real, specific major version (`"20"`), not a moving target you don't control. If you let the language version drift, a test that passes today can fail next month for reasons that have nothing to do with your code - and chasing *that* down is a genuinely bad afternoon.

## Step 3 - install dependencies

```yaml
      - name: Install dependencies
        run: npm ci
```

**What it actually is.** This is a `run:` step - a plain shell command, not an action. `npm ci` ("clean install") installs the project's dependencies *exactly* as locked in your `package-lock.json`, deleting any existing `node_modules` first.

**Why `npm ci` and not `npm install`?** `npm install` can quietly *update* your lockfile to newer versions that satisfy your ranges. In CI you want the opposite: install precisely what the lockfile says, every time, so the build is reproducible and a surprise dependency bump can't break a run. `npm ci` fails loudly if the lockfile and `package.json` disagree - exactly the safety you want here. (For Python this step might be `pip install -r requirements.txt`; for Go, `go mod download` - same principle: install pinned dependencies deterministically.)

*What just happened:* The runner downloaded and installed every package your project needs, matching the lockfile. After this step, `node_modules/` exists on the runner and your tests have everything they need to run.

## Step 4 - run the tests

```yaml
      - name: Run tests
        run: npm test
```

**What it actually is.** Another `run:` step. `npm test` executes whatever you've defined under `"test"` in your `package.json` scripts - your actual test command.

💡 **Key point.** A step succeeds or fails based on its **exit code**: `0` means success, anything else means failure. Test runners follow this convention - they exit non-zero when a test fails. That's the entire mechanism behind the green check and red X: GitHub just watches whether each step exited `0`. If `npm test` exits non-zero, this step fails, the job stops, and the run goes red.

*What just happened:* The runner ran your test suite. Every test passing means the command exited `0`, the step went green, and - since it's the last step - the whole job went green. Any test failing means the command exited non-zero, this step (and the job) went red, and any steps after it would have been skipped.

## Reading a passing run

After you commit this file and push, open the **Actions** tab on GitHub, click the run, then the `test` job. You'll see something like this:

```console
Set up job
✓ Check out the code
✓ Set up Node.js
✓ Install dependencies
✓ Run tests

  > my-project@1.0.0 test
  > vitest run

   ✓ src/math.test.js (3 tests) 8ms

   Test Files  1 passed (1)
        Tests  3 passed (3)

Post Run actions/checkout@v4
Complete job
```

*What just happened:* Each step ran in order and got a green check. The `Run tests` step expanded to show your test runner's own output - 3 tests, all passing. `Set up job` and `Complete job` are GitHub provisioning and tearing down the runner around your steps. A clean run like this is what produces the green check mark on your commit or PR.

## Reading a failing run - the more useful skill

Green runs teach you nothing. The day that matters is when it's red. Here's the same workflow when one test breaks:

```console
Set up job
✓ Check out the code
✓ Set up Node.js
✓ Install dependencies
✗ Run tests

  > my-project@1.0.0 test
  > vitest run

   ❯ src/math.test.js (3 tests | 1 failed) 11ms
     ✓ adds two numbers
     ✗ subtracts two numbers
       → expected 1 to be 2

   Test Files  1 failed (1)
        Tests  1 failed | 2 passed (3)

Error: Process completed with exit code 1.
Complete job
```

*What just happened:* The first three steps passed, so your code checked out, Node installed, and dependencies installed fine - the problem is in the code itself. `Run tests` is the one with the red ✗. Read it top-down: the test `subtracts two numbers` failed, `expected 1 to be 2`. The crucial last line - `Error: Process completed with exit code 1` - is GitHub telling you the command exited non-zero, which is *why* the step is red.

The discipline that saves you: **find the first red step and read its output, ignore everything after.** A later step being skipped or red is usually just a consequence of the first failure. Ninety percent of "debugging CI" is scrolling to the first ✗ and reading the actual error underneath it - which, almost always, is your test runner telling you exactly what's wrong.

⚠️ **Gotcha.** If the *checkout* or *install* step is the red one (not your tests), the failure is about the environment, not your code: a bad lockfile, a private dependency the runner can't reach, or a wrong language version. The fix lives in the workflow or your dependencies, not in your test files. Knowing *which* step went red tells you *where* to look.

## Recap

1. **`actions/checkout@v4`** clones your repo onto the empty runner - without it, nothing works.
2. **`actions/setup-node@v4` (`with: node-version`)** installs a pinned language version; swap for `setup-python`, `setup-go`, etc.
3. **`npm ci`** installs dependencies deterministically from the lockfile - reproducible, unlike `npm install`.
4. **`npm test`** runs your suite; a non-zero **exit code** is what turns a step (and the run) red.
5. To debug a red run, **find the first red step and read its output** - that's where the truth is.

You now have a working pipeline and can read its verdicts. Next, we make it fast and trustworthy: caching, testing multiple versions, secrets, and blocking bad merges.


---

# Beyond the Basics

The pipeline from Phase 2 works, but left as-is it has two everyday frustrations and one real risk. The frustration: it re-downloads every dependency from scratch on every run, which is slow, and it only tests one language version, letting version-specific bugs slip through. The risk: a red pipeline doesn't actually *stop* anyone from merging unless you tell GitHub to enforce it.

This phase fixes all three, and adds the one thing every real project eventually needs - a way to use a password or API token in CI without leaking it. Each piece is a small, self-contained addition to the same `ci.yml` you already understand.

## Caching - stop re-downloading the same packages

**What it actually is.** Every run starts on a fresh runner, so `npm ci` downloads all your dependencies from scratch - every time. A **cache** lets the runner save those downloaded packages after one run and restore them at the start of the next, skipping the slow download when nothing changed.

**What it does in real life.** For the common languages, the `setup-*` actions have caching built in - you don't even need a separate step. Add one input:

```yaml
      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
```

*What just happened:* Adding `cache: "npm"` tells the setup action to cache npm's download store, keyed on your `package-lock.json`. If the lockfile hasn't changed on the next run, it restores the cache instead of re-downloading, so `npm ci` is much faster. Change a dependency and the lockfile changes, the key changes, and it correctly fetches fresh. (The same input exists as `cache: "pip"` on `setup-python`, and `setup-go` caches by default.)

💡 **Key point.** A cache is a *speed optimization, never a correctness dependency*. Your pipeline must produce the same result whether the cache hit or missed - caching only changes how *fast* a step runs, not *what* it does. If a run ever behaves differently because of a cache, something is wrong.

⚠️ **Gotcha.** Don't reach for the lower-level `actions/cache` action to hand-roll dependency caching until the built-in `cache:` input genuinely can't cover your case. The built-in version handles the cache key (the lockfile hash) for you; a hand-rolled key that's too loose will serve stale packages, and one that's too tight never hits. Start with the built-in.

## A build matrix - test several versions at once

**What it actually is.** Suppose your project must work on Node 18, 20, and 22. You could write three near-identical jobs - tedious and easy to let drift. A **matrix** is a way to say "run this one job once per value in a list," and GitHub generates the copies for you, running them in parallel.

**What it does in real life.**

```yaml
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: ["18", "20", "22"]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: "npm"
      - run: npm ci
      - run: npm test
```

*What just happened:* `strategy.matrix.node-version` lists three versions. GitHub expands this one job into three parallel jobs - one per version - each on its own runner. The expression `${{ matrix.node-version }}` is how a step reads the current value; in the first copy it's `"18"`, in the second `"20"`, in the third `"22"`. You wrote the job once; you got three real test runs.

```mermaid
flowchart TD
  Job[one job<br/>test, matrix: node 18, 20, 22] --> N18[node 18]
  Job --> N20[node 20]
  Job --> N22[node 22]
```

📝 **Terminology.** `${{ ... }}` is GitHub Actions **expression** syntax - a small templating language for reading context like `matrix`, `github`, and (next) `secrets`. Anything inside the double braces is evaluated by GitHub before the step runs.

The matrix view in the Actions tab now shows three results. If only Node 18 fails, you've learned something precise and valuable: your code relies on something newer, and you know it *before* a user on Node 18 finds out for you.

## Secrets - passwords your pipeline needs but must never leak

**What it actually is.** Sometimes a CI step needs a credential - a token to publish a package, an API key for an integration test. You can't write that token into `ci.yml`, because the file is in your repo for anyone with access to read. A **secret** is an encrypted value you store in GitHub's settings; the workflow can use it at run time, but it's never written in your code.

**What it does in real life.** You add the secret once in the repo UI (Settings → Secrets and variables → Actions → New repository secret), then read it in the workflow with the `secrets` context:

```yaml
      - name: Run integration tests
        run: npm run test:integration
        env:
          API_TOKEN: ${{ secrets.API_TOKEN }}
```

*What just happened:* `${{ secrets.API_TOKEN }}` pulls the stored secret and hands it to the step as an environment variable named `API_TOKEN`. Your test code reads it from the environment (e.g. `process.env.API_TOKEN`), exactly as it would read any env var - but the value lives encrypted in GitHub, not in the repo.

⚠️ **Gotcha - secrets are masked in logs, but don't tempt fate.** GitHub automatically redacts known secret values in the run log, replacing them with `***`. That's a safety net, not a license - **never deliberately print a secret**: no `echo $API_TOKEN`, no dumping it for debugging. Masking only catches the *exact* stored value; if your code transforms it (base64-encodes it, embeds it in a URL, splits it across lines), the transformed form can slip through unredacted. Treat a secret like a live wire: use it, never look directly at it.

⚠️ **Gotcha - secrets don't flow to fork pull requests.** For security, workflows triggered by a `pull_request` from a *forked* repo do **not** receive your secrets - otherwise any stranger could open a PR that exfiltrates your tokens. If your integration tests need a secret, they'll be skipped or empty on fork PRs by design; that's the system protecting you, not a bug. For a fuller walkthrough of storing and rotating credentials, see [Secrets Management](/guides/secrets-management).

## Required checks - make red actually mean "stop"

**What it actually is.** Here's the surprise that catches teams off guard: by default, a red pipeline is *advisory*. The X shows up on the PR, but GitHub will still happily let someone click Merge. To make CI a real gate, turn on a **branch protection rule** (or **ruleset**) that marks your CI check as **required** - then merging is blocked until it's green.

**What it does in real life.** This is a repository setting, not YAML. In Settings → Branches (or Settings → Rules → Rulesets), add a rule for your `main` branch and enable *"Require status checks to pass before merging,"* then select your CI check (it appears in the list by name once it has run at least once).

```mermaid
flowchart LR
  PR[PR opened] --> CI[CI runs]
  CI -->|green| Enabled[Merge enabled]
  CI -->|red| Blocked[Merge blocked, with a reason]
```

*What just happened:* You connected the pipeline's verdict to the merge button. Now a red run physically prevents the merge, and a contributor sees *why* they're blocked. This is the moment CI stops being decorative and starts protecting `main` - the entire point of running tests on every PR.

⚠️ **Gotcha - required check names come from the job, not the workflow.** The check you select is named after the *job* (and, in a matrix, each generated variant - e.g. `test (18)`, `test (20)`, `test (22)`). If you later rename a job, its required-check entry effectively disappears and the protection silently stops applying to it. After renaming a job, revisit the branch protection rule and re-select the check.

## Recap

1. **Caching** (`cache: "npm"` on the setup action) skips re-downloading unchanged dependencies - speed only, never correctness.
2. **A matrix** (`strategy.matrix`) runs one job once per version in parallel; read the value with `${{ matrix.* }}`.
3. **Secrets** (`${{ secrets.NAME }}`) inject credentials safely; they're masked in logs, but never print them, and they don't reach fork PRs.
4. **Required checks** (branch protection) turn a red run into an actual merge block - without them, CI is only advice.
5. Required-check names track the **job name**, so renaming a job can quietly disable its protection.

That's a complete, fast, trustworthy CI pipeline - and, more importantly, you understand every line of it. The natural next step is the *CD* half: taking a green build and shipping it. That's a guide of its own, because deployment carries its own set of "oh no" moments worth treating with the same care.

**Related:** [What CI/CD Does](/guides/what-cicd-does) · [Testing in CI](/guides/testing-in-ci) · [Secrets Management](/guides/secrets-management)
