ESLint + Jest Quality Gates in GitHub Actions: A Complete Setup Guide

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:

  1. Fails the CI job on lint errors (not warnings)
  2. Fails the CI job when coverage drops below threshold
  3. Posts inline annotations on PR diffs for lint errors
  4. Shows coverage diff (new coverage vs. previous)
  5. Blocks PR merges via branch protection rules

ESLint Configuration

Base ESLint Setup

npm install --save-dev eslint @eslint/js eslint-config-prettier

Modern 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"
          fi

PR 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 Results

This 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: eslint

SARIF 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 src

Phase 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 --noEmit

TypeScript 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:

  1. Settings → Branches → Add rule
  2. Branch name pattern: main
  3. 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"
  4. 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 0 to also block on warnings
  • Jest coverage thresholds fail CI when coverage drops — coverageThreshold in 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.

Read more