diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 37ea29d..61df2f6 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,3 +1,5 @@
+default_install_hook_types: [pre-commit, pre-push]
+
repos:
- repo: https://github.com/adrienverge/yamllint
rev: v1.35.1
@@ -61,3 +63,72 @@ repos:
language: system
files: ^(docker-compose\.ya?ml|\.env\.example|scripts/check-env-example\.sh)$
pass_filenames: false
+ - id: require-signed-commits
+ name: "require gpg/ssh-signed commits on push"
+ description: >
+ Verify every commit being pushed is GPG/SSH signed. Runs at pre-push
+ stage so it catches the full range of new commits in one pass. Use
+ `git push --no-verify` to bypass intentionally. To fix: re-sign with
+ `git rebase --exec 'git commit --amend --no-edit -S' `.
+ language: system
+ entry: |
+ bash -c '
+ set -e
+ # pre-commit pre-push hook passes refs on stdin as:
+ # If invoked without stdin (e.g. via "pre-commit run --hook-stage pre-push" manually),
+ # fall back to verifying every commit since merge-base with origin/HEAD.
+ range=""
+ if read -r local_ref local_sha remote_ref remote_sha; then
+ if [ "$local_sha" = "0000000000000000000000000000000000000000" ]; then
+ exit 0 # deleting a remote ref; nothing to verify
+ fi
+ if [ "$remote_sha" = "0000000000000000000000000000000000000000" ]; then
+ # New branch on remote — verify everything new on this branch since merge-base with default
+ default=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed "s|refs/remotes/||" || echo origin/main)
+ base=$(git merge-base "$local_sha" "$default" 2>/dev/null || git rev-list --max-parents=0 "$local_sha" | tail -1)
+ range="$base..$local_sha"
+ else
+ range="$remote_sha..$local_sha"
+ fi
+ else
+ # Manual invocation — check unpushed commits on current branch
+ upstream=$(git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>/dev/null || true)
+ if [ -n "$upstream" ]; then
+ range="$upstream..HEAD"
+ else
+ default=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed "s|refs/remotes/||" || echo origin/main)
+ base=$(git merge-base HEAD "$default" 2>/dev/null || git rev-list --max-parents=0 HEAD | tail -1)
+ range="$base..HEAD"
+ fi
+ fi
+ commits=$(git rev-list "$range")
+ if [ -z "$commits" ]; then
+ exit 0
+ fi
+ failed=""
+ for sha in $commits; do
+ # %G? prints: G (good), B (bad), U (good but unknown trust), X (good but expired key),
+ # Y (good signed by expired key), R (good but revoked), E (cannot check), N (no signature)
+ status=$(git log -1 --format="%G?" "$sha")
+ case "$status" in
+ G|U) ;;
+ *) failed="$failed $sha($status)" ;;
+ esac
+ done
+ if [ -n "$failed" ]; then
+ echo "ERROR: unsigned or invalid-signature commits in push range:" >&2
+ echo "$failed" | tr " " "\n" | sed "/^$/d; s/^/ /" >&2
+ echo "" >&2
+ echo "Fix by re-signing the range:" >&2
+ echo " git rebase --exec '"'"'git commit --amend --no-edit -S --no-verify'"'"' $(echo "$range" | cut -d. -f1)" >&2
+ echo " git push --force-with-lease" >&2
+ echo "" >&2
+ echo "Or, if you intentionally need to bypass (e.g. CI fixup), use:" >&2
+ echo " git push --no-verify" >&2
+ exit 1
+ fi
+ exit 0
+ '
+ always_run: true
+ pass_filenames: false
+ stages: [pre-push]