Skip to main content
ship it and sleep

Path-Based Filtering and Affected Service Detection

3 min read Chapter 59 of 66

Path-Based Filtering and Affected Service Detection

The Failure

The path filter listed explicit directories for each service. The checkout service imported a utility from libs/http-client. When someone fixed a bug in libs/http-client, the checkout pipeline did not run because the filter only matched services/checkout/**. The bug fix was never tested against checkout. It broke checkout in staging.

Path filters need a dependency graph, not just directory matching.

The Mechanism

Dependency Graph

libs/http-client     → catalog, checkout, frontend
libs/shared-types    → all services
libs/auth-middleware  → checkout, payments
services/catalog     → (standalone)
services/inventory   → (standalone)
services/checkout    → inventory (runtime)
services/payments    → checkout (runtime)
services/frontend    → catalog, checkout, payments (runtime)

A change to libs/http-client must trigger catalog, checkout, and frontend pipelines.

The Implementation

Dependency-Aware Filter Configuration

# .github/filters.yml
# HARDENED: Dependency-aware path filters
catalog:
  - "services/catalog/**"
  - "libs/http-client/**"
  - "libs/shared-types/**"

checkout:
  - "services/checkout/**"
  - "libs/http-client/**"
  - "libs/shared-types/**"
  - "libs/auth-middleware/**"

inventory:
  - "services/inventory/**"
  - "libs/shared-types/**"

payments:
  - "services/payments/**"
  - "libs/shared-types/**"
  - "libs/auth-middleware/**"

frontend:
  - "services/frontend/**"
  - "libs/http-client/**"
  - "libs/shared-types/**"

infra:
  - ".github/**"
  - "docker-compose*.yml"

Dynamic Detection Script

#!/bin/bash
# scripts/detect-affected.sh
# HARDENED: Detect affected services from changed files
set -euo pipefail

CHANGED_FILES=$(git diff --name-only origin/main...HEAD)

declare -A AFFECTED

# Direct service changes
for file in $CHANGED_FILES; do
  case "$file" in
    services/catalog/*)   AFFECTED[catalog]=1 ;;
    services/checkout/*)  AFFECTED[checkout]=1 ;;
    services/inventory/*) AFFECTED[inventory]=1 ;;
    services/payments/*)  AFFECTED[payments]=1 ;;
    services/frontend/*)  AFFECTED[frontend]=1 ;;
  esac
done

# Library dependency propagation
for file in $CHANGED_FILES; do
  case "$file" in
    libs/shared-types/*)
      AFFECTED[catalog]=1
      AFFECTED[checkout]=1
      AFFECTED[inventory]=1
      AFFECTED[payments]=1
      AFFECTED[frontend]=1
      ;;
    libs/http-client/*)
      AFFECTED[catalog]=1
      AFFECTED[checkout]=1
      AFFECTED[frontend]=1
      ;;
    libs/auth-middleware/*)
      AFFECTED[checkout]=1
      AFFECTED[payments]=1
      ;;
    .github/*)
      # CI changes affect everything
      AFFECTED[catalog]=1
      AFFECTED[checkout]=1
      AFFECTED[inventory]=1
      AFFECTED[payments]=1
      AFFECTED[frontend]=1
      ;;
  esac
done

echo "${!AFFECTED[@]}"

Force-Run Override

# In the CI workflow
- name: Check for force-run label
  id: force
  run: |
    if [[ "${{ contains(github.event.pull_request.labels.*.name, 'ci:run-all') }}" == "true" ]]; then
      echo "run_all=true" >> $GITHUB_OUTPUT
    fi

- name: Detect changes
  if: steps.force.outputs.run_all != 'true'
  uses: dorny/paths-filter@v3
  with:
    filters: .github/filters.yml

Adding the ci:run-all label to a PR bypasses path filtering and runs all pipelines. Useful when you suspect a hidden dependency or want to validate everything before a release.

The Gate

The path filter is a speed gate. It ensures CI runs only what is necessary. The force-run label is the escape hatch when the filter is too conservative.

The Recovery

New dependency not reflected in filters: When a service adds a new library import, update .github/filters.yml in the same PR. Code review should catch filter updates.

Filter is too broad (everything runs): If libs/shared-types changes frequently and triggers all pipelines, consider splitting it into smaller, service-specific packages.

Git diff misses changes in merge commits: Use git diff origin/main...HEAD (three dots) to get the correct diff for pull requests. Two dots gives a different result.