Optimizing CI Triggers for Path-Specific Changes Jump to heading

Monorepo architectures suffer from compute bloat when CI pipelines execute blanket triggers on every commit regardless of what actually changed. A documentation-only pull request that fires a full compilation suite wastes runner capacity and slows feedback loops for the engineers who actually changed source code. This recipe shows how to scope pipeline execution precisely to the files that changed β€” a capability that sits within the broader CI/CD Pipeline Trigger Mapping topic, which covers the full event-routing and webhook-verification layer that surrounds these filters.

When to Use This Approach Jump to heading

Path-specific trigger optimization applies when all of the following conditions are true:

  • Your repository contains multiple independently deployable packages, services, or documentation areas that share a single Git remote.
  • CI runtime per PR exceeds two minutes for changes that touch only one service boundary.
  • Queue saturation during peak hours delays feedback for unrelated teams working in separate subdirectories.
  • You can map clear ownership boundaries β€” each directory’s changed files imply a known, bounded set of jobs to run.
  • You have already addressed local validation with pre-push validation rules and need to extend the same discipline to the remote execution layer.

Do not apply path filters if your repository has significant implicit cross-directory dependencies that are not expressed in lockfiles or build graphs β€” a change in shared/utils/ that is not caught by a filter will silently skip downstream tests.

Diagnosing Inefficient Trigger Patterns Jump to heading

Inefficient triggers produce observable symptoms before you look at pipeline YAML: documentation-only PRs spawn full compilation suites; queue saturation appears during peak hours; build cost per PR is high relative to the actual change size.

Diagnose root causes with targeted diff commands:

# Step 1 β€” Audit which paths change most often in recent history.
# High-frequency paths that never affect runtime behaviour are filter candidates.
git log --oneline --stat -- docs/

# Step 2 β€” Inspect the diff for the current branch vs the merge base.
# The three-dot syntax isolates commits unique to this branch.
git diff --name-only origin/main...HEAD

Verify with: git log --oneline -10 -- docs/ β€” if every recent commit touches only documentation but your pipeline YAML has no paths-ignore, you have identified the first filter to add.

What changed and why: the three-dot form origin/main...HEAD resolves to commits reachable from HEAD but not from origin/main, which is exactly the set a CI system evaluates for a PR. Single-dot syntax (origin/main..HEAD) is subtly different and can include merge base noise.

Step-by-Step Recipe Jump to heading

Step 1 β€” Map your dependency boundaries Jump to heading

Before writing glob patterns, produce a dependency map:

# List all top-level directories that contain deployable code or tested packages.
find . -maxdepth 2 -name "package.json" -o -name "go.mod" -o -name "setup.py" \
  | grep -v node_modules | sort

# Identify files whose modification always requires full-pipeline execution.
# These must appear in EVERY path filter block, not just one.
SHARED_TRIGGERS=(
  "package.json"
  "package-lock.json"
  "go.mod"
  "go.sum"
  "tsconfig.json"
  ".github/workflows/*.yml"
)

Verify with: confirm that every path in $SHARED_TRIGGERS appears explicitly in the filter blocks you write in the following steps β€” grep your workflow files after writing them.

What changed and why: explicit enumeration of shared triggers prevents the most common false-negative failure mode, where a lockfile change is silently excluded by a service-scoped path filter.


Step 2 β€” Configure GitHub Actions paths and paths-ignore Jump to heading

GitHub Actions evaluates file changes using native glob matching. The paths directive restricts execution to specified directories; paths-ignore explicitly excludes irrelevant files. Glob patterns are case-sensitive and do not support full regular expressions.

# .github/workflows/backend-ci.yml
on:
  pull_request:
    paths:
      # Scope to the backend service boundary and shared utilities.
      - 'packages/backend/**'
      - 'packages/shared/**'
      # Always re-run when the workflow itself changes.
      - '.github/workflows/backend-ci.yml'
      # Always re-run on lockfile changes β€” a dep update affects everything.
      - 'package-lock.json'

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build --workspace=packages/backend

Verify with: open a PR that changes only docs/README.md β€” the backend-ci workflow must not appear in the checks list. Then open a PR that changes packages/backend/src/index.ts β€” it must trigger.

What changed and why: restricting paths to explicit service directories means GitHub evaluates the changed file list before provisioning a runner, so no compute is allocated for irrelevant changes.

SAFETY WARNING: Overly broad globs like **/*.js trigger on every JavaScript modification across the repository. Always scope patterns to specific package boundaries. Never omit shared configuration files (package-lock.json, tsconfig.json, go.sum) from trigger lists β€” a dependency update that bypasses CI creates a silent correctness failure that is hard to attribute later.

For complex routing where you need per-path boolean outputs to drive a job matrix, add dorny/paths-filter:

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      backend: $
      frontend: $
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            backend:
              - 'packages/backend/**'
              - 'package-lock.json'
            frontend:
              - 'packages/frontend/**'
              - 'package-lock.json'

  build-backend:
    needs: changes
    # Only provision a runner when the backend filter matched.
    if: needs.changes.outputs.backend == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test --workspace=packages/backend

Verify with: add a dry-run step that prints needs.changes.outputs.backend before the build step runs β€” confirm the value matches the files changed in the PR.


Step 3 β€” Configure GitLab CI rules:changes Jump to heading

GitLab CI uses the rules keyword for conditional execution. The changes parameter evaluates file modifications against the merge request target branch. The legacy only/except syntax is deprecated β€” migrate away from it before GitLab removes it.

GitLab computes the diff between the source branch and the pipeline target before job instantiation. An explicit when: never fallback prevents accidental execution when no matching paths are detected:

# .gitlab-ci.yml
backend-test:
  image: node:20
  script:
    - npm ci
    - npm test --workspace=packages/backend
  rules:
    # Run when backend source or shared deps change.
    - changes:
        - 'src/backend/**/*'
        - 'package.json'
        - 'package-lock.json'
      when: always
    # Skip for all other changes β€” no fallback execution.
    - when: never

