Skip to main content
Menu
Home WhoAmI Stack Insights Blog Contact
/user/KayD @ karandeepsingh.ca :~$ cat 9-jenkins-hacks-that-will-make-your-life-easier-devops.md

Git Hooks and Automation: From Shell Hooks to a Go Webhook Server

Karandeep Singh
• 26 minutes read

Summary

Master Git automation with shell hooks and Go. From pre-commit scripts to a complete webhook server that validates payloads, triggers builds, and reports results.

Git hooks are scripts that run at specific points in the Git workflow. They live inside every repository. You do not need to install anything. You do not need a server. They just work.

In this guide, you will start with simple shell scripts for pre-commit and pre-push checks. Then you will build each piece in Go. By the end, you will have a complete webhook server that receives push events over HTTP, validates them with HMAC signatures, and triggers builds.

Every Go program in this article compiles with the standard library. No third-party packages.


Step 1: Pre-Commit Hooks — Catch Problems Early

A pre-commit hook runs before every commit. If the hook exits with a non-zero code, the commit is aborted. This is where you catch problems before they enter the history.

The shell hook

Create the hook file in your repository.

touch .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit

The chmod +x is required. Git will silently ignore hooks that are not executable.

Now open .git/hooks/pre-commit and add checks. This version looks for debug print statements and large files.

#!/bin/bash

echo "Running pre-commit checks..."

# Check for debug statements in Go files
grep -rn 'fmt.Println("DEBUG' *.go && exit 1

# Check for large files being committed
find . -size +1M -not -path './.git/*' | head -5
if [ $? -eq 0 ]; then
    echo "ERROR: Files larger than 1MB found."
    exit 1
fi

echo "All checks passed."
exit 0

Test it by adding a Go file with a debug statement.

echo 'package main
import "fmt"
func main() {
    fmt.Println("DEBUG this should not be committed")
}' > test.go

git add test.go
git commit -m "test commit"

Output:

Running pre-commit checks...
test.go:4:    fmt.Println("DEBUG this should not be committed")

The commit was blocked. Good. But there is a problem.

The bug

The grep command exits immediately when it finds a match. If there are three files with debug statements, you only see the first one. The developer fixes that one file, commits again, sees another one, fixes it, commits again, and so on. This is slow and frustrating.

Also, the find check never runs. The grep line uses && exit 1, which means: if grep finds a match (exit 0), then exit 1. The script stops right there. Everything after it is skipped.

The fix

Collect all violations first. Report them all. Exit with an error at the end.

#!/bin/bash

echo "Running pre-commit checks..."
FAILED=0

# Check for debug statements in Go files
DEBUG_MATCHES=$(grep -rn 'fmt.Println("DEBUG' *.go 2>/dev/null)
if [ -n "$DEBUG_MATCHES" ]; then
    echo "ERROR: Debug statements found:"
    echo "$DEBUG_MATCHES"
    FAILED=1
fi

# Check for large files being committed
LARGE_FILES=$(find . -size +1M -not -path './.git/*' 2>/dev/null)
if [ -n "$LARGE_FILES" ]; then
    echo "ERROR: Files larger than 1MB:"
    echo "$LARGE_FILES"
    FAILED=1
fi

if [ $FAILED -eq 1 ]; then
    echo ""
    echo "Pre-commit checks failed. Fix the issues above and try again."
    exit 1
fi

echo "All checks passed."
exit 0

Now both checks run every time. The developer sees all problems in one shot.

Go version: a pre-commit checker

Shell scripts work, but they are hard to test and hard to extend. Here is a Go program that does the same thing. It runs multiple checks and reports which ones failed.

package main

import (
	"bufio"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
)

type CheckResult struct {
	Name     string
	Passed   bool
	Messages []string
}

func checkDebugStatements() CheckResult {
	result := CheckResult{Name: "debug-statements", Passed: true}

	err := filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return nil
		}
		if info.IsDir() && info.Name() == ".git" {
			return filepath.SkipDir
		}
		if !strings.HasSuffix(path, ".go") {
			return nil
		}

		file, err := os.Open(path)
		if err != nil {
			return nil
		}
		defer file.Close()

		scanner := bufio.NewScanner(file)
		lineNum := 0
		for scanner.Scan() {
			lineNum++
			line := scanner.Text()
			if strings.Contains(line, `fmt.Println("DEBUG`) {
				result.Passed = false
				result.Messages = append(result.Messages,
					fmt.Sprintf("  %s:%d: %s", path, lineNum, strings.TrimSpace(line)))
			}
		}
		return nil
	})
	if err != nil {
		result.Messages = append(result.Messages, "  error walking directory: "+err.Error())
	}

	return result
}

func checkLargeFiles() CheckResult {
	result := CheckResult{Name: "large-files", Passed: true}
	const maxSize int64 = 1 * 1024 * 1024 // 1 MB

	err := filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return nil
		}
		if info.IsDir() && info.Name() == ".git" {
			return filepath.SkipDir
		}
		if !info.IsDir() && info.Size() > maxSize {
			result.Passed = false
			sizeMB := float64(info.Size()) / 1024 / 1024
			result.Messages = append(result.Messages,
				fmt.Sprintf("  %s: %.2f MB", path, sizeMB))
		}
		return nil
	})
	if err != nil {
		result.Messages = append(result.Messages, "  error walking directory: "+err.Error())
	}

	return result
}

