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.
The Cost of a Trigger That Fires Too Often Jump to heading
Trigger design is usually discussed as a cost-saving exercise, and the compute saving is real but secondary. The larger effect is on how much attention the pipeline consumes.
Every run produces a result somebody has to interpret. A pipeline that runs on every push to every branch generates results for commits that are already superseded, for branches nobody is reviewing, and for changes that could not possibly have affected the thing being tested. Each of those is a small demand on attention, and the aggregate effect is that people stop reading pipeline results carefully β which is precisely the moment a real failure gets missed.
The same dynamic explains why flaky tests are so corrosive in a high-trigger environment. A test that fails one run in twenty is an annoyance when the pipeline runs five times a day and a crisis when it runs eighty times, because the failures arrive faster than anyone can investigate them and the rational response becomes to re-run rather than to look.
Reducing trigger frequency therefore buys something more valuable than runner minutes: it raises the proportion of pipeline results that mean something. Three moves do most of the work. Scope by path so a change to documentation does not exercise the mobile build. Cancel superseded runs so only the newest commit on a branch reports. And separate the checks that gate a merge from the ones that are informational, so a red result is unambiguous about whether it blocks anything.
There is a counterweight worth naming. Every filter is a rule about what does not need testing, and rules like that are wrong occasionally β a shared base image, a lockfile, a CI configuration file can affect a service whose paths were untouched. The mitigation is to include those shared inputs in every filter that depends on them, and to review the filters when the repositoryβs structure changes rather than treating them as configured once and finished.
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 |
Reviewing trigger rules on a schedule Jump to heading
Trigger configuration decays quietly. A service is renamed and its filter now matches nothing, so its pipeline stops running and reports nothing β which looks exactly like a service whose tests always pass. A directory is split in two and only one half is covered. A new shared configuration file appears that half the filters ought to include and none of them do.
None of these produce an error, because a filter that matches nothing is indistinguishable from a change that touched nothing. The only reliable detection is a periodic review: list every pipeline, list the paths it claims to watch, and confirm those paths still exist and still contain what the pipeline believes they contain. Quarterly is usually often enough, and the review is quick β but skipping it entirely means discovering a silently dormant pipeline the hard way, when something it should have caught reaches production instead.
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.