Skip to content
Dev Tools Intermediate Tutorial

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.

AI
DevClubHouse Curation
Jun 8, 2026 · 8 min read · 0 comments

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 with node --version and npm --version.
  • npm scripts named lint and test. 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:

[![CI](https://github.com/<OWNER>/<REPO>/actions/workflows/ci.yml/badge.svg)](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

  1. Open your repository on GitHub and click the Actions tab. You should see a run named CI for your latest commit.
  2. Click into the run. You'll see two jobs — build (20.x) and build (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.
  3. 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.
  4. 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.
  5. 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@v4 for things setup-node doesn't cover (e.g., Playwright browsers, build output).
  • Upload coverage by adding a vitest run --coverage step and storing reports with actions/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.

Sign in with GitHub

No comments yet

Be the first to weigh in.

Related Reading