Skip to content
Codeloom
Git

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.

·6 min read · By Codeloom
Advanced 10 min read

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:

LayerHookPurposeBypass-proof
Clientpre-commitLint, format, type-checkNo
Clientcommit-msgMessage formatNo
Clientpre-pushRun testsNo
Serverpre-receiveBlock policy violationsYes
ServerupdatePer-branch rulesYes
Serverpost-receiveTrigger deploymentsYes

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.