func checkGoFmt() CheckResult {
	result := CheckResult{Name: "go-fmt", Passed: true}

	cmd := exec.Command("gofmt", "-l", ".")
	output, err := cmd.Output()
	if err != nil {
		result.Messages = append(result.Messages, "  gofmt not available, skipping")
		return result
	}

	unformatted := strings.TrimSpace(string(output))
	if unformatted != "" {
		result.Passed = false
		for _, file := range strings.Split(unformatted, "\n") {
			result.Messages = append(result.Messages, "  "+file)
		}
	}

	return result
}

func main() {
	fmt.Println("Running pre-commit checks...")
	fmt.Println()

	checks := []CheckResult{
		checkDebugStatements(),
		checkLargeFiles(),
		checkGoFmt(),
	}

	allPassed := true
	for _, check := range checks {
		if check.Passed {
			fmt.Printf("[PASS] %s\n", check.Name)
		} else {
			fmt.Printf("[FAIL] %s\n", check.Name)
			for _, msg := range check.Messages {
				fmt.Println(msg)
			}
			allPassed = false
		}
	}

	fmt.Println()
	if !allPassed {
		fmt.Println("Pre-commit checks failed.")
		os.Exit(1)
	}
	fmt.Println("All checks passed.")
}

Build it and install it as the hook.

go build -o .git/hooks/pre-commit ./cmd/precommit/main.go

Now the hook is a compiled binary. It runs faster than a shell script, and you can write unit tests for each check function.


Step 2: Pre-Push Hooks — Validate Before Sharing

A pre-push hook runs before git push sends commits to the remote. This is the last chance to catch problems before other people see your code.

The shell hook

touch .git/hooks/pre-push
chmod +x .git/hooks/pre-push

This hook runs tests and checks the branch name.

#!/bin/bash

echo "Running pre-push checks..."

# Run tests
go test ./...
if [ $? -ne 0 ]; then
    echo "ERROR: Tests failed. Push aborted."
    exit 1
fi

# Prevent pushing to main
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$CURRENT_BRANCH" = "main" ]; then
    echo "ERROR: Cannot push directly to main. Use a feature branch."
    exit 1
fi

echo "Pre-push checks passed."
exit 0

Test it.

git checkout main
git push origin main

Output:

Running pre-push checks...
ok      myproject/pkg/util    0.003s
ERROR: Cannot push directly to main. Use a feature branch.

The push was blocked. But there is a problem.

The bug

The hook checks the current branch name with git rev-parse --abbrev-ref HEAD. This tells you what branch you are on right now, not which remote branch you are pushing to.

Imagine this scenario: You are on a feature branch called deploy-fix. You run git push origin main. The hook checks the current branch, sees deploy-fix, and allows the push. You just pushed to main from a feature branch. The protection did nothing.

The pre-push hook receives ref information on stdin. Each line has four fields.

read local_ref local_sha remote_ref remote_sha

The remote_ref tells you where the commits are going. That is what you need to check.

The fix

Parse the remote ref from stdin.

#!/bin/bash

echo "Running pre-push checks..."

# Run tests
go test ./...
if [ $? -ne 0 ]; then
    echo "ERROR: Tests failed. Push aborted."
    exit 1
fi

# Read ref info from stdin
while read local_ref local_sha remote_ref remote_sha; do
    if [ "$remote_ref" = "refs/heads/main" ]; then
        echo "ERROR: Cannot push to main. Use a pull request."
        exit 1
    fi
done

echo "Pre-push checks passed."
exit 0

Now the hook checks the actual destination, not the current branch. Pushing to refs/heads/main is blocked regardless of which branch you are on.

Go version: a pre-push validator

package main

import (
	"bufio"
	"fmt"
	"os"
	"os/exec"
	"strings"
)

type PushRef struct {
	LocalRef  string
	LocalSHA  string
	RemoteRef string
	RemoteSHA string
}

func readPushRefs() []PushRef {
	var refs []PushRef
	scanner := bufio.NewScanner(os.Stdin)
	for scanner.Scan() {
		parts := strings.Fields(scanner.Text())
		if len(parts) == 4 {
			refs = append(refs, PushRef{
				LocalRef:  parts[0],
				LocalSHA:  parts[1],
				RemoteRef: parts[2],
				RemoteSHA: parts[3],
			})
		}
	}
	return refs
}

func runTests() error {
	cmd := exec.Command("go", "test", "./...")
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	return cmd.Run()
}

func checkProtectedBranches(refs []PushRef) error {
	protected := map[string]bool{
		"refs/heads/main":    true,
		"refs/heads/master":  true,
		"refs/heads/release": true,
	}

	for _, ref := range refs {
		if protected[ref.RemoteRef] {
			return fmt.Errorf("cannot push to protected branch: %s", ref.RemoteRef)
		}
	}
	return nil
}

func main() {
	fmt.Println("Running pre-push checks...")
	fmt.Println()

	refs := readPushRefs()

	// Check protected branches
	fmt.Print("Checking branch protection... ")
	if err := checkProtectedBranches(refs); err != nil {
		fmt.Println("FAILED")
		fmt.Printf("  %s\n", err)
		os.Exit(1)
	}
	fmt.Println("OK")

	// Run tests
	fmt.Println("Running tests...")
	if err := runTests(); err != nil {
		fmt.Println()
		fmt.Println("Tests failed. Push aborted.")
		os.Exit(1)
	}

	fmt.Println()
	fmt.Println("Pre-push checks passed.")
}

The Go version has a clear advantage: you can define a list of protected branches instead of checking one at a time. You can also add more branches later without changing the logic.

Build and install it.

go build -o .git/hooks/pre-push ./cmd/prepush/main.go

Step 3: Server-Side Post-Receive Hooks

