On this page
- From theory to a green checkmark
- The vocabulary you need (just five words)
- Step 1, the smallest workflow that does something
- Step 2, install, lint, and test
- Step 3, make it fast with caching
- Step 4, run independent jobs in parallel
- Step 5, make the pipeline a required status check
- Common mistakes that cost hours
- Takeaways
- Where to go next
From theory to a green checkmark
You know *what* a pipeline does, now let's build one. By the end of this article you'll have a working CI pipeline that runs on every push and every pull request, installs your dependencies, lints your code, runs your tests, and shows a green checkmark (or a red X) right in the GitHub UI. It's free, it ships with every GitHub repo, and you can have it running in ten minutes.
We'll build it up one piece at a time so nothing is a black box. Each code block is a real, working .github/workflows/*.yml file, copy them straight into a repo.
Who this is for
Anyone with a GitHub repo and basic Git who's never written a workflow file. The examples use a Node.js project (npm), but the structure is identical for Python, Go, or anything else, only the install/test commands change. If you've read [CI/CD Fundamentals](/blog/cicd-fundamentals-what-a-pipeline-does), you have all the background you need.
The vocabulary you need (just five words)
GitHub Actions has its own nouns. Learn these five and the YAML stops looking cryptic. They nest inside each other like Russian dolls.
| Term | What it is |
|---|---|
| Workflow | One .yml file in .github/workflows/. The whole automated process. |
| Event | What triggers the workflow, a push, a pull_request, a schedule. |
| Job | A group of steps that run together on one machine. Jobs can run in parallel. |
| Step | A single task in a job, run a command, or use a prebuilt action. |
| Runner | The machine a job runs on, e.g. ubuntu-latest, fresh every run. |
Put together: a workflow listens for an event, which kicks off one or more jobs, each running its steps on a runner. That's the entire model.
Step 1, the smallest workflow that does something
Create the file .github/workflows/ci.yml in your repo. The folder path matters exactly, GitHub only looks in .github/workflows/. Start with the absolute minimum: check out the code and print a line.
name: CI # shows up in the Actions tab
on: [push, pull_request] # the events that trigger it
jobs:
hello: # job id (any name)
runs-on: ubuntu-latest # the runner
steps:
- uses: actions/checkout@v4 # pull your repo onto the runner
- run: echo "Pipeline is alive!"Commit and push it. Open the Actions tab on GitHub and you'll see the run executing live. That's a working pipeline, it just doesn't do anything useful yet. Two things worth understanding now: actions/checkout@v4 is a *prebuilt action* (someone else's reusable step) that clones your repo onto the runner, and run: executes a shell command. Almost every step is one of those two shapes.
Pro tip
YAML is whitespace-sensitive, indentation is two spaces, never tabs. About half of all "my workflow won't run" problems are an indentation slip. If GitHub shows a syntax error, check your spacing first.
Step 2, install, lint, and test
Now make it real. We'll set up Node, do a clean dependency install, lint, and run tests, the three checks that should gate every change. Each is just another step.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci # clean install from the lockfile
- name: Lint
run: npm run lint
- name: Run tests
run: npm testA few deliberate choices here. We use `npm ci`, not npm install, ci installs the exact versions from your lockfile and fails if the lockfile is out of sync, which is precisely what you want in automation: reproducible, no surprises. We also narrowed the push trigger to the main branch while keeping pull_request open, so the pipeline runs on every PR and on merges to main, but not on every push to every random feature branch (saving CI minutes).
Watch out
In CI, prefer `npm ci` over `npm install`. `npm install` can quietly update your lockfile and pull different versions than your teammates have, defeating the whole point of a reproducible build. `npm ci` is stricter and faster. (Python: use `pip install -r requirements.txt` with pinned versions; the principle is the same.)
Step 3, make it fast with caching
Right now every run re-downloads all your dependencies from scratch, slow and wasteful. Caching stores them between runs so repeat builds are dramatically faster. The setup-node action has caching built in; you just turn it on.
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm" # cache ~/.npm keyed on your lockfileThat single cache: "npm" line keys the cache on your package-lock.json. As long as your dependencies don't change, the cache is reused and npm ci finishes in seconds instead of minutes. When you add or update a package, the lockfile changes, the key changes, and the cache rebuilds automatically. You never manage it by hand.
Pro tip
Slow pipelines get ignored. If CI takes longer than your coffee refill, people stop watching it and start merging on faith, which defeats the purpose. Caching is the single highest-leverage speedup for most pipelines. Turn it on early.
Step 4, run independent jobs in parallel
Linting and testing don't depend on each other, so why run them one after the other? Split them into separate jobs and GitHub runs them on separate machines simultaneously, your total wall-clock time drops to whichever is slowest, not the sum.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20", cache: "npm" }
- run: npm ci
- run: npm run lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20", cache: "npm" }
- run: npm ci
- run: npm testNow lint and test run in parallel. Each job is fully isolated, a fresh runner with nothing shared, which is why each repeats the checkout and install. That isolation is a feature: one job can't pollute another. If you ever need a job to wait for another (say, deploy *after* tests pass), you add needs: test to it, and GitHub sequences them for you.
Step 5, make the pipeline a required status check
A green checkmark is nice, but the real power move is making it *mandatory*: configure your repo so a pull request cannot be merged until CI passes. This turns your pipeline from a suggestion into a gate that protects main.
- 1
Open branch protection
In your GitHub repo: Settings → Branches → Add branch protection rule. Set the branch name pattern to main.
- 2
Require status checks
Tick "Require status checks to pass before merging," then search for and select your jobs (lint and test) from the list. They appear once they've run at least once.
- 3
Require a PR
Also tick "Require a pull request before merging" so nobody can push straight to main and skip the checks entirely.
- 4
Save and test it
Open a PR that deliberately breaks a test. GitHub now blocks the Merge button until the pipeline is green. That's CI doing its actual job.
Watch out
Without branch protection, CI is advisory, a teammate in a hurry can merge a red build. The status check is what makes the pipeline *enforced*. Set it up the moment your CI is stable; it's the difference between "we have tests" and "broken code can't reach main."
Common mistakes that cost hours
- Wrong file location. Workflows only run from
.github/workflows/. A file in.github/orworkflows/is silently ignored, no error, just nothing happens. - Tabs instead of spaces. YAML demands spaces. A single tab anywhere breaks the whole file. Configure your editor to show whitespace.
- Using `npm install` instead of `npm ci`.
installcan drift your lockfile and produce non-reproducible builds.ciis the automation-correct choice. - Hardcoding secrets in the YAML. Tokens and passwords in a workflow file land in your Git history permanently. Use repo Settings → Secrets and reference them as
${{ secrets.NAME }}. - Never enabling branch protection. A pipeline that doesn't block merges is decoration. Make it a required status check or broken code will still reach main.
- Forgetting the cache. Skipping
cache: "npm"makes every run re-download everything. People then stop waiting for slow CI, and a CI nobody watches is worthless.
Takeaways
Your first pipeline in seven lines
- A workflow lives in .github/workflows/*.yml and is triggered by events.
- Workflow → jobs → steps → runners. Jobs run in parallel by default.
- Use actions/checkout to get your code, actions/setup-node to set up the toolchain.
- Use `npm ci` (not install) for reproducible builds; add `cache: "npm"` for speed.
- Split independent work (lint, test) into separate jobs to run them in parallel.
- Use `needs:` to sequence jobs when one must wait for another.
- Enable branch protection with required status checks so red builds can't merge.
Where to go next
You have CI gating every change. The natural next steps are understanding the bigger picture you just plugged into, practicing the commands hands-on, and adding a build step that packages your app into a container.
- CI/CD Fundamentals, the full mental model your pipeline fits into.
- Practice in the CI/CD lab, run real pipeline commands in an in-browser terminal.
- Dockerfile Best Practices, package your app into a small, fast, secure image to deploy from CI.
Want to go deeper?
This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.