# Git Hooks: From Shell Scripts to a Go Webhook Server

- URL: https://karandeepsingh.ca/posts/git-hooks-to-go-webhook-server/
- Author: Karandeep Singh
- Published: 2022-11-12
- Updated: 2025-01-01
- Series: Linux Commands to Go
- Tags: golang, automation, cicd
- 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.

---

![Network switch in a server rack powering automation workflows](https://karandeepsingh.ca/images/posts/jenkins-hacks.jpg)

## 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.

```bash
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.

```bash
#!/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.

```bash
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:

```text
Running pre-commit checks...
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.

And the large-file check is broken in its own right. The `find ... | head -5` pipeline means `$?` holds `head`'s exit status, not `find`'s — and `head` almost always exits 0, whether or not any large files were found. So if that block ever did run, it would print "Files larger than 1MB found." and abort on every single commit.

### The fix

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

```bash
#!/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.

```go
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.

```bash
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

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

This hook runs tests and checks the branch name.

```bash
#!/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.

```bash
git checkout main
git push origin main
```

Output:

```bash
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.

```bash
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.

The fix swaps the current-branch check for a loop over the refs Git sends on stdin:

```diff
# pre-push
@@ -9,12 +9,13 @@
     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
+# 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
```

The full listing, for reference:

```bash
#!/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

```go
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.

```bash
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.

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

Now create the post-receive hook.

```bash
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.

```bash
#!/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.

```bash
#!/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.

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

Output:

```bash
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.

```bash
#!/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.

```bash
# 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

```go
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.

```bash
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.

```bash
nc -l -p 9000
```

In another terminal, send a test webhook.

```bash
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.

```bash
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

```go
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.

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

Output:

```bash
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.

```go
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.

```bash
# 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:

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

Test with an invalid signature.

```bash
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:

```bash
[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.

```bash
# 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.

```bash
# 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.

```go
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:

```bash
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.

```go
// 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.

```go
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.

The payload structs are untouched; what is new is BuildConfig and shouldBuild:

```diff
# main.go
@@ -32,6 +32,11 @@
 	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/")
 }
@@ -40,19 +45,11 @@
 	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 {
+		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)
@@ -62,35 +59,60 @@
 	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",
-		"before": "abc1234567890",
 		"after": "def5678901234",
 		"commits": [{
 			"id": "def5678901234",
-			"message": "Update config",
-			"added": ["new.go"],
-			"modified": ["main.go"],
-			"removed": ["old.go"]
+			"message": "Update docs only",
+			"added": [],
+			"modified": ["docs/readme.md"],
+			"removed": []
 		}],
 		"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
+	json.Unmarshal([]byte(raw), &payload)
+
+	config := BuildConfig{
+		Branches:    []string{"main", "staging"},
+		PathFilters: []string{"cmd/", "pkg/", "internal/"},
 	}
 
-	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)
-	}
+	build, reason := shouldBuild(&payload, config)
+	fmt.Printf("Should build: %v\n", build)
+	fmt.Printf("Reason: %s\n", reason)
 }
```

The full listing, for reference:

```go
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:

```bash
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.

```bash
# 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.

```bash
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.

```go
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.

```bash
go run main.go
```

Output:

```bash
==========================================
  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.

```bash
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:

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

Server output:

```text
[14:45:30] WEBHOOK received from 127.0.0.1:52340 (259 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.

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

Response:

```json
{
  "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.

```bash
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:

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

Server output:

```text
[14:46:00] WEBHOOK received from 127.0.0.1:52341 (245 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.

```bash
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:

```bash
[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.

| Step | Hook / Component | What It Does |
|------|-----------------|--------------|
| 1 | pre-commit | Blocks commits with debug statements or large files |
| 2 | pre-push | Blocks pushes to protected branches, runs tests |
| 3 | post-receive | Triggers background builds on a bare repository |
| 4 | HTTP receiver | Listens for webhook POST requests with HMAC verification |
| 5 | Payload parser | Extracts branch, files, and filters by configuration |
| 6 | Complete server | Combines 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

- [Git Hooks Documentation](https://git-scm.com/docs/githooks)
- [Pro Git Book: Customizing Git Hooks](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks)
- [Go net/http Package](https://pkg.go.dev/net/http)
- [Go crypto/hmac Package](https://pkg.go.dev/crypto/hmac)

## Keep Reading

- [CI Pipeline Basics: From Shell Scripts to a Go Build Runner](https://karandeepsingh.ca/posts/jenkins-devops-best-practices/) — build the CI runner that your webhook server triggers on push events.
- [Deployment Automation: From SSH Scripts to a Go Deploy Tool](https://karandeepsingh.ca/posts/deployment-automation-ssh-to-go/) — chain deployments after successful builds triggered by your webhook server.
- [Remote Server Configuration: From SSH Loops to a Go Config Tool](https://karandeepsingh.ca/posts/remote-server-config-ssh-to-go/) — push configuration to the servers your pipeline deploys to.