Client-side hooks (pre-commit, pre-push) run on the developer’s machine. Server-side hooks run on the machine that receives the push. A post-receive hook runs after a push is fully accepted. It cannot reject the push, but it can trigger actions like builds or deployments.

Setting up a bare repository

Server-side hooks live in bare repositories. A bare repository has no working tree. It is what remote servers use.

mkdir -p /opt/repos/myproject.git
cd /opt/repos/myproject.git
git init --bare

Now create the post-receive hook.

touch /opt/repos/myproject.git/hooks/post-receive
chmod +x /opt/repos/myproject.git/hooks/post-receive

The shell hook

The post-receive hook reads lines from stdin. Each line has the old revision, new revision, and ref name.

#!/bin/bash

while read oldrev newrev refname; do
    echo "Ref updated: $refname"
    echo "  Old: $oldrev"
    echo "  New: $newrev"

    # Only trigger builds for the main branch
    if [ "$refname" = "refs/heads/main" ]; then
        echo "Triggering build for $newrev..."
        /opt/build.sh "$newrev"
    fi
done

The build script /opt/build.sh does the actual work.

#!/bin/bash
# /opt/build.sh
REVISION=$1
WORK_DIR="/opt/builds/$REVISION"

mkdir -p "$WORK_DIR"
git --git-dir=/opt/repos/myproject.git archive "$REVISION" | tar -x -C "$WORK_DIR"

cd "$WORK_DIR"
go build -o app ./cmd/server
echo "Build complete for $REVISION"

Push to the bare repository.

git remote add deploy ssh://server/opt/repos/myproject.git
git push deploy main

Output:

remote: Ref updated: refs/heads/main
remote:   Old: abc1234
remote:   New: def5678
remote: Triggering build for def5678...
remote: Build complete for def5678

It works. But there is a problem.

The bug

The build script runs synchronously. The git push command blocks until the build finishes. If the build takes five minutes, the developer stares at a frozen terminal for five minutes. This is not acceptable.

The fix

Run the build in the background. Write the PID to a file so you can track it later.

#!/bin/bash

while read oldrev newrev refname; do
    echo "Ref updated: $refname"

    if [ "$refname" = "refs/heads/main" ]; then
        echo "Starting build for $newrev in background..."
        nohup /opt/build.sh "$newrev" > "/opt/logs/build-$newrev.log" 2>&1 &
        BUILD_PID=$!
        echo "$BUILD_PID" > "/opt/logs/build-$newrev.pid"
        echo "Build started with PID $BUILD_PID. Check /opt/logs/build-$newrev.log for output."
    fi
done

Now the push returns immediately. The build runs in the background. You can check its status later.

# Check if build is still running
PID=$(cat /opt/logs/build-def5678.pid)
ps -p $PID > /dev/null && echo "Still running" || echo "Finished"

# Check the build log
tail -f /opt/logs/build-def5678.log

Go version: a post-receive handler

package main

import (
	"bufio"
	"fmt"
	"os"
	"os/exec"
	"strings"
	"time"
)

type RefUpdate struct {
	OldRev  string
	NewRev  string
	RefName string
}

func parseRefUpdates() []RefUpdate {
	var updates []RefUpdate
	scanner := bufio.NewScanner(os.Stdin)
	for scanner.Scan() {
		parts := strings.Fields(scanner.Text())
		if len(parts) == 3 {
			updates = append(updates, RefUpdate{
				OldRev:  parts[0],
				NewRev:  parts[1],
				RefName: parts[2],
			})
		}
	}
	return updates
}

func triggerBuild(rev string) {
	logFile := fmt.Sprintf("/opt/logs/build-%s.log", rev)
	pidFile := fmt.Sprintf("/opt/logs/build-%s.pid", rev)

	f, err := os.Create(logFile)
	if err != nil {
		fmt.Printf("  ERROR: Cannot create log file: %s\n", err)
		return
	}

	cmd := exec.Command("/opt/build.sh", rev)
	cmd.Stdout = f
	cmd.Stderr = f

	err = cmd.Start()
	if err != nil {
		fmt.Printf("  ERROR: Cannot start build: %s\n", err)
		f.Close()
		return
	}

	pid := cmd.Process.Pid
	os.WriteFile(pidFile, []byte(fmt.Sprintf("%d", pid)), 0644)
	fmt.Printf("  Build started with PID %d\n", pid)
	fmt.Printf("  Log: %s\n", logFile)

	// Release the process so it continues after this program exits
	go func() {
		cmd.Wait()
		f.Close()
	}()
}

func main() {
	updates := parseRefUpdates()
	timestamp := time.Now().Format("2006-01-02 15:04:05")

	for _, update := range updates {
		fmt.Printf("[%s] Ref updated: %s\n", timestamp, update.RefName)
		fmt.Printf("  %s -> %s\n", update.OldRev[:8], update.NewRev[:8])

		if update.RefName == "refs/heads/main" {
			fmt.Println("  Triggering build...")
			triggerBuild(update.NewRev)
		}
	}
}

Build and install it in the bare repository.

go build -o /opt/repos/myproject.git/hooks/post-receive ./cmd/postreceive/main.go

The Go version handles errors properly. If the build script cannot start, you get a clear error message. If it starts, you get the PID and log file path.


Step 4: HTTP Webhook Receiver

Git hooks work well for direct pushes. But most teams use hosted Git services. These services send HTTP webhooks when events happen. You need a server that listens for these webhooks.

Quick debugging with netcat

Before writing any code, use nc to see what a webhook request looks like.

nc -l -p 9000

In another terminal, send a test webhook.

