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-checkWhen 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-checkWhat this demonstrates:
- Three checks run in sequence: ESLint, Prettier format check, and TypeScript type check
npm ciinstalls exact versions from lock file (faster and deterministic)cache: "npm"cachesnode_modulesbetween runs for speedconcurrencycancels in-progress runs when new commits are pushedtimeout-minutesprevents 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 lintrunsnext lint, which exits with code 1 if there are errorsprettier --checkexits with code 1 if any file is not formatted correctlytsc --noEmitexits 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-checkWith 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-lockfileBranch 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(notnpm 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
.eslintignoreor theignoresarray ineslint.config.mjsand.prettierignore. -
tsc is slow in CI — TypeScript type-checking can take 30 seconds or more on large projects. Fix: Enable
"incremental": trueintsconfig.jsonand cache the.tsbuildinfofile between CI runs. Alternatively, use@vercel/next-swcwhich 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.prettierrcand 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
mainand require the CI job to pass.
Alternatives
Other ways to solve the same problem — and when each is the better choice.
| Alternative | Use When | Don't Use When |
|---|---|---|
| Husky + lint-staged | You want to catch issues before they reach CI | You need a CI safety net (use both) |
| GitLab CI / CircleCI | You are not on GitHub | You are on GitHub (Actions is native) |
trunk check | You want a unified CI tool that manages linters for you | You want full control over your CI pipeline |
| Vercel deployment checks | You only care about build-time errors | You 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 ciinstalls exact versions from the lock file (deterministic).- It deletes
node_modulesfirst for a clean install. - It is faster than
npm installin 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 ciinstall. - 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 installlocally may resolve different versions thannpm ciin 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@v1GitHub 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
tsccan.
How do I speed up tsc in CI?
- Enable
"incremental": trueintsconfig.json. - Cache the
.tsbuildinfofile 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
ignoresarray ineslint.config.mjs. - Add them to
.prettierignoreas well. - Common examples: Prisma client, GraphQL types, migration files.
Related
- ESLint Setup for Next.js — ESLint configuration that CI runs
- Prettier Setup — Prettier configuration for format checks
- TypeScript Strict Mode — tsconfig options that
tsc --noEmitenforces - Husky & lint-staged — pre-commit hooks complement CI
- ESLint + Prettier Integration — ensure consistent behavior between local and CI