Linting in CI/CD
Run ESLint, Prettier, and TypeScript type-checking in GitHub Actions to enforce code quality on every pull request.
Search across all documentation pages
Run ESLint, Prettier, and TypeScript type-checking in GitHub Actions to enforce code quality on every pull request.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
# .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:
npm ci installs exact versions from lock file (faster and deterministic)cache: "npm" caches node_modules between runs for speedconcurrency cancels in-progress runs when new commits are pushedtimeout-minutes prevents stuck jobs from running indefinitelymainnpm run lint runs next lint, which exits with code 1 if there are errorsprettier --check exits with code 1 if any file is not formatted correctlytsc --noEmit exits with code 1 if there are type errorsParallel 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)
// 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.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.
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 |
--no-verify.npm ci installs exact versions from the lock file (deterministic).node_modules first for a clean install.npm install in CI because it skips dependency resolution.concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: truenpm ci install.npm install locally may resolve different versions than npm ci in CI.npm ci.- name: Biome
run: npx biome check .A single command replaces both lint and format checks.
reviewdog/action-eslint@v1 GitHub Action."endOfLine": "lf" in .prettierrc.git config --global core.autocrlf input.tsc can."incremental": true in tsconfig.json..tsbuildinfo file between CI runs.ignores array in eslint.config.mjs..prettierignore as well.tsc --noEmit enforcesReviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