Guides/GitHub ActionsGitHub Actions/Building a CI Pipeline with GitHub Actions

Building a CI Pipeline with GitHub Actions

Build a real CI pipeline in GitHub Actions: lint, test, and build on every push and PR, with caching, parallel jobs via needs, artifacts, and branch protection.


Continuous integration is the safety net that catches broken code before it reaches your main branch. The idea is simple: every time someone pushes a commit or opens a pull request, an automated pipeline runs the same checks a careful reviewer would - does it lint, does it build, do the tests pass? - and reports back in minutes. GitHub Actions gives you this for free, defined as YAML that lives in your repository. This guide builds a real CI pipeline from an empty file up to a fast, parallel, branch-protecting workflow, using a Node.js project as the concrete example. The same shapes apply to any language; only the setup steps change.

What a good CI pipeline actually does

A CI pipeline exists to give you fast, trustworthy feedback on every change. "Fast" and "trustworthy" pull against each other, and the whole craft is balancing them.

The job is to run, on every push and pull request, the checks that would otherwise be a human's job and are easy to forget: static analysis (a linter and usually a type check), a build to prove the code even compiles or bundles, and the test suite. If any of those fail, the pipeline goes red and the author knows within a couple of minutes, while the change is still fresh in their head. That immediacy is the entire value - a test suite that runs nightly catches the same bugs a day too late, after the author has moved on and three more commits have piled on top.

The second job is to be a gate. A green pipeline is the precondition for merging. Wire that up (we get to branch protection below) and CI stops being advisory and becomes the thing that keeps main releasable at all times. Everything in this guide serves those two goals: catch problems early, and refuse to let broken code merge.

Anatomy of a workflow

A GitHub Actions workflow is a YAML file in .github/workflows/. The vocabulary is small and worth getting straight before the example, because every pipeline is just these five words arranged differently:

  • workflow - the whole file. It has a name and a trigger (on:).
  • event - what starts it: a push, a pull_request, a schedule, a manual click.
  • job - a unit that runs on its own fresh virtual machine (runs-on:). Jobs run in parallel by default.
  • step - an ordered command inside a job. A step either runs a shell command (run:) or calls a reusable action (uses:).
  • action - a packaged, reusable step someone published, referenced by uses: owner/name@version (for example actions/checkout@v4).

The key structural fact: each job gets a clean, isolated machine. Nothing carries between jobs unless you deliberately pass it (via artifacts or a cache). That isolation is what lets jobs run in parallel safely, and it is why "it worked on my laptop" stops being an argument - CI starts from nothing every time.

A complete CI workflow

Here is a full pipeline that triggers on pull requests (and pushes to main), checks out the code, sets up Node, installs dependencies with caching, lints, tests, and uploads a coverage report as an artifact. Read it once top to bottom, then we will pull it apart.

name: CI

on:
  pull_request:
  push:
    branches: [main]

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

      - name: Set up Node
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"          # cache the npm download cache, keyed on package-lock.json

      - name: Install dependencies
        run: npm ci             # clean, lockfile-exact install - faster and reproducible

      - name: Lint
        run: npm run lint

      - name: Run tests with coverage
        run: npm test -- --coverage

      - name: Upload coverage report
        if: always()            # upload even if the tests failed, so you can see why
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage/
          retention-days: 7

Walking through it:

  • on: fires the workflow for every pull request against any branch, and also on direct pushes to main. Running on both means a PR is checked before merge and the post-merge state is re-checked (the merge can combine two green branches into a red result).
  • actions/checkout@v4 clones your repository into the runner. Without it the machine is empty - this is almost always step one.
  • actions/setup-node@v4 installs the Node version you ask for. The cache: "npm" line is doing real work: it restores npm's download cache keyed on your package-lock.json, so unchanged dependencies are not re-downloaded on every run.
  • npm ci installs exactly what the lockfile pins, from a clean slate. Prefer it over npm install in CI - it is faster and it fails loudly if the lockfile and package.json disagree, instead of quietly resolving new versions.
  • Lint, then test. Each run: is a shell command; a non-zero exit code fails the step and fails the job. npm test -- --coverage runs the suite and writes a coverage report to coverage/.
  • actions/upload-artifact@v4 saves that report so you can download it from the run's summary page. The if: always() is deliberate - by default a job stops at the first failed step, so without it a failing test would skip the upload exactly when you most want the report.

The Python version of this is the same skeleton with different setup steps: actions/setup-python@v5 with cache: "pip", then pip install -r requirements.txt, ruff check ., and pytest --cov. The pipeline shape does not change; the ecosystem-specific commands do.

Parallel vs sequential: jobs and needs

