React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

cigithub-actionslintingtype-checkformattingautomation

Linting in CI/CD

Run ESLint, Prettier, and TypeScript type-checking in GitHub Actions to enforce code quality on every pull request.

Recipe

Quick-reference recipe card — copy-paste ready.

// package.json scripts
{
  "scripts": {
    "lint": "next lint",
    "format:check": "prettier --check .",
    "type-check": "tsc --noEmit"
  }
}
# Run all checks locally (same as CI)
npm run lint && npm run format:check && npm run type-check

When to reach for this: Every project that uses pull requests. CI is your safety net for catching issues that pre-commit hooks miss.

Working Example

# .github/workflows/code-quality.yml
name: Code Quality
 
on:
  pull_request:
    branches: [main]
  push:
    branches: [main]
 
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
 
jobs:
  quality:
    name: Lint, Format & Type Check
    runs-on: ubuntu-latest
    timeout-minutes: 10
 
    steps:
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "npm"
 
      - name: Install dependencies
        run: npm ci
 
      - name: ESLint
        run: npm run lint
 
      - name: Prettier
        run: npm run format:check
 
      - name: TypeScript
        run: npm run type-check

What this demonstrates:

  • Three checks run in sequence: ESLint, Prettier format check, and TypeScript type check
  • npm ci installs exact versions from lock file (faster and deterministic)
  • cache: "npm" caches node_modules between runs for speed
  • concurrency cancels in-progress runs when new commits are pushed
  • timeout-minutes prevents stuck jobs from running indefinitely

Deep Dive

How It Works

  • GitHub Actions runs the workflow on every pull request and push to main
  • Each step exits with a non-zero code on failure, which marks the PR check as failed
  • npm run lint runs next lint, which exits with code 1 if there are errors
  • prettier --check exits with code 1 if any file is not formatted correctly
  • tsc --noEmit exits with code 1 if there are type errors
  • Failed checks block merging when branch protection rules require them

Variations

Parallel jobs (faster for large projects):

jobs:
  lint:
    name: ESLint
    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
 
  format:
    name: Prettier
    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 format:check
 
  type-check:
    name: TypeScript
    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 type-check

With Biome (single check replaces lint + format):

- name: Biome
  run: npx biome check .

PR review comments with reviewdog:

- name: ESLint with reviewdog
  uses: reviewdog/action-eslint@v1
  with:
    github_token: ${{ secrets.GITHUB_TOKEN }}
    reporter: github-pr-review
    eslint_flags: "src/"

This posts ESLint errors as inline PR review comments on the exact lines that need fixing.

Caching for pnpm:

- name: Setup pnpm
  uses: pnpm/action-setup@v4
  with:
    version: 9
 
- name: Setup Node.js
  uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: "pnpm"
 
- name: Install dependencies
  run: pnpm install --frozen-lockfile

Branch protection setup:

GitHub repo → Settings → Branches → Branch protection rules:
  ✓ Require status checks to pass before merging
  ✓ Require branches to be up to date before merging
  Status checks: "Lint, Format & Type Check" (or individual job names)

TypeScript Notes

// tsc --noEmit checks the ENTIRE project, not just changed files.
// This is intentional — a change in one file can break types elsewhere.
 
// Example: changing a shared type
// types.ts
export type User = {
  name: string;
  email: string;
  role: "admin" | "user"; // Adding "moderator" here is safe
};
 
// But removing "admin" breaks every file that uses User.role === "admin"
// Only tsc catches this — ESLint cannot.

Gotchas

