React Application CI Pipeline

A React application CI pipeline automatically installs dependencies, lints, tests, and builds your app every time code changes, so problems are caught before they reach reviewers or production. This lesson builds on the GitHub Actions basics you already know and focuses on the decisions that separate a toy workflow from one you can trust to gate merges: reproducible installs, parallel jobs, version matrices, caching, minimal permissions, and safe handling of untrusted pull requests.

Overview / How It Works

A CI pipeline for a React app is continuous integration, not deployment: its job is to give a fast, reliable signal about whether a change is safe to merge. It does not push anything to users. Every push and pull request triggers a workflow that checks out the code, installs dependencies from a lockfile, runs static checks (lint, type checks), runs the test suite, and produces a production build as an artifact. If any step fails, GitHub reports a failing check on the commit or pull request, and you can require that check to pass before merging.

Keeping deployment out of this workflow is deliberate. Continuous delivery would mean the pipeline also produces a release that a human approves before shipping; continuous deployment would mean it ships automatically once checks pass. Mixing deploy credentials into a build-and-test workflow that also runs against untrusted pull requests is a common source of security incidents, so this lesson stays scoped to build, test, and quality gates.

Syntax / Workflow Structure

A React CI workflow file lives at .github/workflows/ci.yml and typically has these parts:

  • Triggerson: push and on: pull_request scoped to the branches you care about, usually main.
  • Concurrency — a concurrency group that cancels a stale run when a newer commit is pushed to the same branch or pull request, so you are not paying for outdated runs.
  • Permissions — an explicit permissions block. The default token permissions vary by repository and organization settings, and a build-and-test workflow almost never needs to write to the repository, so it should declare contents: read and nothing more.
  • Jobs — separate jobs for lint, test, and build. Independent jobs run in parallel on separate runners, which shortens the pipeline compared to one long job that does everything sequentially.
  • Strategy matrix — for the test job, a matrix of Node.js versions verifies the app works on every version you support, and fail-fast: false ensures one failing version does not cancel the others before you see all the results.
  • Cachingactions/setup-node‘s built-in cache: npm option keys the cache to your lockfile, so dependency installation is fast without risking a stale cache masking a real dependency change.
  • Artifactsactions/upload-artifact preserves the build output and coverage reports so later jobs, or a human, can inspect them without re-running the pipeline.

Examples

Example 1: A Minimal React CI Workflow

Start with a single job that installs, lints, tests, and builds in sequence. This is the shape most teams begin with before splitting work into parallel jobs.

name: CI

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

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

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

      - name: Install dependencies
        run: npm ci

      - name: Lint
        run: npm run lint

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

      - name: Build production bundle
        run: npm run build

Expected behavior: on every push or pull request targeting main, one job named build runs top to bottom. If linting or tests fail, the job stops there and the build step never runs, so the check shows red with the failing step highlighted. If everything passes, the job finishes green in roughly the sum of all four steps’ durations, because they run sequentially on one runner.

Example 2: Parallel Lint and Test Jobs with a Version Matrix

Splitting lint and test into separate jobs lets them run at the same time instead of one after another, and a matrix confirms the app works across the Node.js versions you support in production.