The workflow above puts everything in one job, so lint and test run sequentially - if lint takes 40 seconds, tests do not start until it is done. Splitting independent work into separate jobs lets them run in parallel on separate machines, which is usually faster:

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 test

lint and test now run at the same time on two machines. Total wall-clock time is the longer of the two, not the sum.

But some jobs genuinely depend on others - you should not spend money building and pushing a Docker image if the tests are red. That is what needs is for: it makes one job wait for another to succeed.

  build:
    needs: [lint, test]     # only runs if BOTH lint and test pass
    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 build

needs builds a dependency graph. Jobs with no needs start immediately and in parallel; a job with needs waits for its dependencies to finish green, then runs. If any dependency fails, the dependent job is skipped. The rule of thumb: run independent checks in parallel for speed, and use needs only where there is a real ordering constraint (build after tests pass, deploy after build succeeds).

Failing fast and protecting main

A red pipeline is only useful if it actually blocks the merge. By default GitHub will happily let you merge a PR with a failing CI run - the checks are just decoration until you turn them into a gate.

That gate is a branch protection rule (repository Settings -> Branches, or the newer Rulesets). You mark specific status checks as required, and GitHub then refuses to merge any PR into main until those checks are green. Point it at your job names (lint, test, build) and merging a broken change becomes impossible, not just discouraged. This is the single most important step to make CI mean anything - a pipeline nobody is forced to pass is a pipeline people learn to ignore.

Two related behaviors keep the feedback sharp:

  • Fail fast within a job. Steps run in order and stop at the first non-zero exit, so a lint failure never wastes time running the whole test suite. That is the default, and it is what you want.
  • Cancel superseded runs. When someone pushes three commits in a row, you do not need CI grinding through the first two. A concurrency group cancels the older, now-irrelevant runs:
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

This groups runs by branch and cancels any in-flight run when a newer commit arrives, so your runners spend their time on the code that actually matters.

Seeing results: checks and artifacts

Feedback is only useful if it is visible where the work happens. GitHub surfaces CI results in two places, and both are worth wiring up properly.

On a pull request, every job shows up as a check in the merge box - a green tick or a red cross with a direct link to the failing step's logs. That is the first thing a reviewer sees. Keep job names clear (lint, test, build) because those names are the labels people read on the PR and the exact strings you mark as required in branch protection.

For anything richer than a pass/fail, use artifacts. The coverage report we uploaded earlier appears as a downloadable file on the run's summary page, so anyone can grab the HTML report and see exactly which lines are uncovered. Test result files (JUnit XML), built binaries, screenshots from a failed browser test - all of these travel out of the ephemeral runner as artifacts. Without an artifact, everything the job produced is destroyed when its machine is torn down. Caching and artifacts are worth understanding in depth, since they are where most CI time and most debugging pain live - see Caching and artifacts for the full treatment.

Keeping CI fast

Slow CI is CI people route around - they stop waiting for it, merge on faith, or disable the required check "just this once." A pipeline that returns in two minutes gets trusted; one that takes twenty gets resented. A few levers keep it fast:

  • Cache dependencies. The biggest, easiest win. setup-node's cache: "npm" (or setup-python's cache: "pip") skips re-downloading unchanged packages, which is often the largest chunk of a run. The cache is keyed on the lockfile, so it invalidates automatically only when dependencies actually change.
  • Run jobs in parallel. As above - independent checks on separate machines cost the same but finish in the time of the slowest one, not the sum.
  • Only run what changed. For monorepos, path filters skip a workflow entirely when the change does not touch relevant files. There is no point running the frontend test suite for a docs-only edit:
on:
  pull_request:
    paths:
      - "src/**"
      - "package.json"
      - "package-lock.json"
  • Test across versions with a matrix, not copy-paste. When you need to check several runtime versions or operating systems, a matrix fans one job out into parallel variants instead of duplicating jobs by hand - and each variant runs concurrently, so covering more does not cost more wall-clock time. That pattern gets its own guide: Matrix builds.

The order to reach for these: cache first (biggest return for least effort), then parallelize, then trim what runs at all with path filters. Most slow pipelines are slow because they re-download dependencies and run everything sequentially - fix those two and you have usually cut the time in half.

The shape of it

A CI pipeline is a workflow that runs on every push and pull request and answers one question fast: is this change safe to merge? You check out the code, set up the runtime, install dependencies with a cache, and run the checks that would otherwise be forgotten - lint, build, test - failing at the first sign of trouble. Independent checks run in parallel as separate jobs; real dependencies are ordered with needs. You make it mean something by marking those jobs as required status checks so a red pipeline blocks the merge, and you make it usable by uploading artifacts for anything richer than pass/fail. Keep it fast with caching, parallelism, and path filters, and CI stops being a chore you wait on and becomes the thing that lets you merge with confidence.