CI & Workflow Integration

Linting in CI: Enforce Code Quality on Every Pull Request

A lint rule that only runs on some laptops isn't a standard — it's a suggestion. The moment one developer's editor has the plugin disabled, or one commit goes in from a machine without the hook, your "enforced" convention has an exception, and exceptions compound. The fix is structural: run the checks where they can't be skipped.

The proven pattern is three layers — editor for instant feedback, pre-commit hook for a cheap local gate, and CI as the enforcement point of record. Each layer catches what the previous one missed, and only the last one is authoritative. This guide wires up all three, keeps them fast, and shows how to roll them onto a legacy codebase without a 12,000-warning mutiny.

The three layers, and why you need all of them

  • Editor — the linter runs as you type, so most issues die seconds after they're born. Best feedback, zero enforcement: plugins can be off, configs can drift.
  • Pre-commit hook — the linter runs on the files you're about to commit. Catches issues before they reach the remote, but hooks are opt-in by design: git commit --no-verify skips them, and fresh clones don't have them until installed.
  • CI — the linter runs on the server for every push and pull request, and a finding fails the check. Nobody can skip it, everyone sees the same result, and branch protection can make a green run a merge requirement.

The design principle: feedback as early as possible, enforcement only at CI. If a check exists in a hook or an editor, the identical check — same tool, same version, same config — must exist in CI. Anything else eventually disagrees with itself.

(New to what the linter itself should check? Start with what is linting — this guide assumes the rules are chosen and focuses on making them stick.)

Layer 1: the editor

Keep this layer boring: the official linter/formatter extension for your editor, configured to read the project's config file from the repo — never per-developer rule settings. Commit a shared editor config where your team standardizes on one editor (for VS Code, a .vscode/extensions.json recommending the linter extensions) so new machines self-provision. Format-on-save plus lint-on-type removes most friction before it's felt.

Layer 2: pre-commit hooks

Hooks shine because they check only what's being committed — a second or two, not a full-repo scan.

Python (and polyglot) repos typically use the pre-commit framework, configured in .pre-commit-config.yaml:

repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.4.4  # pin to the latest release
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format

Each developer runs pre-commit install once per clone; after that, every git commit lints and formats the staged Python files, and the framework manages tool versions from the pinned rev.

JavaScript/TypeScript repos usually pair Husky (installs the Git hook) with lint-staged (runs tools on staged files only). In package.json:

{
  "scripts": {
    "prepare": "husky"
  },
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": "eslint --fix",
    "*.{js,jsx,ts,tsx,css,md,json}": "prettier --write"
  }
}

With a .husky/pre-commit hook that runs npx lint-staged, every commit auto-fixes and formats exactly the files it touches.

Treat hook failures as help, not obstruction: the same problem found here costs seconds; found in CI it costs a round trip.

Layer 3: the CI gate

This is the layer that makes it real. A minimal GitHub Actions workflow for a Node project:

name: Lint
on:
  pull_request:
  push:
    branches: [main]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npx eslint . --max-warnings 0
      - run: npx prettier --check .

The Python equivalent swaps the tool steps for ruff check . and ruff format --check .. Three details carry most of the value:

  • --max-warnings 0 — warnings that can't fail the build become wallpaper. If a rule matters, it blocks; if it doesn't, turn it off.
  • --check mode for formatters — CI must never rewrite code silently; it verifies the developer already formatted (locally, where the hook did it for them).
  • Branch protection — mark the lint job as a required status check so an unlintable branch physically cannot merge. Without this, the gate is decorative.

The same job shape runs your type checker and security scanner — one pipeline, several analyzers; see static analysis explained for what each additional layer buys you.

Keeping CI lint fast

A ten-minute lint job trains people to hate the gate. In rough order of payoff:

  1. Cache dependencies. Installing packages usually dwarfs linting itself — the cache: npm line above, or your ecosystem's equivalent.
  2. Use the tool's own cache. ESLint's --cache flag persists results and re-lints only changed files; restore the cache file between CI runs for large repos.
  3. Run analyzers in parallel jobs. Lint, type-check, and tests don't need to queue behind each other.
  4. Pick fast tools where speed hurts. Tool choice is a legitimate performance lever — linters differ by orders of magnitude on big repos (Ruff's headline feature is exactly this). Weigh it with stated criteria rather than folklore.
  5. Lint only changed files — carefully. Diff-only linting is a real tactic for huge repos, but rules that consider cross-file context can miss violations introduced indirectly. Prefer caching first; reach for diff-only linting when scale forces it, and schedule a periodic full run as a backstop.

For monorepos, lean on your build system's task graph (Nx, Turborepo, Bazel) so a change to one package lints that package and its dependents, not the world.

Rolling out on a legacy codebase

Turning a strict config loose on an old repo yields thousands of findings and instant resentment. The playbook that works:

  1. Format everything once. Formatting is mechanical, so do the big-bang reformat in a single, code-only commit — then add that commit's hash to a .git-blame-ignore-revs file so git blame (and GitHub's blame view) skips it and history stays readable.
  2. Baseline the linter. Start with only the rules the codebase already passes (or auto-fixes). You get immediate protection against new violations without touching old code.
  3. Ratchet up. Enable one meaningful rule at a time: fix or suppress existing findings in a dedicated PR, promote the rule to error, repeat. Several tools support a stored-baseline mode that reports only new findings — same idea, less ceremony.
  4. Never mix cleanup with features. Lint-fix commits containing logic changes are how bugs hide in "mechanical" diffs — and how reviewers learn to rubber-stamp them.

The metric that matters isn't findings fixed; it's that the default branch stays at zero findings for every enabled rule from the day of the rollout onward.

FAQ

If CI enforces linting, do we still need pre-commit hooks?

Strictly, no — CI alone guarantees nothing bad merges. Practically, yes: a hook catches the problem in two seconds instead of after a push, a CI queue, and a red X ten minutes later. Hooks are the feedback layer, CI is the enforcement layer; run the identical checks in both so they never disagree.

Should the CI job auto-fix issues and push a commit?

Most teams shouldn't. Bot pushes to PR branches complicate signatures, re-trigger pipelines, and teach developers to outsource hygiene to the robot. Keep auto-fixing local — editor format-on-save and pre-commit --fix — and let CI only verify with --check. If commit noise is the concern, that's fixed by hooks, not by a pushing bot.

Won't a one-time reformat of the whole repo destroy git blame?

Not if you record it: put the reformat in its own commit and add that hash to .git-blame-ignore-revs. Configure Git locally with git config blame.ignoreRevsFile .git-blame-ignore-revs — GitHub's blame view respects the file automatically. Blame then skips straight past the mechanical commit to the real authors.

Should generated files and vendored code be linted in CI?

No — you don't own that style, and findings there are pure noise. Exclude generated output, build artifacts, and vendored directories in the linter's ignore configuration (.eslintignore patterns, Ruff's exclude, etc.), and make sure the same exclusions apply in the editor and hooks so all three layers agree on what's in scope.


Enforcement is what turns a style guide from a wiki page into a property of your codebase: editor for speed, hooks for convenience, CI for truth. The remaining variable is the tool itself — and on large repos, its speed and integration fit decide whether the gate feels instant or infuriating. Compare the leading linters and formatters on Lintense — criteria-first comparisons covering speed, configuration surface, and ecosystem — before you wire one into the pipeline.

Comments are disabled for this article.