curl -X POST \
  -H 'Content-Type: application/json' \
  -d '{"ref":"refs/heads/main","after":"abc123"}' \
  http://localhost:9000/webhook

The netcat terminal shows the raw HTTP request.

POST /webhook HTTP/1.1
Host: localhost:9000
Content-Type: application/json
Content-Length: 42

{"ref":"refs/heads/main","after":"abc123"}

Now you know what the server needs to handle. Kill netcat and build a real server.

Go version: basic HTTP webhook receiver

package main

import (
	"fmt"
	"io"
	"net/http"
	"time"
)

func webhookHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Cannot read body", http.StatusBadRequest)
		return
	}
	defer r.Body.Close()

	timestamp := time.Now().Format("15:04:05")
	fmt.Printf("[%s] Received webhook: %d bytes\n", timestamp, len(body))
	fmt.Printf("  Body: %s\n", string(body))

	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"status":"received"}`))
}

func main() {
	http.HandleFunc("/webhook", webhookHandler)

	fmt.Println("Webhook server listening on :9000")
	err := http.ListenAndServe(":9000", nil)
	if err != nil {
		fmt.Printf("Server error: %s\n", err)
	}
}

Run it and test.

go run main.go &
curl -X POST \
  -H 'Content-Type: application/json' \
  -d '{"ref":"refs/heads/main"}' \
  http://localhost:9000/webhook

Output:

Webhook server listening on :9000
[14:32:07] Received webhook: 25 bytes
  Body: {"ref":"refs/heads/main"}

It works. But there is a serious problem.

The bug

This server accepts any POST to /webhook. There is no authentication. Anyone who knows the URL can trigger builds. A random person on the internet, a bot scanning for open endpoints, or a disgruntled employee can send fake webhooks.

The fix

Most Git hosting services sign webhook payloads with a shared secret. They compute an HMAC-SHA256 hash of the request body and send it in the X-Hub-Signature-256 header. Your server must verify this signature.

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"net/http"
	"strings"
	"time"
)

const webhookSecret = "your-secret-key-here"

func verifySignature(body []byte, signatureHeader string) bool {
	if signatureHeader == "" {
		return false
	}

	// The header looks like: sha256=abc123def456...
	if !strings.HasPrefix(signatureHeader, "sha256=") {
		return false
	}

	receivedSig := signatureHeader[7:] // Remove "sha256=" prefix

	// Compute expected signature
	mac := hmac.New(sha256.New, []byte(webhookSecret))
	mac.Write(body)
	expectedSig := hex.EncodeToString(mac.Sum(nil))

	// Use hmac.Equal for constant-time comparison (prevents timing attacks)
	return hmac.Equal([]byte(receivedSig), []byte(expectedSig))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Cannot read body", http.StatusBadRequest)
		return
	}
	defer r.Body.Close()

	timestamp := time.Now().Format("15:04:05")

	// Verify HMAC signature
	signature := r.Header.Get("X-Hub-Signature-256")
	if !verifySignature(body, signature) {
		fmt.Printf("[%s] REJECTED: Invalid signature\n", timestamp)
		http.Error(w, "Invalid signature", http.StatusUnauthorized)
		return
	}

	fmt.Printf("[%s] VERIFIED: Webhook received (%d bytes)\n", timestamp, len(body))

	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"status":"accepted"}`))
}

func main() {
	http.HandleFunc("/webhook", webhookHandler)

	fmt.Println("Webhook server listening on :9000")
	fmt.Println("HMAC verification: enabled")
	err := http.ListenAndServe(":9000", nil)
	if err != nil {
		fmt.Printf("Server error: %s\n", err)
	}
}

Test with a valid signature.

# Compute the signature for the payload
PAYLOAD='{"ref":"refs/heads/main"}'
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "your-secret-key-here" | awk '{print $2}')

curl -X POST \
  -H 'Content-Type: application/json' \
  -H "X-Hub-Signature-256: sha256=$SIGNATURE" \
  -d "$PAYLOAD" \
  http://localhost:9000/webhook

Output:

[14:35:12] VERIFIED: Webhook received (25 bytes)

Test with an invalid signature.

curl -X POST \
  -H 'Content-Type: application/json' \
  -H 'X-Hub-Signature-256: sha256=0000000000000000000000000000000000000000000000000000000000000000' \
  -d '{"ref":"refs/heads/main"}' \
  http://localhost:9000/webhook

Output:

[14:35:18] REJECTED: Invalid signature

The hmac.Equal function is important. It does a constant-time comparison. A regular == comparison returns faster when the first differing byte is found. An attacker can measure this timing difference to guess the secret one byte at a time. Constant-time comparison always takes the same amount of time regardless of where the strings differ.


Step 5: Webhook Payload Parsing and Filtering

The server can receive and verify webhooks. Now it needs to understand what happened. The webhook payload contains the branch name, commit hashes, and changed files.

Inspecting payloads with jq

Use jq to explore the structure of a webhook payload.

# Save a sample payload to a file
cat > payload.json << 'EOF'
{
  "ref": "refs/heads/main",
  "before": "abc1234567890",
  "after": "def5678901234",
  "commits": [
    {
      "id": "def5678901234",
      "message": "Update config and add new handler",
      "added": ["pkg/handler/new.go"],
      "modified": ["config.yaml", "main.go"],
      "removed": []
    }
  ],
  "repository": {
    "name": "myproject",
    "full_name": "org/myproject"
  },
  "pusher": {
    "name": "developer1"
  }
}
EOF

Extract specific fields.

# Get the branch name
jq '.ref' payload.json
# Output: "refs/heads/main"

