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.

One commit, four candidate pipelines, two dispatchedA commit touching the API service and shared docs matches the API pipeline and the docs pipeline. The mobile and infrastructure pipelines see no matching paths and are not dispatched at all, which is where most of the saving comes from.push eventservices/api, docs/services/api/**docs/**apps/mobile/**infra/**api pipeline dispatcheddocs pipeline dispatchedno matching paths β€”never queued, never billed,and not reported as skippedwatch required checks here

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.


Why the same event arrives twiceA webhook provider retries on timeout, so the same delivery can arrive more than once. Recording the delivery identifier and rejecting repeats means a retry is absorbed rather than producing a second, competing pipeline run.delivery abc-123seen abc-123 before?no β†’ record and dispatchone pipeline rundelivery abc-123retried after timeoutseen abc-123 before?yes β†’ acknowledge, dropno second runwithout the check, two runs race, two deployments contend, and the slower one wins

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

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

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.