ESLint + Jest Quality Gates in GitHub Actions: A Complete Setup Guide
ESLint catches code style and quality issues; Jest enforces behavior through tests. Combined in GitHub Actions quality gates, they block merges when code doesn't meet your team's standards. This guide covers the complete setup — from configuration to PR annotations and coverage enforcement.
Why Quality Gates, Not Just Checks
Running ESLint and Jest in CI is common. Quality gates are different: they block the merge, not just notify. A warning that doesn't block gets ignored. A check that blocks gets fixed.
A quality gate setup:
- Fails the CI job on lint errors (not warnings)
- Fails the CI job when coverage drops below threshold
- Posts inline annotations on PR diffs for lint errors
- Shows coverage diff (new coverage vs. previous)
- Blocks PR merges via branch protection rules
ESLint Configuration
Base ESLint Setup
npm install --save-dev eslint @eslint/js eslint-config-prettierModern ESLint (v9+) uses flat config (eslint.config.js):
// eslint.config.js
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
prettier, // Disables formatting rules that conflict with Prettier
{
rules: {
// Error-level rules (block CI)
'no-console': 'error',
'no-unused-vars': 'error',
'no-debugger': 'error',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-non-null-assertion': 'error',
// Warning-level rules (don't block CI, but show in PR)
'prefer-const': 'warn',
'no-var': 'warn',
}
},
{
// Different rules for test files
files: ['**/*.test.ts', '**/*.spec.ts'],
rules: {
'no-console': 'off', // Allow console.log in tests
'@typescript-eslint/no-explicit-any': 'warn', // Less strict in tests
}
},
{
// Ignore generated and vendor files
ignores: [
'dist/**',
'coverage/**',
'node_modules/**',
'**/*.generated.ts'
]
}
);Configuring ESLint to Fail on Errors Only
// package.json
{
"scripts": {
"lint": "eslint src --ext .ts,.tsx",
"lint:ci": "eslint src --ext .ts,.tsx --max-warnings 0",
"lint:fix": "eslint src --ext .ts,.tsx --fix"
}
}--max-warnings 0 makes ESLint exit with code 1 if there are any warnings, ensuring both errors and warnings fail CI. Adjust to match your team's standards — some teams allow warnings, others don't.
Jest Configuration with Coverage Thresholds
Jest Setup
npm install --save-dev jest @types/jest ts-jest// jest.config.js
export default {
preset: 'ts-jest',
testEnvironment: 'node',
// Coverage configuration
collectCoverage: true,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'json-summary'],
// Coverage thresholds — fail CI if below these
coverageThreshold: {
global: {
branches: 75,
functions: 80,
lines: 80,
statements: 80
},
// Per-file thresholds for critical modules
'./src/auth/**/*.ts': {
branches: 90,
functions: 95,
lines: 95,
statements: 95
}
},
// Collect coverage from all source files, not just tested ones
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/*.test.ts',
'!src/generated/**'
]
};When coverage drops below the thresholds, Jest exits with code 1:
FAIL Coverage threshold for branches (75%) not met: 72%Running Jest in CI Mode
{
"scripts": {
"test:ci": "jest --ci --runInBand --forceExit"
}
}--ci mode:
- Fails immediately on missing snapshots instead of writing them
- Doesn't use the watch mode cache
- Better suited for CI environments
GitHub Actions Workflow
name: Quality Gates
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
lint:
name: ESLint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint:ci
# Adds inline annotations to the PR diff
- name: Annotate ESLint results
if: failure() && github.event_name == 'pull_request'
uses: ataylorme/eslint-annotate-action@v3
with:
report-json: eslint-report.json
# Requires: "lint:ci": "eslint src --format json --output-file eslint-report.json"
test:
name: Jest Tests + Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests with coverage
run: npm run test:ci
- name: Upload coverage report
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/
# Post coverage comment to PR
- name: Coverage Report
uses: davelosert/vitest-coverage-report-action@v2
if: github.event_name == 'pull_request'
with:
json-summary-path: coverage/coverage-summary.json
json-final-path: coverage/coverage-final.json
quality-summary:
name: Quality Gate Summary
runs-on: ubuntu-latest
needs: [lint, test]
if: always()
steps:
- name: Quality Gate Status
run: |
if [[ "${{ needs.lint.result }}" == "failure" || "${{ needs.test.result }}" == "failure" ]]; then
echo "❌ Quality gate failed"
echo "Lint: ${{ needs.lint.result }}"
echo "Tests: ${{ needs.test.result }}"
exit 1
else
echo "✅ All quality gates passed"
fiPR Coverage Comments
Add coverage comparison to each PR. Install jest-coverage-comment:
- name: Add coverage comment to PR
uses: MishaKav/jest-coverage-comment@main
if: github.event_name == 'pull_request'
with:
coverage-summary-path: ./coverage/coverage-summary.json
title: Test Coverage
badge-title: Coverage
hide-comment: false
create-new-comment: false
junitxml-path: ./junit.xml
junitxml-title: Test ResultsThis adds a comment to every PR showing:
- Overall coverage percentage
- Coverage change vs. base branch
- Per-file coverage breakdown for changed files
ESLint Inline PR Annotations
For ESLint to post inline annotations on the PR diff, change the output format:
{
"scripts": {
"lint:ci": "eslint src --format @microsoft/eslint-formatter-sarif --output-file eslint-results.sarif || true"
}
}- name: Upload ESLint results
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: eslint-results.sarif
category: eslintSARIF format integrates with GitHub Code Scanning, placing lint errors directly in the Files Changed tab of the PR.
Handling Pre-existing Lint Errors
When enabling ESLint on an existing codebase with hundreds of errors, don't block CI immediately. Use a phased approach:
Phase 1: Warn only (0 new issues policy)
# Save the current error baseline
npx eslint src --format json > .eslint-baseline.json
<span class="hljs-comment"># In CI: only fail if new issues appear beyond baseline
npx eslint-baseline-check --baseline .eslint-baseline.json srcPhase 2: Rule-by-rule enforcement
Enable the most impactful rules first, suppress the rest:
// Gradually enable rules in eslint.config.js
rules: {
'no-unused-vars': 'error', // Week 1: enforce
'@typescript-eslint/no-any': 'warn', // Week 3: will become error
}Phase 3: Full enforcement
After clearing the backlog (using autofix or scheduled cleanup sprints), set all rules to error and maintain zero-tolerance.
TypeScript Strict Mode as a Quality Gate
TypeScript's strict mode is the highest-impact quality gate for TypeScript projects:
// tsconfig.json
{
"compilerOptions": {
"strict": true, // Enables all strict checks
"noUncheckedIndexedAccess": true, // Beyond strict
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}# Type checking as a separate CI step (faster feedback than running tests)
- name: TypeScript type check
run: npx tsc --noEmitTypeScript errors fail the CI before tests even run — the fastest possible feedback for type issues.
Branch Protection Configuration
Enable quality gates in GitHub repository settings:
- Settings → Branches → Add rule
- Branch name pattern:
main - Enable:
- "Require status checks to pass before merging"
- Required checks:
ESLint,Jest Tests + Coverage,TypeScript type check - "Require branches to be up to date before merging"
- Enable "Restrict who can push to matching branches"
With this configuration, PRs cannot be merged if any quality gate fails.
Summary
ESLint + Jest quality gates in GitHub Actions work as a hard enforcement mechanism:
- ESLint blocks on errors; use
--max-warnings 0to also block on warnings - Jest coverage thresholds fail CI when coverage drops —
coverageThresholdin jest.config.js - TypeScript strict mode catches type errors before runtime
- PR annotations put lint errors and coverage data directly in the PR diff
- Branch protection makes the checks non-bypassable
The key principle: quality checks that don't block get ignored. Configure them to block, and teams fix issues before merging rather than accumulating debt.