# Get all modified files across all commits
jq '.commits[].modified[]' payload.json
# Output:
# "config.yaml"
# "main.go"

# Get the pusher name
jq '.pusher.name' payload.json
# Output: "developer1"

# Get added, modified, and removed files in one query
jq '[.commits[] | (.added + .modified + .removed)[]] | unique' payload.json
# Output: ["config.yaml", "main.go", "pkg/handler/new.go"]

Go version: parse the webhook payload

Define Go structs that match the JSON structure.

package main

import (
	"encoding/json"
	"fmt"
	"strings"
)

type WebhookPayload struct {
	Ref        string     `json:"ref"`
	Before     string     `json:"before"`
	After      string     `json:"after"`
	Commits    []Commit   `json:"commits"`
	Repository Repository `json:"repository"`
	Pusher     Pusher     `json:"pusher"`
}

type Commit struct {
	ID       string   `json:"id"`
	Message  string   `json:"message"`
	Added    []string `json:"added"`
	Modified []string `json:"modified"`
	Removed  []string `json:"removed"`
}

type Repository struct {
	Name     string `json:"name"`
	FullName string `json:"full_name"`
}

type Pusher struct {
	Name string `json:"name"`
}

func (p *WebhookPayload) Branch() string {
	return strings.TrimPrefix(p.Ref, "refs/heads/")
}

func (p *WebhookPayload) ChangedFiles() []string {
	seen := make(map[string]bool)
	var files []string
	for _, commit := range p.Commits {
		for _, f := range commit.Added {
			if !seen[f] {
				seen[f] = true
				files = append(files, f)
			}
		}
		for _, f := range commit.Modified {
			if !seen[f] {
				seen[f] = true
				files = append(files, f)
			}
		}
		for _, f := range commit.Removed {
			if !seen[f] {
				seen[f] = true
				files = append(files, f)
			}
		}
	}
	return files
}

func main() {
	raw := `{
		"ref": "refs/heads/main",
		"before": "abc1234567890",
		"after": "def5678901234",
		"commits": [{
			"id": "def5678901234",
			"message": "Update config",
			"added": ["new.go"],
			"modified": ["main.go"],
			"removed": ["old.go"]
		}],
		"repository": {"name": "myproject", "full_name": "org/myproject"},
		"pusher": {"name": "developer1"}
	}`

	var payload WebhookPayload
	err := json.Unmarshal([]byte(raw), &payload)
	if err != nil {
		fmt.Printf("Parse error: %s\n", err)
		return
	}

	fmt.Printf("Repository: %s\n", payload.Repository.FullName)
	fmt.Printf("Branch:     %s\n", payload.Branch())
	fmt.Printf("Pusher:     %s\n", payload.Pusher.Name)
	fmt.Printf("Commit:     %s\n", payload.After[:8])
	fmt.Printf("Changed files:\n")
	for _, f := range payload.ChangedFiles() {
		fmt.Printf("  - %s\n", f)
	}
}

Output:

Repository: org/myproject
Branch:     main
Pusher:     developer1
Commit:     def56789
Changed files:
  - new.go
  - main.go
  - old.go

Now add branch filtering. You probably only want to build when specific branches are pushed to.

The bug

A common mistake is comparing the raw ref field directly with the branch name.

// Bug: this never matches
if payload.Ref == "main" {
    fmt.Println("Building main branch...")
}

The ref field contains "refs/heads/main", not "main". The comparison always fails. No builds are ever triggered. The server receives webhooks, logs them, and does nothing.

This is a silent failure. There is no error message. The server looks like it is working. You only notice the problem when you realize builds are not running.

The fix

Use strings.TrimPrefix to strip the refs/heads/ prefix before comparing.

func (p *WebhookPayload) Branch() string {
	return strings.TrimPrefix(p.Ref, "refs/heads/")
}

// Now this works
if payload.Branch() == "main" {
    fmt.Println("Building main branch...")
}

The Branch() method handles this once. Every place that needs the branch name calls this method instead of parsing the ref again.

Here is the complete filtering logic with support for multiple branches and path-based filtering.

package main

import (
	"encoding/json"
	"fmt"
	"strings"
)

type WebhookPayload struct {
	Ref        string     `json:"ref"`
	Before     string     `json:"before"`
	After      string     `json:"after"`
	Commits    []Commit   `json:"commits"`
	Repository Repository `json:"repository"`
	Pusher     Pusher     `json:"pusher"`
}

type Commit struct {
	ID       string   `json:"id"`
	Message  string   `json:"message"`
	Added    []string `json:"added"`
	Modified []string `json:"modified"`
	Removed  []string `json:"removed"`
}

type Repository struct {
	Name     string `json:"name"`
	FullName string `json:"full_name"`
}

type Pusher struct {
	Name string `json:"name"`
}

type BuildConfig struct {
	Branches    []string // Branches that trigger builds
	PathFilters []string // Only build if files in these paths changed
}

func (p *WebhookPayload) Branch() string {
	return strings.TrimPrefix(p.Ref, "refs/heads/")
}

func (p *WebhookPayload) ChangedFiles() []string {
	seen := make(map[string]bool)
	var files []string
	for _, commit := range p.Commits {
		all := make([]string, 0)
		all = append(all, commit.Added...)
		all = append(all, commit.Modified...)
		all = append(all, commit.Removed...)
		for _, f := range all {
			if !seen[f] {
				seen[f] = true
				files = append(files, f)
			}
		}
	}
	return files
}