frontend-test:
  image: node:20
  script:
    - npm ci
    - npm test --workspace=packages/frontend
  rules:
    - changes:
        - 'src/frontend/**/*'
        - 'package.json'
        - 'package-lock.json'
      when: always
    - when: never

Verify with: create an MR that changes only src/frontend/App.vue β€” backend-test must show as skipped in the pipeline view; frontend-test must run.

What changed and why: the when: never terminal rule prevents GitLab from falling back to unconditional execution when no change filter matches. Without it, the job runs on every pipeline that lacks a matching changes block, which defeats the filter entirely.


Step 4 β€” Custom diff evaluation for self-hosted runners Jump to heading

Native path filters have limitations in complex dependency graphs, merge queue scenarios, or self-hosted runner environments. The following script uses git diff --name-only with three-dot syntax for precise path resolution and can be embedded in any CI provider’s pipeline definition:

#!/usr/bin/env bash
# ci-path-filter.sh β€” portable path filter for any CI provider.
# Usage: ./ci-path-filter.sh [target-branch] [path-regex]
# Exit 0 = paths matched (run the pipeline); exit 1 = no match (skip).
set -euo pipefail

TARGET_BRANCH="${1:-origin/main}"
PATH_REGEX="${2:-'^src/(frontend|backend)/'}"

# Three-dot syntax computes the diff from the merge base,
# excluding commits already on the target branch.
CHANGED_FILES=$(git diff --name-only "${TARGET_BRANCH}...HEAD")

if echo "${CHANGED_FILES}" | grep -qE "${PATH_REGEX}"; then
  echo "MATCH: relevant paths changed β€” triggering pipeline"
  exit 0
else
  echo "SKIP: no paths matching ${PATH_REGEX} were modified"
  exit 1
fi

Integrate into a Jenkins when block:

stage('Backend Tests') {
  when {
    expression {
      sh(script: './ci-path-filter.sh origin/main "^src/backend/"',
         returnStatus: true) == 0
    }
  }
  steps {
    sh 'npm test --workspace=packages/backend'
  }
}

Verify with: run ./ci-path-filter.sh origin/main "^src/backend/" locally on a branch that only changed docs/ β€” confirm it exits 1.

SAFETY WARNING: Always validate the target branch reference before executing diff operations. A wrong branch pointer produces false positives (unnecessary builds) or false negatives (skipping required validation). Ensure your CI environment runs Git v2.30+ for consistent three-dot diff output. On shallow clones, fetch enough history to reach the merge base: git fetch --deepen=50 origin main.


Step 5 β€” Validate and benchmark Jump to heading

Verify trigger accuracy before merging configuration changes:

# Simulate GitHub Actions path resolution locally using the gh CLI.
gh pr diff --name-only | grep -E '^src/api/'

# Dry-run with act (local GitHub Actions runner) against a fixture payload.
act -j build --dry-run --eventpath pull_request.json

Track pipeline execution rates, mean queue times, and compute cost per PR to quantify optimization impact. Implement a dry-run mode during initial rollout to prevent production disruptions from misconfigured filters.

Monitor false-negative rates closely. Over-filtering allows untested code to reach staging environments β€” this is a correctness problem, not a performance problem.


The following SVG illustrates how path filter evaluation slots into the CI trigger flow, from webhook delivery through to runner provisioning:

Path-specific CI trigger evaluation flowDiagram showing the sequence: Push/PR event arrives at the webhook receiver, which extracts the changed file list, evaluates it against configured path patterns, and either provisions a runner or skips the job.Push / PReventWebhookreceiver + HMAC verifygit diff --name-onlyorigin/main...HEADPath filterpaths / rules:changes match?yesRunnernoSkip jobAlways trigger regardless of path filter:package-lock.json Β· go.sum Β· tsconfig.json.github/workflows/*.yml

Validation Checklist Jump to heading

Frequently Asked Questions Jump to heading

What happens when both paths and paths-ignore are set in the same GitHub Actions trigger? Jump to heading

GitHub evaluates paths-ignore first. If a changed file matches an ignore pattern it is excluded even if it also matches a paths pattern. Use only one directive per trigger block to avoid unintended interactions β€” combine exclusion logic inside paths using the ! prefix where the runner supports negative globs.

Does GitLab CI rules:changes evaluate the full file tree or only the MR diff? Jump to heading

GitLab computes the diff between the source branch HEAD and the target branch HEAD at pipeline creation time. On push events without a merge request context it falls back to comparing with the previous pipeline SHA, which can produce inconsistent results. Always test rules:changes in an actual MR context rather than against a plain branch push.

How do I prevent shared config files from being silently skipped by path filters? Jump to heading

Explicitly add every shared configuration file (package.json, go.mod, tsconfig.json, Makefile, .github/workflows/*.yml) to every path filter block that covers a service depending on those files. Never rely on a catch-all glob to cover them β€” treat shared files as first-class trigger sources so that any lockfile change always fires a full build.