.pre-commit-hooks.yaml # the hook definitions consumers reference
hooks/<hook-id>.sh # one script per hook, named after its id
tests/<hook-id>.bats # the hook's tests
README.md # a section per hook
- Write
hooks/<hook-id>.sh, executable (chmod +x), with a shebang. Hooks uselanguage: script, so any tool they need (e.g.uv) must be on the consumer'sPATH: document it in the README. - Declare it in
.pre-commit-hooks.yamlwithentry: hooks/<hook-id>.sh. - Add
tests/<hook-id>.bats, written with bats. The tests call the script directly with the arguments and file names pre-commit would pass, and check its exit status and output. Seetests/uv-lock-script.bats. - Add a section to
README.md: what it does, its arguments, its requirements, an example.
Install mise, then the tools CI uses, at the versions of mise.toml:
mise install # prek, bats and uv
prek run --all-files # lint: shellcheck, zizmor, manifest validation
bats tests/ # all tests, as CI runs them
bats tests/<hook-id>.bats # one hook's testsTo change a tool's version, run mise use <tool>@<version>: it updates mise.toml and mise.lock.
To try a hook in another repository before it's released, run from that repository:
prek try-repo /path/to/pre-commit-hooks <hook-id> --all-filesCI runs the same commands on every PR. On a PR from a fork, GitHub may wait for a maintainer to approve the workflow runs before CI starts.
Every merge to main publishes a release: a vX.Y.Z tag and a GitHub release whose notes list the
merged PR titles, so write the title for consumers.
The bump is chosen by the label of the merged PR. A PR can't be merged until the "Release label" check passes, and it only passes when the PR has exactly one of these labels:
| Label | Bump | When |
|---|---|---|
release:major |
major | Breaking change, see below |
release:minor |
minor | A new hook, a new option, a change in behavior |
release:patch |
patch | Bug fix with no change in behavior |
norelease |
no release | Nothing consumers can see: docs, CI, tests, tooling |
Only people with write access to the repository can add labels. If you contribute from a fork, say in the PR
description which label you think fits: a maintainer adds it during the review, and the check stays red until
then. Renovate PRs get norelease automatically (.github/renovate.json5): they only update CI and dev tooling.
A change is breaking when a consumer bumping rev can be broken without changing their config:
- removing or renaming a hook id;
- removing or renaming an argument, or making a new one required;
- requiring a new tool on the consumer's machine;
- making the hook fail where it used to pass (e.g. a stricter check).
Moving or renaming a hook's script is not breaking: consumers only reference the hook id, and
pre-commit resolves entry at the pinned version.
Rules for tags:
- Never delete, move or re-create a tag. Consumers pin them, and pre-commit caches each
revforever. - There is no floating major tag (
v2), for the same reason.