func shouldBuild(payload *WebhookPayload, config BuildConfig) (bool, string) {
	// Check branch
	branch := payload.Branch()
	branchMatch := false
	for _, b := range config.Branches {
		if b == branch {
			branchMatch = true
			break
		}
	}
	if !branchMatch {
		return false, fmt.Sprintf("branch %q not in build list", branch)
	}

	// Check path filters (if configured)
	if len(config.PathFilters) > 0 {
		files := payload.ChangedFiles()
		for _, file := range files {
			for _, prefix := range config.PathFilters {
				if strings.HasPrefix(file, prefix) {
					return true, fmt.Sprintf("file %q matches filter %q", file, prefix)
				}
			}
		}
		return false, "no changed files match path filters"
	}

	return true, "branch matches, no path filters"
}

func main() {
	raw := `{
		"ref": "refs/heads/main",
		"after": "def5678901234",
		"commits": [{
			"id": "def5678901234",
			"message": "Update docs only",
			"added": [],
			"modified": ["docs/readme.md"],
			"removed": []
		}],
		"repository": {"name": "myproject", "full_name": "org/myproject"},
		"pusher": {"name": "developer1"}
	}`

	var payload WebhookPayload
	json.Unmarshal([]byte(raw), &payload)

	config := BuildConfig{
		Branches:    []string{"main", "staging"},
		PathFilters: []string{"cmd/", "pkg/", "internal/"},
	}

	build, reason := shouldBuild(&payload, config)
	fmt.Printf("Should build: %v\n", build)
	fmt.Printf("Reason: %s\n", reason)
}

Output:

Should build: false
Reason: no changed files match path filters

The push changed docs/readme.md, but the build config only watches cmd/, pkg/, and internal/. No build is triggered. This prevents wasting build time on documentation-only changes.


Step 6: Complete Webhook Server With Build Triggering

Now combine everything from the previous steps into one server. This server receives webhooks, validates HMAC signatures, parses payloads, filters by branch and path, queues builds, and reports status.

Architecture overview

The server has three parts:

  1. HTTP handler: receives webhooks and validates signatures
  2. Build queue: ensures only one build runs at a time
  3. Status endpoint: returns current build state as JSON

Linux: testing the complete flow

Before looking at the code, set up a test environment.

# Create a build script
mkdir -p /tmp/webhook-test
cat > /tmp/webhook-test/build.sh << 'SCRIPT'
#!/bin/bash
echo "Building revision $1..."
sleep 2
echo "Running tests..."
sleep 1
echo "Build complete."
exit 0
SCRIPT
chmod +x /tmp/webhook-test/build.sh

Generate the HMAC signature for a test payload.

SECRET="my-webhook-secret"
PAYLOAD='{"ref":"refs/heads/main","after":"abc123","commits":[{"id":"abc123","message":"deploy fix","added":[],"modified":["main.go"],"removed":[]}],"repository":{"name":"myproject","full_name":"org/myproject"},"pusher":{"name":"dev1"}}'

SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')

echo "Signature: sha256=$SIGNATURE"

Go: the complete server

This is the full server. Read through it section by section.

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os/exec"
	"strings"
	"sync"
	"time"
)

// --- Configuration ---

type Config struct {
	Port         int
	Secret       string
	BuildScript  string
	Branches     []string
	PathFilters  []string
}

func defaultConfig() Config {
	return Config{
		Port:        9000,
		Secret:      "my-webhook-secret",
		BuildScript: "/tmp/webhook-test/build.sh",
		Branches:    []string{"main"},
		PathFilters: []string{},
	}
}

// --- Webhook payload types ---

type WebhookPayload struct {
	Ref        string     `json:"ref"`
	Before     string     `json:"before"`
	After      string     `json:"after"`
	Commits    []Commit   `json:"commits"`
	Repository Repository `json:"repository"`
	Pusher     Pusher     `json:"pusher"`
}

type Commit struct {
	ID       string   `json:"id"`
	Message  string   `json:"message"`
	Added    []string `json:"added"`
	Modified []string `json:"modified"`
	Removed  []string `json:"removed"`
}

type Repository struct {
	Name     string `json:"name"`
	FullName string `json:"full_name"`
}

type Pusher struct {
	Name string `json:"name"`
}

func (p *WebhookPayload) Branch() string {
	return strings.TrimPrefix(p.Ref, "refs/heads/")
}

func (p *WebhookPayload) ChangedFiles() []string {
	seen := make(map[string]bool)
	var files []string
	for _, c := range p.Commits {
		all := make([]string, 0, len(c.Added)+len(c.Modified)+len(c.Removed))
		all = append(all, c.Added...)
		all = append(all, c.Modified...)
		all = append(all, c.Removed...)
		for _, f := range all {
			if !seen[f] {
				seen[f] = true
				files = append(files, f)
			}
		}
	}
	return files
}

// --- HMAC verification ---

func verifySignature(body []byte, signatureHeader string, secret string) bool {
	if signatureHeader == "" {
		return false
	}
	if !strings.HasPrefix(signatureHeader, "sha256=") {
		return false
	}

	receivedSig := signatureHeader[7:]

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write(body)
	expectedSig := hex.EncodeToString(mac.Sum(nil))

	return hmac.Equal([]byte(receivedSig), []byte(expectedSig))
}

// --- Build queue ---

type BuildStatus struct {
	State     string    `json:"state"`     // idle, building, success, failed
	Revision  string    `json:"revision"`
	Branch    string    `json:"branch"`
	Pusher    string    `json:"pusher"`
	StartedAt time.Time `json:"started_at,omitempty"`
	Duration  string    `json:"duration,omitempty"`
	Output    string    `json:"output,omitempty"`
}

