Path-Based Filtering and Affected Service Detection
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.