Set Up a CI Pipeline with GitHub Actions
Build a GitHub Actions workflow that installs dependencies, lints, and tests on every push and pull request — complete with dependency caching, a build matrix, and a live status badge.
What you'll build
You'll create a .github/workflows/ci.yml workflow that runs on every push and pull request. It checks out your code, installs dependencies, runs your linter, runs your tests across multiple Node.js versions, caches npm downloads for speed, and surfaces a green/red status badge in your README. The examples use a Node.js project, but the patterns transfer to any stack.
Prerequisites
- A GitHub repository you can push to (public or private — Actions is free for public repos and includes a monthly minutes allowance for private ones).
- Node.js 20+ locally and a project with a
package.json. Verify withnode --versionandnpm --version. - npm scripts named
lintandtest. If you don't have them yet, this tutorial includes a minimal setup. - Git configured and the repo pushed to GitHub.
No OS-specific concerns: GitHub-hosted runners execute in the cloud. Your local OS only matters for the prep steps below, which use POSIX shell syntax (macOS/Linux). On Windows, run the equivalent in Git Bash or WSL.
Step 1 — Add lint and test scripts locally
If your project already has working npm run lint and npm test, skip ahead. Otherwise, set up a minimal, real toolchain.
Install ESLint and a test runner (Vitest here, but Jest works identically):
npm install --save-dev eslint vitest
Initialize ESLint's flat config interactively:
npm init @eslint/config@latest
Then wire up scripts in package.json:
{
"scripts": {
"lint": "eslint .",
"test": "vitest run"
}
}
The key detail for CI is vitest run (not bare vitest), which runs once and exits instead of starting watch mode. Confirm both commands work and exit cleanly before touching CI:
npm run lint
npm test
Commit package-lock.json — the workflow relies on it for reproducible installs and cache keys.
Step 2 — Create the workflow file
GitHub Actions discovers any YAML file under .github/workflows/. Create the directory and file:
mkdir -p .github/workflows
touch .github/workflows/ci.yml
Paste the following into ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [20.x, 22.x]
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Test
run: npm test
Step 3 — Understand each piece
Triggers (on). This runs on pushes to main and on pull requests targeting main. Running on pull_request is what gives you the pre-merge status check; running on push to main confirms the branch stays green after merges.
runs-on: ubuntu-latest. Ubuntu runners are the fastest and cheapest. For private repos, billing multiplies by OS: Linux is 1x, Windows 2x, macOS 10x — another reason to default to Ubuntu.
Matrix. The job runs once per Node version in parallel. fail-fast: false lets every matrix leg finish even if one fails, so you see all failures instead of just the first.
actions/checkout@v4. Clones your repository into the runner. Always pin to a major version tag.
actions/setup-node@v4 with cache: npm. This installs the requested Node version and enables built-in dependency caching. It hashes your package-lock.json to build the cache key and restores ~/.npm automatically — no separate actions/cache step required. This is the modern, recommended approach.
npm ci. Use npm ci in CI, never npm install. It installs exactly what's in package-lock.json, fails if the lockfile is out of sync, and is faster and deterministic.
Step 4 — Commit and push
git add .github/workflows/ci.yml package.json package-lock.json
git commit -m "Add CI workflow"
git push origin main
The push immediately triggers the workflow.
Step 5 — Add a status badge
GitHub generates a badge URL for every workflow automatically. The format is:
https://github.com/<OWNER>/<REPO>/actions/workflows/ci.yml/badge.svg
Add it to the top of your README.md, linking to the Actions tab:
[](https://github.com/<OWNER>/<REPO>/actions/workflows/ci.yml)
Replace <OWNER> and <REPO> with your values, and use the workflow's filename (ci.yml), not its name:. By default the badge reflects the workflow status on your default branch. To pin it to a specific branch, append ?branch=main.
Verify it works
- Open your repository on GitHub and click the Actions tab. You should see a run named CI for your latest commit.
- Click into the run. You'll see two jobs —
build (20.x)andbuild (22.x)— running in parallel. Expanding either shows the Check out, Set up Node, Install, Lint, and Test steps, each with a green checkmark on success. - In the Set up Node step logs, after the first successful run you'll see cache activity (
Cache restored from key: ...) on subsequent runs, confirming caching works. - Open a test pull request. At the bottom of the PR you'll see the CI check reported as a required-or-optional status before merge.
- Refresh your repository's README — the badge should render green (
passing).
Expected tail of a healthy run:
Run npm test
> vitest run
✓ test/example.test.js (1 test)
Test Files 1 passed (1)
Tests 1 passed (1)
Troubleshooting
npm ci fails with "package-lock.json not found" or lockfile mismatch.
You either didn't commit package-lock.json or it's out of date. Run npm install locally to regenerate it, then commit the result. npm ci requires the lockfile and will refuse to run without it.
Badge shows "no status" or 404.
The path must match the workflow file name exactly: .../actions/workflows/ci.yml/badge.svg. A common mistake is using the display name (CI) instead of the filename. Also confirm the workflow has run at least once on the default branch.
Tests hang and the job times out.
You're likely running the test runner in watch mode. Ensure the script is vitest run (or jest --ci for Jest), which exits after a single pass. Watch mode never returns, so the runner waits until the 6-hour job limit.
Workflow doesn't trigger at all.
Check that the file is under .github/workflows/ (exact path), has a .yml/.yaml extension, and that your branch filters match your default branch name. If your default branch is master, update the branches: lists. YAML is indentation-sensitive — validate with Actions tab errors or a linter if the run never appears.
Next steps
- Cache build artifacts beyond npm with
actions/cache@v4for things setup-node doesn't cover (e.g., Playwright browsers, build output). - Upload coverage by adding a
vitest run --coveragestep and storing reports withactions/upload-artifact@v4. - Require the check before merge via repository Settings → Branches → Branch protection rules, marking the CI job a required status check.
- Speed up large matrices with concurrency control:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
This cancels superseded runs when you push again to the same branch.
- Add deployment in a separate workflow gated on the CI job using
needs:and environment protection rules.
With this in place, every push and pull request is automatically installed, linted, and tested — and your README advertises it.
Discussion 0
Join the discussion
Sign in with GitHub to comment and vote.
No comments yet
Be the first to weigh in.