Pinning and autoupdating pre-commit hook versions Jump to heading

Every entry in .pre-commit-config.yaml points at a Git repository and a rev. That revision decides exactly which code runs on every developer’s machine at every commit — it is a dependency, and an unusually privileged one, since it executes with the developer’s permissions inside their working copy. Teams often pin it loosely or never update it: a tag that could be moved, a branch name that changes daily, or a version from three years ago that formats code differently from the CI image. This page sets up pins that cannot move under you, an update routine that keeps them current, and a review step that treats hook updates with the seriousness they deserve, within the pre-commit framework for polyglot repos.

When to use this approach Jump to heading

  • Your repository uses the pre-commit framework with hooks from external repositories.
  • Some rev values are branch names, short tags or very old versions.
  • Formatting churn appears because developers or CI run different hook versions.
  • You already pin other dependencies by hash, as in pinning GitHub Actions to a commit SHA, and want the same discipline here.

Step 1 — Audit what each hook is pinned to Jump to heading

List each repository and its rev, and classify it: a full commit hash is immutable; a version tag is conventional but movable; a branch name changes constantly.

python3 - <<'EOF'
import re, yaml
cfg = yaml.safe_load(open(".pre-commit-config.yaml"))
for r in cfg["repos"]:
    rev = r.get("rev", "")
    kind = "local/meta" if r["repo"] in ("local", "meta") else \
           "hash" if re.fullmatch(r"[0-9a-f]{40}", rev) else \
           "tag" if re.match(r"v?\d", rev) else "BRANCH?"
    print(f"{kind:10} {rev:42} {r['repo']}")
EOF
What a pre-commit rev can be pinned toA branch name changes whenever upstream pushes, so every developer may run different code. A version tag is stable by convention but can be deleted and recreated. A full commit hash names exactly one tree and cannot change, which makes it the only truly reproducible pin.Can change silentlyReproduciblerev: mainevery pushnorev: v4.6.0if re-taggedusuallyrev: <40-char hash>neveryeshooks run with your permissions — pin them like any other executable dependency

Step 2 — Pin to commit hashes with the version as a comment Jump to heading

pre-commit autoupdate --freeze resolves each tag to its commit and writes the hash, leaving the tag as a comment so humans can still read it.

pre-commit autoupdate --freeze
git diff .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: 2c9f875913ee60ca25ce70243dc24d5b6415598c  # frozen: v4.6.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: 8b5112a3b2ad121439a2092f8ff548c0d80f2514  # frozen: v0.6.9
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format

The hashes shown here are illustrative; autoupdate --freeze writes the real commit for each tag.

# Verification: every external repo now has a 40-character rev
grep -E '^\s+rev:' .pre-commit-config.yaml | grep -vE '[0-9a-f]{40}' || echo "all frozen"

Step 3 — Update on a schedule, not by accident Jump to heading

Pinned hooks never update unless someone updates them. A scheduled job runs autoupdate --freeze weekly and opens a pull request when anything changed.

# .github/workflows/pre-commit-autoupdate.yml
on:
  schedule: [{ cron: "37 5 * * 1" }]
  workflow_dispatch:
permissions: { contents: write, pull-requests: write }
jobs:
  autoupdate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pipx install pre-commit && pre-commit autoupdate --freeze
      - run: pre-commit run --all-files || true          # apply any new formatting
      - run: |
          git diff --quiet && exit 0
          git switch -c chore/pre-commit-autoupdate-$(date +%F)
          git commit -am "chore: update pre-commit hooks"
          git push -u origin HEAD
          gh pr create --fill --label dependencies
        env: { GH_TOKEN: "${{ github.token }}" }
A weekly hook updateA scheduled job freezes the latest released versions of every hook, runs all hooks across the repository so any formatting changes from new versions are included, and opens a pull request where the version bumps and their effect on the code are reviewed together.Scheduleweeklyautoupdate--freezerun --all-filesapply new formattingPull requestversions + effectsbundling the reformat with the bump means main never sees a half-updated state

Running all hooks after updating matters: a new formatter version may reformat files, and those changes belong in the same pull request as the version bump. Otherwise the next unrelated commit picks them up and its diff fills with noise.

Step 4 — Review hook updates as code changes Jump to heading

A hook update changes code that runs on every developer’s machine. Review it as you would a dependency with install scripts: read the upstream changelog, look at the diff between the old and new commit for anything surprising, and check the hook’s repository is still the one you meant.

# What changed upstream between the old and new pins?
old=2c9f8759...; new=0ad5f61d...
tmp=$(mktemp -d); git -C "$tmp" init -q
git -C "$tmp" fetch -q https://github.com/pre-commit/pre-commit-hooks "$old" "$new"
git -C "$tmp" log --oneline "$old..$new"
git -C "$tmp" diff --stat "$old" "$new"

Treat a hook that suddenly adds network calls, install scripts or new dependencies with suspicion, just as you would a package dependency.

Step 5 — Keep CI on the same versions Jump to heading

CI must run the same frozen versions developers do, or the two disagree about formatting. Running pre-commit run in CI reads the same config, so this is automatic — as long as CI does not override hook versions or use a separately installed copy of the tools.

      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pipx install pre-commit && pre-commit run --show-diff-on-failure --color=always --all-files

Caching the hook environments keeps this fast; see caching pre-commit environments in CI.

Step 6 — Pin the framework itself Jump to heading

The pre-commit tool has its own version, and old versions may not understand newer config features. Set a minimum in the config and pin the version installed in CI.

# .pre-commit-config.yaml
minimum_pre_commit_version: "3.7.0"
pipx install "pre-commit==3.8.0"
Every layer that decides which hook code runsThe pre-commit framework version decides which config features work. Each repo's frozen rev decides the hook code. Additional dependencies pinned in the config decide the libraries hooks import. CI running the same config ensures developers and pipelines agree.pin all of them, update them togetherpre-commit itselfminimum_pre_commit_version + CI pinHook repositoriesrev frozen to commit hashesadditional_dependenciesexact versions in the configCIsame config, no overridesadditional_dependencies are easy to forget — pin them with == versions too

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Does freezing make updates harder? Jump to heading

No. pre-commit autoupdate --freeze reads the tags upstream, picks the newest, and writes its hash, so updating frozen pins is the same command as updating tags.

What about the local repo type? Jump to heading

Local hooks run code from your own repository and have no rev, so there is nothing to pin. Their tools may still need pinned versions through the language’s own dependency files.

Can Renovate or Dependabot update pre-commit hooks? Jump to heading

Renovate supports pre-commit configuration natively. Either a bot or the scheduled job above works; what matters is that updates arrive as reviewed pull requests rather than on someone’s laptop.