name: CI

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

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
    strategy:
      fail-fast: false
      matrix:
        node-version: ['18', '20']
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - run: npm ci
      - run: npm test -- --ci --coverage

  build:
    needs: [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 run build
      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: production-build
          path: build/
          retention-days: 7

Expected behavior: GitHub now shows four checks — lint, test (18), test (20), and build. Lint and both test legs start immediately and run concurrently. Because build declares needs: [lint, test], it waits for all three to succeed before starting, and it is skipped entirely if any of them fails. A downloadable production-build artifact appears on the workflow run summary once build completes.

Example 3: Production-Grade Pipeline with Concurrency and Minimal Permissions

The final version adds a concurrency group to cancel superseded runs, an explicit least-privilege permissions block, and per-matrix coverage artifacts, so reviewers can download the exact coverage report for the Node version they care about.

name: React CI

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

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

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
    strategy:
      fail-fast: false
      matrix:
        node-version: ['18', '20']
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - run: npm ci
      - name: Run tests with coverage
        run: npm test -- --ci --coverage
      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        with:
          name: coverage-node-${{ matrix.node-version }}
          path: coverage/
          retention-days: 7

  build:
    needs: [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 run build
      - name: Upload production build
        uses: actions/upload-artifact@v4
        with:
          name: production-build
          path: build/
          retention-days: 7

Expected behavior: pushing a second commit to a pull request cancels the in-flight run for the previous commit instead of letting both finish, which saves runner minutes and avoids confusing, out-of-date check results. Because permissions is scoped to contents: read, the workflow’s automatic GITHUB_TOKEN cannot push commits, create releases, or write to other repositories even if a step were compromised. Notice this workflow deliberately does not upload coverage to a third-party service like Codecov with an API token. Pull requests from forks run with the same workflow file but should never have access to secrets, because the pull request author controls the code being executed. If you add a coverage-upload step later, keep it on the pull_request event (not pull_request_target) and skip the token entirely for fork PRs, or run that step only after a maintainer manually approves the workflow.

Step by Step

  1. Confirm your package.json has working lint, test, and build scripts, since the workflow only orchestrates commands that must already work locally.
  2. Commit a lockfile (package-lock.json) if one is not already tracked; npm ci requires it and will fail without it.
  3. Create .github/workflows/ci.yml with the triggers, permissions, and jobs shown in Example 3.
  4. Push the branch and open a pull request against main to see the checks run for the first time.
  5. Open the Actions tab and confirm lint, both test matrix legs, and build each appear as separate checks with their own logs.
  6. In the repository’s branch protection settings for main, require the lint, test (18), test (20), and build checks to pass before merging.
  7. Push a commit that breaks a test to confirm the pull request is blocked from merging, then fix it and confirm the checks turn green.

Common Mistakes

Mistake 1: Using npm install Instead of npm ci

npm install can update package-lock.json and resolve slightly different dependency versions than what is committed, so a CI run can pass with different packages than what a teammate has locally, or than what a later run installs. npm ci refuses to run if the lockfile and package.json are out of sync, and it always installs exactly what the lockfile specifies, which makes builds reproducible.

npm install
npm ci

Mistake 2: Omitting an Explicit permissions Block

Without a permissions key, the workflow’s GITHUB_TOKEN uses the repository or organization default, which on many repositories still grants write access to contents, issues, and pull requests. A build-and-test workflow that only reads code and produces artifacts does not need any of that. Declaring the narrowest permissions the workflow actually needs limits the damage if a dependency or action is ever compromised.

name: CI
on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test
name: CI
on: [push, pull_request]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

Best Practices

  • Always use npm ci (or yarn install --frozen-lockfile, or pnpm install --frozen-lockfile) in CI, never a plain install command.
  • Set permissions: contents: read at the workflow level, and only elevate a specific job’s permissions when that job genuinely needs to write, such as publishing a release.
  • Pin third-party actions to a specific major version tag like @v4 at minimum; for higher assurance, pin to a full commit SHA and update it deliberately, since a tag can be moved to point at different, potentially malicious code while a commit SHA cannot.
  • Split lint, test, and build into separate jobs so independent work runs in parallel and failures are easy to attribute to a specific stage.
  • Use fail-fast: false on test matrices so you see every failing combination in one run instead of stopping at the first one.
  • Add a concurrency group with cancel-in-progress: true to stop wasting runner time on superseded commits.
  • Never expose secrets to workflows triggered by pull_request from forks, and be especially cautious with pull_request_target, which runs with the base repository’s permissions and secrets even though it can check out and execute code from the fork.
  • Require the CI checks in branch protection rules so a pull request cannot merge with failing lint, tests, or build.
  • Keep the pipeline fast; slow CI gets bypassed or ignored, so prefer caching, parallel jobs, and a lean dependency set over a single long sequential job.

Practice Exercises

  1. Add --max-warnings=0 to your ESLint script so any warning fails the build, then intentionally introduce a lint warning in a branch and confirm the lint check turns red.
  2. Extend Example 2’s test matrix to include Node '22' with fail-fast: false, open a pull request, and confirm three separate test checks appear and run concurrently.
  3. Add branch protection on main requiring the lint, both test matrix checks, and build to pass, then open a pull request with a deliberately failing test and confirm GitHub blocks the merge button.
  4. Add a coverageThreshold setting to your test configuration requiring 80% line coverage, delete a test file so coverage drops below that, and confirm the test job fails even though no test itself failed.

Summary

A trustworthy React CI pipeline installs dependencies deterministically with npm ci, runs lint and tests in parallel jobs with a version matrix, builds only after those checks pass, and uploads artifacts for inspection. It declares the minimum permissions it needs, cancels superseded runs with a concurrency group, and never exposes secrets to workflows triggered by untrusted fork pull requests. None of this deploys anything; it exists purely to give a fast, reliable, gate-worthy signal that a change is safe to merge, which is the foundation the deployment lessons later in this course will build on.