Git Hooks for CI/CD and Automation Pipelines
Build custom Git hooks that integrate with CI/CD pipelines: server-side hooks, push-based triggers, branch protection enforcement, and automated deployment gates.
What you'll learn
- ✓How server-side Git hooks differ from client-side hooks
- ✓Building a pre-receive hook that enforces branch policies
- ✓Triggering CI/CD pipelines from post-receive hooks
- ✓Using update hooks for per-branch deployment gates
- ✓Combining hooks with webhook APIs for external integrations
Prerequisites
None — this post is self-contained.
Client-side Git hooks like pre-commit and commit-msg catch issues early on developer machines. But the real power of Git hooks for automation lies on the server side. Server-side hooks run on the Git hosting server during push operations, giving you a mandatory enforcement point that no developer can skip. This guide covers how to use server-side hooks and webhook integrations to build automation pipelines triggered directly by Git operations.
Server-Side Hook Types
Git provides three server-side hooks that run when code is pushed:
pre-receive runs once before any references are updated. It receives all the refs being pushed on stdin. If it exits non-zero, the entire push is rejected. Use this for policy enforcement.
update runs once per branch being pushed. It receives the ref name, old SHA, and new SHA as arguments. If it exits non-zero, only that specific branch update is rejected. Use this for per-branch rules.
post-receive runs after all references are updated. The push has already succeeded, so this hook cannot reject it. Use this for notifications, deployments, and triggering external systems.
Enforcing Branch Policies with pre-receive
A pre-receive hook can enforce rules that protect your repository better than platform settings alone. Here is a hook that blocks force-pushes to protected branches and enforces commit message format:
#!/bin/bash
# pre-receive hook
PROTECTED_BRANCHES="main release"
while read oldrev newrev refname; do
branch=$(echo "$refname" | sed 's|refs/heads/||')
# Block force pushes to protected branches
for protected in $PROTECTED_BRANCHES; do
if [ "$branch" = "$protected" ]; then
# Check if this is a force push (old commits not ancestor of new)
if [ "$oldrev" != "0000000000000000000000000000000000000000" ]; then
merge_base=$(git merge-base "$oldrev" "$newrev" 2>/dev/null)
if [ "$merge_base" != "$oldrev" ]; then
echo "ERROR: Force push to $branch is not allowed."
exit 1
fi
fi
fi
done
# Validate commit messages for all new commits
if [ "$oldrev" = "0000000000000000000000000000000000000000" ]; then
commits=$(git rev-list "$newrev")
else
commits=$(git rev-list "$oldrev..$newrev")
fi
for commit in $commits; do
msg=$(git log --format=%s -1 "$commit")
if ! echo "$msg" | grep -qE '^(feat|fix|docs|chore|refactor|test|ci)(\(.+\))?: .{3,}'; then
echo "ERROR: Commit $commit has invalid message format."
echo "Expected: type(scope): description"
echo "Got: $msg"
exit 1
fi
done
done
exit 0
This hook reads each ref update from stdin, checks for force pushes on protected branches by verifying ancestry, and validates that every new commit follows conventional commit format.
Per-Branch Deployment Gates with update
The update hook is ideal for per-branch rules because it receives the branch name directly. Use it to implement deployment gates:
#!/bin/bash
# update hook
# Arguments: refname oldrev newrev
refname=$1
oldrev=$2
newrev=$3
branch=$(echo "$refname" | sed 's|refs/heads/||')
# Require signed commits on release branches
if [[ "$branch" == release/* ]]; then
commits=$(git rev-list "$oldrev..$newrev")
for commit in $commits; do
if ! git verify-commit "$commit" 2>/dev/null; then
echo "ERROR: Commit $commit on release branch must be GPG signed."
exit 1
fi
done
fi
# Require linear history on main (no merge commits)
if [ "$branch" = "main" ]; then
merge_commits=$(git rev-list --merges "$oldrev..$newrev")
if [ -n "$merge_commits" ]; then
echo "ERROR: Merge commits are not allowed on main."
echo "Use rebase-and-merge or squash-and-merge."
exit 1
fi
fi
exit 0
Triggering Pipelines with post-receive
The post-receive hook is where you connect Git to external systems. Since the push has already been accepted, this hook is fire-and-forget:
#!/bin/bash
# post-receive hook
while read oldrev newrev refname; do
branch=$(echo "$refname" | sed 's|refs/heads/||')
# Trigger CI/CD pipeline via webhook
if [ "$branch" = "main" ]; then
curl -s -X POST \
-H "Authorization: Bearer $CI_WEBHOOK_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"ref\": \"$refname\", \"sha\": \"$newrev\", \"action\": \"deploy-staging\"}" \
"https://ci.example.com/api/webhooks/deploy" &
fi
# Notify Slack on release tags
if echo "$refname" | grep -q "refs/tags/v"; then
tag=$(echo "$refname" | sed 's|refs/tags/||')
curl -s -X POST \
-H "Content-Type: application/json" \
-d "{\"text\": \"Release $tag has been pushed.\"}" \
"$SLACK_WEBHOOK_URL" &
fi
done
# Wait for background jobs
wait
exit 0
The & runs webhook calls in the background so the push completes quickly. The wait ensures all calls finish before the hook exits.
Webhook-Based Automation for Hosted Platforms
If you use GitHub, GitLab, or Bitbucket, you cannot install server-side hooks directly. Instead, these platforms expose webhook APIs that serve the same purpose.
A GitHub webhook receiver that validates pushes and triggers deployments:
from flask import Flask, request, jsonify
import hmac
import hashlib
import subprocess
app = Flask(__name__)
WEBHOOK_SECRET = "your-secret-here"
def verify_signature(payload, signature):
expected = hmac.new(
WEBHOOK_SECRET.encode(),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(f"sha256={expected}", signature)
@app.route("/webhook", methods=["POST"])
def handle_push():
signature = request.headers.get("X-Hub-Signature-256", "")
if not verify_signature(request.data, signature):
return jsonify({"error": "Invalid signature"}), 403
event = request.headers.get("X-GitHub-Event")
payload = request.json
if event == "push" and payload["ref"] == "refs/heads/main":
sha = payload["after"]
# Trigger deployment pipeline
subprocess.Popen(["./deploy.sh", sha])
return jsonify({"status": "deploying", "sha": sha})
return jsonify({"status": "ignored"})
Combining Client and Server Hooks
A robust automation pipeline uses both layers. Client-side hooks provide fast feedback. Server-side hooks enforce policy. Here is how they complement each other:
| Layer | Hook | Purpose | Bypass-proof |
|---|---|---|---|
| Client | pre-commit | Lint, format, type-check | No |
| Client | commit-msg | Message format | No |
| Client | pre-push | Run tests | No |
| Server | pre-receive | Block policy violations | Yes |
| Server | update | Per-branch rules | Yes |
| Server | post-receive | Trigger deployments | Yes |
Client hooks can always be skipped with --no-verify. Server hooks cannot. Design your pipeline so that client hooks are a convenience layer and server hooks are the enforcement layer.
Testing Hooks Locally
Server-side hooks are hard to test because they run in a bare repository context. Create a local bare repo to test them:
# Create a bare repo with your hook
git init --bare /tmp/test-hooks.git
cp pre-receive /tmp/test-hooks.git/hooks/
chmod +x /tmp/test-hooks.git/hooks/pre-receive
# Clone it and push to test
git clone /tmp/test-hooks.git /tmp/test-repo
cd /tmp/test-repo
echo "test" > file.txt
git add file.txt
git commit -m "feat: add test file"
git push origin main
This lets you iterate on hook logic without deploying to a shared server.
Key Takeaways
Server-side hooks are the enforcement layer that client-side hooks cannot be. Use pre-receive for repo-wide policies, update for per-branch rules, and post-receive for triggering external systems. On hosted platforms, webhook receivers serve the same role. Build your automation pipeline with client hooks for developer convenience and server hooks for mandatory enforcement, and test both locally before deploying them to production.
Related articles
- Git Git Hooks and pre-commit Tutorial
Automate checks before commits, pushes, and merges with native git hooks and the pre-commit framework. Keep your repo clean without slowing down.
- Git Git Monorepo Management at Scale
Manage large Git monorepos with sparse checkout, partial clone, subtrees, CODEOWNERS, and scaling strategies that keep performance acceptable as the repo grows.
- Git Git Rebasing Strategies for Clean Team Histories
Advanced git rebase strategies for teams: interactive rebase workflows, rebase --onto, autosquash, and policies that keep shared branches clean without rewriting public history.
- CI/CD GitHub Actions vs Jenkins: CI/CD Compared
Compare GitHub Actions and Jenkins for CI/CD pipelines. Covers setup, configuration, plugins, pricing, and when to choose each tool for your team.