type BuildQueue struct {
	mu      sync.Mutex
	current BuildStatus
	running bool
}

func NewBuildQueue() *BuildQueue {
	return &BuildQueue{
		current: BuildStatus{State: "idle"},
	}
}

func (q *BuildQueue) Status() BuildStatus {
	q.mu.Lock()
	defer q.mu.Unlock()
	return q.current
}

func (q *BuildQueue) Enqueue(payload *WebhookPayload, buildScript string) (bool, string) {
	q.mu.Lock()

	if q.running {
		q.mu.Unlock()
		return false, "build already in progress"
	}

	q.running = true
	q.current = BuildStatus{
		State:     "building",
		Revision:  payload.After,
		Branch:    payload.Branch(),
		Pusher:    payload.Pusher.Name,
		StartedAt: time.Now(),
	}
	q.mu.Unlock()

	// Run build in background
	go q.runBuild(payload, buildScript)

	return true, "build started"
}

func (q *BuildQueue) runBuild(payload *WebhookPayload, buildScript string) {
	start := time.Now()
	rev := payload.After

	cmd := exec.Command(buildScript, rev)
	output, err := cmd.CombinedOutput()

	q.mu.Lock()
	defer q.mu.Unlock()

	q.running = false
	q.current.Duration = time.Since(start).Round(time.Millisecond).String()
	q.current.Output = string(output)

	if err != nil {
		q.current.State = "failed"
		logColor("\033[31m", "BUILD FAILED", rev, time.Since(start))
	} else {
		q.current.State = "success"
		logColor("\033[32m", "BUILD SUCCESS", rev, time.Since(start))
	}
}

// --- Filtering ---

func shouldBuild(payload *WebhookPayload, config Config) (bool, string) {
	branch := payload.Branch()

	branchMatch := false
	for _, b := range config.Branches {
		if b == branch {
			branchMatch = true
			break
		}
	}
	if !branchMatch {
		return false, fmt.Sprintf("branch %q not in build list", branch)
	}

	if len(config.PathFilters) > 0 {
		files := payload.ChangedFiles()
		for _, file := range files {
			for _, prefix := range config.PathFilters {
				if strings.HasPrefix(file, prefix) {
					return true, fmt.Sprintf("file %q matches filter %q", file, prefix)
				}
			}
		}
		return false, "no changed files match path filters"
	}

	return true, "branch matches"
}

// --- Logging ---

func logColor(color string, label string, rev string, duration time.Duration) {
	reset := "\033[0m"
	ts := time.Now().Format("15:04:05")
	fmt.Printf("[%s] %s%s%s %s (%s)\n", ts, color, label, reset, rev, duration)
}

func logEvent(format string, args ...interface{}) {
	ts := time.Now().Format("15:04:05")
	msg := fmt.Sprintf(format, args...)
	fmt.Printf("[%s] %s\n", ts, msg)
}

// --- HTTP handlers ---

type Server struct {
	config Config
	queue  *BuildQueue
}

func NewServer(config Config) *Server {
	return &Server{
		config: config,
		queue:  NewBuildQueue(),
	}
}

func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Cannot read body", http.StatusBadRequest)
		return
	}
	defer r.Body.Close()

	// Step 1: Verify signature
	signature := r.Header.Get("X-Hub-Signature-256")
	if !verifySignature(body, signature, s.config.Secret) {
		logEvent("\033[31mREJECTED\033[0m: Invalid signature from %s", r.RemoteAddr)
		http.Error(w, "Invalid signature", http.StatusUnauthorized)
		return
	}
	logEvent("\033[33mWEBHOOK\033[0m received from %s (%d bytes)", r.RemoteAddr, len(body))

	// Step 2: Parse payload
	var payload WebhookPayload
	err = json.Unmarshal(body, &payload)
	if err != nil {
		logEvent("  Parse error: %s", err)
		http.Error(w, "Invalid JSON", http.StatusBadRequest)
		return
	}
	logEvent("  Repository: %s", payload.Repository.FullName)
	logEvent("  Branch: %s", payload.Branch())
	logEvent("  Pusher: %s", payload.Pusher.Name)
	logEvent("  Revision: %s", payload.After)

	// Step 3: Check if we should build
	build, reason := shouldBuild(&payload, s.config)
	if !build {
		logEvent("  \033[33mSKIPPED\033[0m: %s", reason)
		w.WriteHeader(http.StatusOK)
		json.NewEncoder(w).Encode(map[string]string{
			"status": "skipped",
			"reason": reason,
		})
		return
	}

	// Step 4: Queue the build
	started, msg := s.queue.Enqueue(&payload, s.config.BuildScript)
	if !started {
		logEvent("  \033[33mQUEUED\033[0m: %s", msg)
		w.WriteHeader(http.StatusConflict)
		json.NewEncoder(w).Encode(map[string]string{
			"status": "rejected",
			"reason": msg,
		})
		return
	}

	logEvent("  \033[32mBUILD STARTED\033[0m: %s", payload.After)

	w.WriteHeader(http.StatusAccepted)
	json.NewEncoder(w).Encode(map[string]string{
		"status":   "building",
		"revision": payload.After,
	})
}

func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	status := s.queue.Status()
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(status)
}

