Skip to content

0008. In-Image Binaries for All Pre-Commit Hooks

Status

Accepted (updated: extended from Go-based hooks to all hooks)

Context

Pre-commit's hook installation model clones each repo: entry from GitHub and installs the hook's language environment (Go toolchain, Python virtualenv, Node modules, etc.) the first time pre-commit run is executed inside the project.

The dev container enforces a strict egress allowlist (.devcontainer/egress-allowlist.txt). Several domains pre-commit needs at hook-install time are absent:

  • Go module proxy (go.dev, proxy.golang.org, sum.golang.org) — required for language: golang hooks (gitleaks, tflint).
  • PyPI / GitHub — required to install language: python hook virtualenvs (shellcheck-py, pre-commit-terraform, checkov, ansible-lint, conventional-pre-commit).

The practical symptom in both cases is a network error on the first pre-commit run inside a project that has not yet seeded the hook cache. This makes the dev container unusable in air-gapped projects, and creates an unnecessary network dependency even when GitHub is reachable.

Three options:

  1. Allow all hook dependencies at runtime: open the Go module proxy and PyPI in the egress allowlist; accept that first runs need internet.
  2. In-image binaries + local hooks: install every tool at image build time as a pre-built artefact or pip/pipx package. Reference them from local pre-commit hooks with language: system. First runs need no network.
  3. Pre-seed the pre-commit cache: run pre-commit install-hooks during the Docker build and ship the resulting ~/.cache/pre-commit as a seed directory (similar to the Terraform/Packer/Ansible provider cache pattern). Reduces but does not eliminate runtime network dependency — consuming projects that change hook versions still trigger downloads.

Decision

Adopt option 2: in-image binaries + local hooks for all hooks.

This eliminates every runtime network dependency. The .pre-commit-config.yaml uses a single repo: local block; every hook declares language: system and invokes the in-image binary or CLI entry point on $PATH.

Implementation

Dockerfile builder stage installs binaries from GitHub releases (SHA256-verified where the upstream provides a checksum file):

Binary Dockerfile ARG Source
terraform TERRAFORM_VERSION HashiCorp releases
tflint TFLINT_VERSION terraform-linters/tflint
gitleaks GITLEAKS_VERSION gitleaks/gitleaks
shellcheck SHELLCHECK_VERSION koalaman/shellcheck

shellcheck does not publish a SHA256 sidecar file; integrity relies on HTTPS from github.com (same policy as the 1Password CLI).

Layer E (pip install) installs Python tools alongside pre-commit itself:

Package ARG Entry points used
pre-commit-hooks PRE_COMMIT_HOOKS_VERSION trailing-whitespace, end-of-file-fixer, check-yaml, check-json, check-merge-conflict, detect-private-key, check-added-large-files
conventional-pre-commit CONVENTIONAL_PRE_COMMIT_VERSION conventional-pre-commit

Layer E (pipx install) — already present:

Package Entry points used
checkov checkov

pip install — already present:

Package Entry points used
ansible-lint ansible-lint

.pre-commit-config.yaml uses a single repo: local block. All hooks declare language: system.

renovate.json continues to track versions at the ARG level (the Renovate native pre-commit manager only watches upstream repo: entries, not local: ones).

Hook semantics

  • terraform-fmt: passes staged .tf filenames to terraform fmt -diff -check.
  • terraform-validate: deduplicates parent directories of changed .tf files and runs terraform -chdir=<dir> validate -no-color in each.
  • terraform-tflint: runs tflint --recursive once; tflint discovers files itself.
  • shellcheck: passes staged shell files to shellcheck -e SC1091.
  • checkov: runs checkov -d . --framework terraform,dockerfile,ansible once.
  • ansible-lint: runs ansible-lint once.
  • conventional-pre-commit: receives the commit message file path from pre-commit's commit-msg stage; validates against Conventional Commits format.

Consequences

Easier: - No runtime network access required — pre-commit run works fully offline after docker pull. - No Go toolchain, PyPI, or go.dev in the egress allowlist. - All version pins are in the Dockerfile next to every other pinned tool, tracked by the same Renovate regex manager. - .pre-commit-config.yaml has a single repo: local block — no rev: fields to keep in sync with Dockerfile ARGs. - Works in air-gapped projects that cannot reach GitHub at runtime.

Harder: - We are responsible for downloading and (where possible) verifying binaries at image build time. - The local hook entries are more verbose than upstream repo: shorthands. - terraform validate directory-walking logic is re-implemented in a bash entry; if the upstream hook adds new behaviour we must mirror it. - shellcheck does not publish a SHA256 sidecar, so verification relies on HTTPS.

Out of scope: - This ADR does not preclude adding a repo: entry for a hook where no in-image binary exists and the tool is not suitable for pre-installation. The choice is per-tool.