#2720·prek

Builtin hook to verify commits are signed (check-signed-commit)

Author: ebuildyCreated Sep 14, 2026Updated Sep 14, 2026
Labelsenhancement

Summary

(This have been IA generated, sorry)

There's no builtin hook to assert that commits are signed, and it's a case where the local-hook workaround is unusually easy to get wrong. I set one up this week and hit three separate footguns before it worked. I'd like to propose check-signed-commit as a builtin, and I'm happy to implement it.

Why a local hook is awkward here

The obvious config is this, and it is broken in two independent ways:

toml
[[repos]]
repo = "local"
hooks = [{ id = "check-signed-commit", entry = "git verify-commit HEAD", language = "system", stages = ["commit-msg"], always_run = true }]

1. pass_filenames interacts badly with git verify-commit. prek appends filenames to entry, and at commit-msg the filename is the message file, so what actually runs is:

bash
git verify-commit HEAD .git/COMMIT_EDITMSG

git verify-commit reads every argument as a commit-ish, so it verifies HEAD correctly and then fails on the second argument:

gpg: Good signature from "..." [ultimate]
error: commit '.git/COMMIT_EDITMSG' not found.

That error is quite confusing, because the signature check visibly succeeded right above it. Fix is pass_filenames = false.

2. commit-msg is the wrong stage, and the intuitive stages don't work. At commit-msg the commit doesn't exist yet, so git verify-commit HEAD inspects the parent commit and says nothing about the one being written. It passes while creating an unsigned commit, and errors on a repo's first commit. I tested each stage:

stage sees the commit being made can block
commit-msg no — HEAD is still the parent yes
post-commit yes no — git ignores the exit code; I confirmed the unsigned commit is created anyway
pre-push yes yes

So pre-push is the only stage where the commits exist and a non-zero exit stops anything. That's not obvious from the outside, and both wrong answers fail silently rather than loudly.

3. Getting the push range requires knowing an undocumented-ish detail. Git passes the range to a pre-push hook on stdin, but prek consumes stdin and passes no arguments — I probed it with a hook that dumps $* and cat, and got empty for both. The range is only reachable via the PRE_COMMIT_FROM_REF / PRE_COMMIT_TO_REF env vars prek exports. Without knowing that, the natural fallback is HEAD, which silently verifies the entire repository history instead of the commits being pushed. A new branch also reports an all-zero FROM_REF, which needs its own branch.

Proposed behaviour

check-signed-commit, defaulting to stages = ["pre-push"]:

  • Range: $PRE_COMMIT_FROM_REF..$PRE_COMMIT_TO_REF; when FROM_REF is all zeros (new branch), use $TO_REF --not --remotes so it checks only commits not yet on any remote.
  • Walk git rev-list --no-merges <range>.
  • Accept %G? of G (good) and U (good, key not marked trusted). Reject the rest, reporting the code per commit.

Reporting the actual code matters. N (unsigned) and E (signed, but the key isn't in the local keyring) need completely different fixes, and E is common in practice: GitHub squash-merges are signed with GitHub's web-flow key, and they're non-merge commits, so --no-merges doesn't filter them out. Output I've been using:

Refusing to push: commits are not verifiably signed.
  [N] 11eaf88 feat: unsigned sneaky change
  [E] ad3a973 migration: plan + bootstrap (#7)

  N = unsigned   E = signed but key unavailable locally   B/R/X/Y = bad/revoked/expired

Worth discussing: whether E should be accepted behind an option (allow_unverifiable_key or similar), since "is it signed" and "can I verify it" are different questions and some workflows only care about the former.

Reference implementation

This is the POSIX-sh version I'm running now, which passes: signed-only push accepted; push containing an unsigned commit refused with the remote unchanged; new branch with an unsigned commit refused; new-branch signed-only accepted.

bash
#!/bin/sh
set -eu
from="${PRE_COMMIT_FROM_REF:-}"
to="${PRE_COMMIT_TO_REF:-HEAD}"

if [ -z "$from" ] || [ -z "$(printf '%s' "$from" | tr -d '0')" ]; then
	set -- "$to" --not --remotes
else
	set -- "$from..$to"
fi

rc=0
for commit in $(git rev-list --no-merges "$@"); do
	verdict=$(git log -1 --format='%G?' "$commit")
	case "$verdict" in
		G | U) ;;
		*)
			[ "$rc" -eq 0 ] && echo "Refusing to push: commits are not verifiably signed." >&2
			rc=1
			printf '  [%s] %s\n' "$verdict" "$(git log -1 --format='%h %s' "$commit")" >&2
			;;
	esac
done
exit "$rc"

Why builtin rather than docs

Two reasons I think this fits as a builtin rather than a documentation example:

  • There's precedent for non-file-based builtins: no-commit-to-branch inspects git state rather than file contents, same shape as this.
  • There's no upstream hook to point people at. pre-commit/pre-commit-hooks has no signed-commit hook, so this isn't a port — but prek already ships builtins that don't exist upstream (deny-pattern, require-pattern, check-json5, check-jsonc), so net-new builtins seem in scope.

A builtin would also make the three footguns above unreachable by construction: no pass_filenames, no stage choice, no env-var archaeology.

Happy to send a PR if you're open to it — just let me know your preference on the E/unverifiable-key question and whether you'd want a non-pre-push stage supported at all.

Tested with prek 0.5.2 (bfaa03cc9).