Gitaly Server Hook for Commit Validation¶
Overview¶
A global Gitaly pre-receive server hook that validates commit messages on push to the main branch across all projects on the GitLab instance.
What it does:
- On
git pushtomain(only) — validates every commit against the Conventional Commits format - Invalid commit → push is rejected with a descriptive error
- Pushes to other branches — allowed without validation
Info
GitLab Push Rules (UI-based message validation) require GitLab Premium. Since we run GitLab CE, server hooks are the only mechanism for server-side commit validation.
Components¶
| File | Purpose |
|---|---|
TBD-GitalyCommitHook |
Pre-receive hook script — reads stdin, iterates commits, calls validation functions |
TBD-CommitTypes |
Single source of truth (SSOT) for commit types, regex patterns, and validation functions. Sourced by the hook at runtime. |
Both files originate from the ICVR/General/BUILD_SCRIPTS repository (Version/ directory) and must be manually deployed to the Gitaly server.
Installation¶
Configure the Custom Hooks Directory¶
Verify the configuration in /etc/gitlab/gitlab.rb:
If the directive is missing, add it and apply:
Create the Directory Structure¶
sudo mkdir -p /var/opt/gitlab/gitaly/custom_hooks/pre-receive.d
sudo mkdir -p /var/opt/gitlab/gitaly/custom_hooks/lib
GitLab executes all files in pre-receive.d/ in alphabetical order. If any file returns a non-zero exit code, the push is rejected. The lib/ directory is not executed — it stores shared libraries sourced by hooks.
Deploy Files¶
Copy from the buildscripts repository:
sudo cp TBD-GitalyCommitHook /var/opt/gitlab/gitaly/custom_hooks/pre-receive.d/TBD-GitalyCommitHook
sudo cp TBD-CommitTypes /var/opt/gitlab/gitaly/custom_hooks/lib/TBD-CommitTypes
sudo chmod +x /var/opt/gitlab/gitaly/custom_hooks/pre-receive.d/TBD-GitalyCommitHook
sudo chmod +x /var/opt/gitlab/gitaly/custom_hooks/lib/TBD-CommitTypes
sudo chown git:git /var/opt/gitlab/gitaly/custom_hooks/pre-receive.d/TBD-GitalyCommitHook
sudo chown git:git /var/opt/gitlab/gitaly/custom_hooks/lib/TBD-CommitTypes
Resulting Directory Structure¶
/var/opt/gitlab/gitaly/custom_hooks/
├── pre-receive.d/
│ └── TBD-GitalyCommitHook # Pre-receive hook (executed by GitLab)
└── lib/
└── TBD-CommitTypes # SSOT for types (sourced by hook via hardcoded path)
The hook sources TBD-CommitTypes from the lib/ directory:
Note
TBD-CommitTypes has a source guard (return 0 on double-load). It defines no executable logic when called directly — only variables and functions that become available after sourcing.
How It Works¶
Execution Flow¶
git push
│
▼
GitLab/Gitaly invokes all scripts in pre-receive.d/ (alphabetical order)
│
▼
TBD-GitalyCommitHook receives stdin: <old_sha> <new_sha> <ref_name>
│
├─ ref_name != refs/heads/main → skip (exit 0)
├─ Branch deletion (new_sha = 000...0) → skip
├─ New branch creation (old_sha = 000...0) → skip
│
├─ For each commit in old_sha..new_sha:
│ ├─ Merge commit (Merge branch ...) → pass (valid)
│ ├─ Valid format (type: description) → pass
│ └─ Invalid format → print GL-HOOK-ERR → increment error count
│
└─ errors > 0 → exit 1 (push rejected)
errors = 0 → exit 0 (push accepted)
Validation Rules¶
The hook calls tbd_validate_message() from TBD-CommitTypes. A commit message is valid if it matches either:
- Conventional Commits format:
<type>[(<scope>)]: <description> - Merge commit pattern:
Merge branch ...orMerge remote-tracking branch ...
Valid commit types:
| Type | Example |
|---|---|
feat |
feat: add user dashboard |
fix |
fix(auth): resolve token expiry |
chore |
chore: update dependencies |
remove |
remove: drop legacy endpoint |
BREAKING CHANGE |
BREAKING CHANGE: remove v1 API |
BREAKING-CHANGE |
BREAKING-CHANGE: remove v1 API |
Error Output¶
Invalid commits produce GL-HOOK-ERR: prefixed messages, which GitLab displays to the user:
remote: GL-HOOK-ERR: Commit a1b2c3d4 has invalid message: 'bad commit'
remote: GL-HOOK-ERR: Expected: <type>(<scope>): <description>
remote: GL-HOOK-ERR: Valid types: feat, fix, chore, remove, BREAKING CHANGE, BREAKING-CHANGE
remote: GL-HOOK-ERR: Examples: 'feat: add user login', 'fix(auth): resolve token expiry'
Verification¶
Server-Side Test¶
# Test: non-main branch — should pass (exit 0)
echo "aaa000 bbb111 refs/heads/feature/test" | \
sudo -u git /var/opt/gitlab/gitaly/custom_hooks/pre-receive.d/TBD-GitalyCommitHook
echo $? # Expected: 0
Client-Side Test¶
# Invalid commit — push to main should be rejected
git commit --allow-empty -m "bad commit"
git push origin main
# → remote: GL-HOOK-ERR: Commit xxxxxxxx has invalid message: 'bad commit'
# → rejected
# Valid commit — push to main should succeed
git commit --allow-empty -m "feat: test hook"
git push origin main
# → OK
Updating Commit Types¶
When TBD-CommitTypes is updated in the buildscripts repository, redeploy it to the Gitaly server:
sudo cp TBD-CommitTypes /var/opt/gitlab/gitaly/custom_hooks/lib/TBD-CommitTypes
sudo chown git:git /var/opt/gitlab/gitaly/custom_hooks/lib/TBD-CommitTypes
No service restart is required — Gitaly reads the scripts on each push.
Info
TBD-CommitTypes contains all type definitions, regex patterns, and validation functions (tbd_validate_message, tbd_print_format_hint). The hook script itself contains no validation logic — it only calls functions from the SSOT.
Architecture: CI vs Gitaly Validation¶
Commit message validation exists in two contexts, both using TBD-CommitTypes as the single source of truth:
┌──────────────────────────────────┬──────────────────────────────────┐
│ CI Runner │ Gitaly Server │
├──────────────────────────────────┼──────────────────────────────────┤
│ TBD-ValidateCommitMessage │ TBD-GitalyCommitHook │
│ source Config (Logs, Errors) │ (standalone, no dependencies) │
│ source TBD-CommitTypes │ source TBD-CommitTypes │
│ → validates MR title │ → validates push commits │
│ → runs in MR pipeline │ → runs on every push to main │
├──────────────────────────────────┴──────────────────────────────────┤
│ TBD-CommitTypes (single source of truth) │
│ │
│ Types: feat, fix, chore, remove, BREAKING CHANGE, BREAKING-CHANGE │
│ Patterns: TBD_VALID_PATTERN, TBD_MERGE_PATTERN, ... │
│ Functions: tbd_validate_message(), tbd_print_format_hint() │
│ Mappings: tbd_type_bump(), tbd_type_section() │
└─────────────────────────────────────────────────────────────────────┘
| Aspect | CI (MR Pipeline) | Gitaly (Server Hook) |
|---|---|---|
| Script | TBD-ValidateCommitMessage |
TBD-GitalyCommitHook |
| Validates | MR title (CI_MERGE_REQUEST_TITLE) |
Individual commit messages on push |
| When | MR pipeline (open/update MR) | Every git push to main |
| Dependencies | Config (Logs, ErrorCodes), TBD-CommitTypes |
TBD-CommitTypes only (standalone) |
| Failure | Pipeline job fails (non-blocking merge) | Push rejected (blocking) |
| Scope | Single project (where CI is configured) | All projects on the GitLab instance |
Info
Both validation layers complement each other:
* **CI validation** catches issues early — developers see errors in the MR pipeline before merge
* **Gitaly validation** is the last line of defense — prevents non-compliant commits from entering `main` even if CI is bypassed or not configured
Scope and Limitations¶
- The hook runs globally — it validates pushes to
mainin every project on the instance - Projects that do not follow Conventional Commits will have pushes to
mainrejected - To exempt a project, it must push to a differently named branch (not
main) - The hook does not validate MR squash-merge messages — that is handled by the CI job (
TBD-ValidateCommitMessage) - The hook only checks the commit subject line (first line), not the body