Builtin hook to verify commits are signed (check-signed-commit)
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:
[[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:
git verify-commit HEAD .git/COMMIT_EDITMSGgit 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; whenFROM_REFis all zeros (new branch), use$TO_REF --not --remotesso it checks only commits not yet on any remote. - Walk
git rev-list --no-merges <range>. - Accept
%G?ofG(good) andU(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/expiredWorth 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.
#!/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-branchinspects git state rather than file contents, same shape as this. - There's no upstream hook to point people at.
pre-commit/pre-commit-hookshas 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).
Source: j178/prek