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:
- Triggers —
on: pushandon: pull_requestscoped to the branches you care about, usuallymain. - Concurrency — a
concurrencygroup 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
permissionsblock. 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 declarecontents: readand 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
matrixof Node.js versions verifies the app works on every version you support, andfail-fast: falseensures one failing version does not cancel the others before you see all the results. - Caching —
actions/setup-node‘s built-incache: npmoption keys the cache to your lockfile, so dependency installation is fast without risking a stale cache masking a real dependency change. - Artifacts —
actions/upload-artifactpreserves 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
- Confirm your
package.jsonhas workinglint,test, andbuildscripts, since the workflow only orchestrates commands that must already work locally. - Commit a lockfile (
package-lock.json) if one is not already tracked;npm cirequires it and will fail without it. - Create
.github/workflows/ci.ymlwith the triggers, permissions, and jobs shown in Example 3. - Push the branch and open a pull request against
mainto see the checks run for the first time. - Open the Actions tab and confirm
lint, bothtestmatrix legs, andbuildeach appear as separate checks with their own logs. - In the repository’s branch protection settings for
main, require thelint,test (18),test (20), andbuildchecks to pass before merging. - 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(oryarn install --frozen-lockfile, orpnpm install --frozen-lockfile) in CI, never a plain install command. - Set
permissions: contents: readat 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
@v4at 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: falseon test matrices so you see every failing combination in one run instead of stopping at the first one. - Add a
concurrencygroup withcancel-in-progress: trueto stop wasting runner time on superseded commits. - Never expose secrets to workflows triggered by
pull_requestfrom forks, and be especially cautious withpull_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
- Add
--max-warnings=0to your ESLint script so any warning fails the build, then intentionally introduce a lint warning in a branch and confirm thelintcheck turns red. - Extend Example 2’s test matrix to include Node
'22'withfail-fast: false, open a pull request, and confirm three separatetestchecks appear and run concurrently. - Add branch protection on
mainrequiring thelint, bothtestmatrix checks, andbuildto pass, then open a pull request with a deliberately failing test and confirm GitHub blocks the merge button. - Add a
coverageThresholdsetting to your test configuration requiring 80% line coverage, delete a test file so coverage drops below that, and confirm thetestjob 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.
