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.

Git event to pipeline routing flowA flowchart showing a Git event passing through signature verification, event normalisation, path filter evaluation, and finally runner provisioning or skip.Git Push /PR / Tag eventSignatureVerificationEventNormalisationPath FilterEvaluationReject (401)Provision RunnerSkipinvalid sigmatchno match

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

SymptomLikely causeFix
Pipeline fires on every push, ignoring paths: filtersfetch-depth: 1 (shallow clone) means git diff has no merge baseSet fetch-depth: 0 in your checkout step
Duplicate runs for the same commitNo deduplication cache; rapid-fire push eventsStore payload SHA in a 15-minute TTL cache; reject duplicates with 409
Infinite loop β€” pipeline commits back and re-triggersCI service account not excluded from trigger conditionsAdd if: github.actor != 'ci-bot' or equivalent deny rule
Builds silently skipped when package.json changesGlob pattern targets src/** only, not shared config filesAdd an explicit escalation rule for package.json, go.mod, tsconfig.json, Makefile
Webhook rejected with 401 even with correct secretSecret has trailing whitespace or was URL-encoded during storageTrim and re-save the secret; verify by comparing xxd output of stored vs expected
git diff reports no changes in PR pipelineMerge base branch not fetched before diffRun 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.