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 forlanguage: golanghooks (gitleaks, tflint). - PyPI / GitHub — required to install
language: pythonhook 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:
- Allow all hook dependencies at runtime: open the Go module proxy and PyPI in the egress allowlist; accept that first runs need internet.
- In-image binaries +
localhooks: install every tool at image build time as a pre-built artefact or pip/pipx package. Reference them fromlocalpre-commit hooks withlanguage: system. First runs need no network. - Pre-seed the pre-commit cache: run
pre-commit install-hooksduring the Docker build and ship the resulting~/.cache/pre-commitas 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.tffilenames toterraform fmt -diff -check.terraform-validate: deduplicates parent directories of changed.tffiles and runsterraform -chdir=<dir> validate -no-colorin each.terraform-tflint: runstflint --recursiveonce; tflint discovers files itself.shellcheck: passes staged shell files toshellcheck -e SC1091.checkov: runscheckov -d . --framework terraform,dockerfile,ansibleonce.ansible-lint: runsansible-lintonce.conventional-pre-commit: receives the commit message file path from pre-commit'scommit-msgstage; 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.