Things that will bite you. Each gotcha includes what goes wrong, why it happens, and the fix.

  • CI passes but local fails (or vice versa) — Different Node.js versions, different dependency versions, or OS-specific behavior. Fix: Pin Node.js version in CI to match local. Use npm ci (not npm install) to use exact lock file versions.

  • Lint errors on generated files — CI lints files that are generated during the build (e.g., Prisma client, GraphQL types). Fix: Add generated directories to .eslintignore or the ignores array in eslint.config.mjs and .prettierignore.

  • tsc is slow in CI — TypeScript type-checking can take 30 seconds or more on large projects. Fix: Enable "incremental": true in tsconfig.json and cache the .tsbuildinfo file between CI runs. Alternatively, use @vercel/next-swc which does type-checking faster.

  • Prettier check fails on line endings — Windows developers commit files with CRLF, CI runs on Linux with LF. Fix: Set "endOfLine": "lf" in .prettierrc and configure Git: git config --global core.autocrlf input.

  • Branch protection not enforced — Status checks only block merging if branch protection is configured. Without it, anyone can merge failing PRs. Fix: Enable branch protection rules on main and require the CI job to pass.

Alternatives

Other ways to solve the same problem — and when each is the better choice.

AlternativeUse WhenDon't Use When
Husky + lint-stagedYou want to catch issues before they reach CIYou need a CI safety net (use both)
GitLab CI / CircleCIYou are not on GitHubYou are on GitHub (Actions is native)
trunk checkYou want a unified CI tool that manages linters for youYou want full control over your CI pipeline
Vercel deployment checksYou only care about build-time errorsYou want lint and format enforcement

FAQs

Why do I need CI linting if I already have pre-commit hooks?
  • Git hooks are local only and can be bypassed with --no-verify.
  • CI environments do not execute pre-commit hooks.
  • CI is the safety net that catches what hooks miss.
What does npm ci do differently from npm install?
  • npm ci installs exact versions from the lock file (deterministic).
  • It deletes node_modules first for a clean install.
  • It is faster than npm install in CI because it skips dependency resolution.
How does the concurrency setting help?
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
  • It cancels in-progress workflow runs when new commits are pushed to the same branch.
  • This saves CI minutes and avoids stale results.
Should I run lint, format check, and type check in parallel or sequence?
  • Sequential (single job) is simpler and uses one npm ci install.
  • Parallel (separate jobs) is faster for large projects but triples the install time.
  • For most projects, sequential is sufficient.
Gotcha: Why does CI pass but local linting fails (or vice versa)?
  • Different Node.js versions between local and CI.
  • npm install locally may resolve different versions than npm ci in CI.
  • Pin the Node.js version in CI to match local, and always use npm ci.
How do I use Biome instead of ESLint + Prettier in CI?
- name: Biome
  run: npx biome check .

A single command replaces both lint and format checks.

What is reviewdog and why would I use it?
  • It posts ESLint errors as inline PR review comments on the exact lines that need fixing.
  • Developers see issues directly in the PR diff instead of reading CI logs.
  • Use the reviewdog/action-eslint@v1 GitHub Action.
Gotcha: Why does Prettier fail on line endings in CI?
  • Windows developers commit files with CRLF, but CI runs on Linux with LF.
  • Set "endOfLine": "lf" in .prettierrc.
  • Configure Git with git config --global core.autocrlf input.
How do I enforce that CI checks must pass before merging?
  • Go to GitHub repo Settings, then Branches, then Branch protection rules.
  • Enable "Require status checks to pass before merging."
  • Select your CI job name as a required check.
  • Without this, anyone can merge failing PRs.
Why does tsc --noEmit check the entire project instead of just changed files?
  • TypeScript needs full project context because a change in one file can break types elsewhere.
  • For example, removing a member from a shared type breaks every file that uses it.
  • ESLint cannot catch cross-file type errors; only tsc can.
How do I speed up tsc in CI?
  • Enable "incremental": true in tsconfig.json.
  • Cache the .tsbuildinfo file between CI runs.
  • This skips re-checking files that have not changed.
How do I prevent CI from linting generated files?
  • Add generated directories to the ignores array in eslint.config.mjs.
  • Add them to .prettierignore as well.
  • Common examples: Prisma client, GraphQL types, migration files.