CI/CD Pipeline Trigger Mapping Jump to heading
Precise event routing is the foundation of deterministic builds. This page covers how to map Git events to pipeline triggers, validate webhook payloads, and apply path-based filters β all within the broader Git Automation & CI/CD Hook Engineering discipline of separating client-side validation from server-side orchestration.
Prerequisites Jump to heading
Event-to-Pipeline Routing Architecture Jump to heading
The diagram below shows how a raw Git event travels from the remote host through signature verification, event normalisation, and path evaluation before a runner is provisioned.
Step 1 β Verify Webhook Signatures Before Any Processing Jump to heading
Every webhook delivery from GitHub or GitLab carries a cryptographic signature. Validate it before trusting payload content.
GitHub includes X-Hub-Signature-256; GitLab uses X-Gitlab-Token. Both use HMAC-SHA256 keyed to the secret you configured in the webhook settings.
# POSIX shell β verify a GitHub webhook delivery
# $GITHUB_WEBHOOK_SECRET is the shared secret stored in your CI environment
# $RAW_PAYLOAD is the raw request body (read before any JSON parsing)
EXPECTED=$(echo -n "$RAW_PAYLOAD" | \
openssl dgst -sha256 -hmac "$GITHUB_WEBHOOK_SECRET" | \
awk '{print "sha256="$2}')
if [ "$EXPECTED" != "$X_HUB_SIGNATURE_256" ]; then
echo "Signature mismatch β rejecting delivery" >&2
exit 1
fi Verification: replay a stored delivery through this check β it should pass. Mutate one byte of the payload and confirm it rejects with a non-zero exit code.
SAFETY WARNING: Skipping signature verification exposes your runner infrastructure to spoofed payloads that can trigger arbitrary pipeline executions or exhaust runner capacity. Treat unverified payloads as hostile input.
Step 2 β Normalise Events to a Canonical Routing Model Jump to heading
The same logical βcode was pushedβ event arrives under different field names across providers. Build a thin normalisation layer so downstream routing logic is provider-agnostic.
# Extract a canonical event type from GitHub Actions context variables
# Run as an early step before the job matrix is built
case "$GITHUB_EVENT_NAME" in
push)
EVENT_TYPE="push"
REF="$GITHUB_REF"
;;
pull_request | pull_request_target)
EVENT_TYPE="pull_request"
REF="$GITHUB_HEAD_REF"
;;
merge_group)
EVENT_TYPE="merge_group"
REF="$GITHUB_REF"
;;
create)
# tag or branch creation
EVENT_TYPE="tag"
REF="$GITHUB_REF"
;;
workflow_dispatch)
EVENT_TYPE="manual"
REF="$GITHUB_REF"
;;
*)
echo "Unknown event: $GITHUB_EVENT_NAME β skipping" >&2
exit 0
;;
esac
echo "Canonical event: $EVENT_TYPE on $REF" Verification: print $EVENT_TYPE in a dry-run workflow triggered by each event type; confirm it matches expectations before hooking into real job matrices.
Step 3 β Apply Path-Based Filters to Restrict Runner Dispatch Jump to heading
Before allocating a runner, determine whether the changeset actually touches code that a given pipeline owns. Use git diff against the merge base β not against HEAD~1, which breaks for merge commits.
# Extract files changed relative to the target branch
# Works in GitHub Actions with fetch-depth: 0
git fetch origin "$BASE_BRANCH" --quiet
CHANGED=$(git diff --name-only --diff-filter=ACMR \
"origin/${BASE_BRANCH}...${GITHUB_SHA}")
# Route to the application pipeline only if src/ changed
echo "$CHANGED" | grep -qE '^src/' \
&& echo "TRIGGER: app pipeline" \
|| echo "SKIP: no src/ changes"
# Always escalate to full pipeline when shared config changes
echo "$CHANGED" | grep -qE '^(package\.json|tsconfig\.json|go\.mod|Makefile)$' \
&& echo "ESCALATE: shared config changed β full pipeline required" Verification: create a branch that only modifies a README.md; confirm the application pipeline is skipped. Then touch package.json and confirm the full-pipeline escalation fires.
SAFETY WARNING: Overly broad glob patterns cause cascading rebuilds across unrelated services. Overly narrow ones silently skip pipelines when shared configuration files change. Test patterns against production ref names in a staging environment before enforcing them in main.
Step 4 β Implement Idempotency and Deduplication Jump to heading
Rapid-fire pushes (force-push, rebase and re-push) can deliver the same logical event multiple times. Deduplicate using a SHA-keyed cache with a short TTL.
# Pseudocode β implement in your webhook receiver (Node.js, Python, etc.)
# CACHE_TTL: 900 seconds (15 minutes)
# DELIVERY_SHA: SHA-256 of the raw payload, computed after signature verification
CACHE_KEY="webhook:${DELIVERY_SHA}"
if cache_exists "$CACHE_KEY"; then
echo "Duplicate delivery detected β returning 409 Conflict"
http_respond 409
exit 0
fi
cache_set "$CACHE_KEY" "processed" --ttl 900
# Proceed with routing
dispatch_pipeline "$EVENT_TYPE" "$REF" Verification: replay an identical webhook delivery twice within 15 minutes. The second delivery should receive a 409 and produce no pipeline run.
SAFETY WARNING: Infinite trigger loops occur when pipeline jobs commit back to the repository and re-fire the push webhook. Use a dedicated CI service account and exclude its commits from trigger conditions β e.g.
if: github.actor != 'ci-bot'in GitHub Actions.
Step 5 β Configure Provider-Specific Trigger Rules Jump to heading
With the routing logic in place, express the trigger constraints natively in your CI provider so the platform itself enforces them before your normalisation layer is invoked.
GitHub Actions β on: filters with paths: constraints and if: conditions:
on:
push:
branches:
- main
- "release/**"
pull_request:
branches:
- main
paths:
- "src/**"
- "package.json"
workflow_dispatch:
# Always allow manual runs regardless of path
jobs:
build:
runs-on: ubuntu-latest
# Skip CI-bot commits to break re-trigger loops
if: github.actor != 'ci-service-account'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # needed for git diff against merge base GitLab CI β rules: with changes: and workflow:rules for global gating:
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_PIPELINE_SOURCE == "web" # manual trigger
app-build:
rules:
- changes:
- src/**/*
- package.json
when: always
- when: never Jenkins β multibranch pipelines with when { changeset } and lightweight pre-checkout evaluation:
pipeline {
options { skipDefaultCheckout() }
stages {
stage('Lightweight checkout') {
steps {
checkout scm: [$class: 'GitSCM',
extensions: [[$class: 'CloneOption', shallow: true, depth: 1]]]
}
}
stage('Build') {
when {
changeset 'src/**'
}
steps { sh './build.sh' }
}
}
} Verification: submit a PR that only changes documentation; confirm the build stage is skipped. Submit one that touches src/; confirm it runs.
Integration with Adjacent Tools Jump to heading
Local hooks vs remote triggers β Local Hook Configuration with Husky handles pre-commit validation at the workstation. Remote triggers must treat that as irrelevant: a developer running git push --no-verify bypasses every local hook, so the pipeline cannot assume any client-side checks passed. The two systems have completely separate scopes.
Baseline quality gates before runner allocation β when integrated with Lint-Staged & Formatting Automation, a lightweight lint step can reject malformed payloads before provisioning expensive runners. The boundary is clear: lint-staged owns file-level checks during commit; the remote trigger layer owns routing and gating before the full pipeline runs.
Pre-push hooks as a soft gate β Pre-Push Validation Rules can run a subset of the remote pipeline checks locally, giving developers early feedback. However, the remote pipeline must still run independently; pre-push hooks are advisory, not authoritative.
Workflow continuity under failures β if a runner cluster becomes unavailable, queue payloads with ordering guarantees rather than dropping them. Monitor trigger-to-execution latency as a primary SLO. Implement dead-letter queues with explicit retry limits so capacity exhaustion does not cause permanent payload loss.
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
Pipeline fires on every push, ignoring paths: filters | fetch-depth: 1 (shallow clone) means git diff has no merge base | Set fetch-depth: 0 in your checkout step |
| Duplicate runs for the same commit | No deduplication cache; rapid-fire push events | Store payload SHA in a 15-minute TTL cache; reject duplicates with 409 |
| Infinite loop β pipeline commits back and re-triggers | CI service account not excluded from trigger conditions | Add if: github.actor != 'ci-bot' or equivalent deny rule |
Builds silently skipped when package.json changes | Glob pattern targets src/** only, not shared config files | Add an explicit escalation rule for package.json, go.mod, tsconfig.json, Makefile |
| Webhook rejected with 401 even with correct secret | Secret has trailing whitespace or was URL-encoded during storage | Trim and re-save the secret; verify by comparing xxd output of stored vs expected |
git diff reports no changes in PR pipeline | Merge base branch not fetched before diff | Run git fetch origin $BASE_BRANCH before the diff command |
Frequently Asked Questions Jump to heading
What Git events should trigger a CI pipeline? Jump to heading
At minimum: push to protected branches, pull_request (opened, synchronised, reopened), and tag creation. Add merge_group if you use GitHubβs merge queue, and workflow_dispatch for manual runs. Avoid triggering on push to every branch β scope it to branches that feed into release or deployment flows.
Can I rely on local hooks passing before the pipeline runs? Jump to heading
No. Developers can bypass local hooks with git push --no-verify, and freshly cloned repositories may not have hooks installed at all. The remote pipeline must validate independently. Think of local hooks as fast feedback for the developer, not as a gate the pipeline can trust.
How do I prevent infinite trigger loops? Jump to heading
Exclude commits authored by your CI service account from trigger conditions. In GitHub Actions: if: github.actor != 'ci-service-account'. In GitLab CI: add a workflow:rules condition that filters on $GITLAB_USER_LOGIN. Pair this with idempotency keys so replayed deliveries are rejected before reaching routing logic.
How should monorepos route builds to affected services only? Jump to heading
Use git diff --name-only --diff-filter=ACMR origin/$BASE_BRANCH...$SHA to extract the changed file set, then match each serviceβs directory glob against it. Always escalate to a full build when shared configuration files (package.json, go.mod, tsconfig.json) change β a modified build tool configuration can affect every service. For detailed patterns, see Optimizing CI Triggers for Path-Specific Changes.
What TTL should I use for a webhook deduplication cache? Jump to heading
15 minutes covers rapid-fire push events from the same commit while keeping memory overhead low. Pair the cache with a dead-letter queue so payloads that arrive after expiry are not silently dropped β they should be reprocessed, not discarded.
Related Jump to heading
- Optimizing CI Triggers for Path-Specific Changes β deep-dive on glob syntax and incremental build routing for GitHub Actions and GitLab CI in monorepo setups.
- Pre-Push Validation Rules β configure client-side checks that run before
git pushreaches the remote, giving developers early feedback without replacing the remote pipeline. - Preventing Broken Builds with Pre-Push Hooks β practical recipes for running test suites and build checks in the pre-push hook.
- Local Hook Configuration with Husky β set up and enforce
pre-commitandpre-pushhooks across your team using Husky v9. - Lint-Staged & Formatting Automation β run linters and formatters only on staged files, reducing the overhead of baseline quality checks before commits reach CI.