func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"status":"healthy"}`))
}

func main() {
	config := defaultConfig()
	server := NewServer(config)

	http.HandleFunc("/webhook", server.handleWebhook)
	http.HandleFunc("/status", server.handleStatus)
	http.HandleFunc("/health", server.handleHealth)

	fmt.Println("==========================================")
	fmt.Println("  Webhook Server")
	fmt.Println("==========================================")
	fmt.Printf("  Port:        %d\n", config.Port)
	fmt.Printf("  Branches:    %v\n", config.Branches)
	fmt.Printf("  Build script: %s\n", config.BuildScript)
	fmt.Printf("  HMAC:        enabled\n")
	fmt.Println("==========================================")
	fmt.Println()

	addr := fmt.Sprintf(":%d", config.Port)
	logEvent("Listening on %s", addr)

	err := http.ListenAndServe(addr, nil)
	if err != nil {
		fmt.Printf("Server error: %s\n", err)
	}
}

Running the complete server

Start the server.

go run main.go

Output:

==========================================
  Webhook Server
==========================================
  Port:        9000
  Branches:    [main]
  Build script: /tmp/webhook-test/build.sh
  HMAC:        enabled
==========================================

[14:45:00] Listening on :9000

Send a valid webhook.

SECRET="my-webhook-secret"
PAYLOAD='{"ref":"refs/heads/main","after":"abc12345","commits":[{"id":"abc12345","message":"fix login bug","added":[],"modified":["main.go","pkg/auth/login.go"],"removed":[]}],"repository":{"name":"myproject","full_name":"org/myproject"},"pusher":{"name":"karandeep"}}'

SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')

curl -s -X POST \
  -H 'Content-Type: application/json' \
  -H "X-Hub-Signature-256: sha256=$SIGNATURE" \
  -d "$PAYLOAD" \
  http://localhost:9000/webhook | jq .

Response:

{
  "status": "building",
  "revision": "abc12345"
}

Server output:

[14:45:30] WEBHOOK received from 127.0.0.1:52340 (244 bytes)
[14:45:30]   Repository: org/myproject
[14:45:30]   Branch: main
[14:45:30]   Pusher: karandeep
[14:45:30]   Revision: abc12345
[14:45:30]   BUILD STARTED: abc12345
[14:45:33] BUILD SUCCESS abc12345 (3.012s)

Check the build status.

curl -s http://localhost:9000/status | jq .

Response:

{
  "state": "success",
  "revision": "abc12345",
  "branch": "main",
  "pusher": "karandeep",
  "started_at": "2025-01-01T14:45:30Z",
  "duration": "3.012s",
  "output": "Building revision abc12345...\nRunning tests...\nBuild complete.\n"
}

Send a webhook for a non-main branch.

PAYLOAD='{"ref":"refs/heads/feature/docs","after":"xyz999","commits":[{"id":"xyz999","message":"update readme","added":[],"modified":["README.md"],"removed":[]}],"repository":{"name":"myproject","full_name":"org/myproject"},"pusher":{"name":"karandeep"}}'

SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')

curl -s -X POST \
  -H 'Content-Type: application/json' \
  -H "X-Hub-Signature-256: sha256=$SIGNATURE" \
  -d "$PAYLOAD" \
  http://localhost:9000/webhook | jq .

Response:

{
  "status": "skipped",
  "reason": "branch \"feature/docs\" not in build list"
}

Server output:

[14:46:00] WEBHOOK received from 127.0.0.1:52341 (198 bytes)
[14:46:00]   Repository: org/myproject
[14:46:00]   Branch: feature/docs
[14:46:00]   Pusher: karandeep
[14:46:00]   Revision: xyz999
[14:46:00]   SKIPPED: branch "feature/docs" not in build list

Send a webhook with a bad signature.

curl -s -X POST \
  -H 'Content-Type: application/json' \
  -H 'X-Hub-Signature-256: sha256=badhash' \
  -d '{"ref":"refs/heads/main"}' \
  http://localhost:9000/webhook

Server output:

[14:46:10] REJECTED: Invalid signature from 127.0.0.1:52342

The server rejected the request with a 401 status code.

What the server does at each stage

Here is the flow for every incoming request:

  1. Receive: Accept the HTTP POST and read the body
  2. Verify: Compute HMAC-SHA256 and compare with the signature header
  3. Parse: Decode the JSON payload into Go structs
  4. Filter: Check if the branch and changed files match the build config
  5. Queue: Start the build if nothing else is running
  6. Execute: Run the build script with the revision as an argument
  7. Report: Update the status with the result

Each stage can reject the request with a clear reason. The /status endpoint always returns the latest build state.


Summary

You started with a shell script in .git/hooks/pre-commit and ended with a full HTTP server in Go. Here is what each step covered.

StepHook / ComponentWhat It Does
1pre-commitBlocks commits with debug statements or large files
2pre-pushBlocks pushes to protected branches, runs tests
3post-receiveTriggers background builds on a bare repository
4HTTP receiverListens for webhook POST requests with HMAC verification
5Payload parserExtracts branch, files, and filters by configuration
6Complete serverCombines all pieces with build queue and status endpoint

The key patterns to remember:

  • Client-side hooks (pre-commit, pre-push) prevent mistakes before they enter the repository. They run on the developer’s machine.
  • Server-side hooks (post-receive) trigger actions after a push is accepted. They run on the server that hosts the repository.
  • Webhook servers are HTTP services that respond to push events from hosted Git services. They need HMAC verification to prevent unauthorized requests.
  • Always use strings.TrimPrefix when comparing the ref field. The raw value is refs/heads/main, not main.
  • Always use hmac.Equal for signature comparison. Regular string comparison is vulnerable to timing attacks.
  • Background builds prevent blocking the Git push. Write the PID to a file and provide a status endpoint.

Every Go program in this article uses only the standard library. You can copy any of them into your project and compile with go build. No dependencies to manage.


External Resources

Keep Reading

Contents