GitHub Actions path filters are a game-changer for monorepo CI/CD.
Here’s how a typical monorepo workflow might look with path filtering in action:
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
paths:
- 'packages/backend/**'
- 'packages/shared/**'
- '.github/workflows/backend.yml'
jobs:
build-backend:
runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' || contains(join(github.event.pull_request.changed_files), 'packages/backend/') }}
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
working-directory: packages/backend
- name: Build
run: npm run build
working-directory: packages/backend
- name: Test
run: npm test
working-directory: packages/backend
build-frontend:
runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' || contains(join(github.event.pull_request.changed_files), 'packages/frontend/') }}
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
working-directory: packages/frontend
- name: Build
run: npm run build
working-directory: packages/frontend
- name: Test
run: npm test
working-directory: packages/frontend
This workflow uses paths in the on.pull_request trigger to specify which directories should cause the workflow to run. For a push event to main, it runs unconditionally. For a pull_request event, it only runs if changes are detected within packages/backend or packages/shared. The if condition further refines this, ensuring that the build-backend job only executes if files within packages/backend (or related config) have changed in a pull request.
The core problem path-based workflows solve is CI/CD cost and speed in monorepos. Without them, every commit to any part of a large monorepo triggers a full build, test, and deploy cycle for the entire project. This wastes compute resources, slows down feedback loops for developers, and makes CI/CD pipelines prohibitively expensive. Path filters allow you to define granular triggers, so only the relevant parts of your monorepo are built and tested when specific files change. This dramatically reduces CI execution time and cost.
Internally, GitHub Actions checks the file paths of all changed files in a commit or pull request against the paths or paths-ignore patterns you define. If any of the paths patterns match, or if none of the paths-ignore patterns match, the workflow (or a specific job, if using jobs.<job_id>.if) is triggered. The github.event.pull_request.changed_files context variable is crucial here for PRs, as it provides a list of all files modified in the PR. The contains(join(github.event.pull_request.changed_files), 'some/path/') expression is a common idiom to check if any of those changed files fall within a specific directory.
The exact levers you control are the glob patterns within the paths and paths-ignore keys at the workflow level, and the if conditions at the job level. You can specify individual files, directories, or use wildcards. For example, packages/api/src/**/*.ts would trigger a workflow if any TypeScript file within the packages/api/src directory or its subdirectories changes. Conversely, paths-ignore can be used to prevent a workflow from running if specific files or directories are modified, which is useful for ignoring things like documentation updates or configuration changes that shouldn’t trigger a full build.
Most people don’t realize that path filters can be applied not just to the entire workflow but also to individual jobs within a workflow using the if conditional. This allows for even finer-grained control. For instance, you might have a workflow that runs for any code change, but a specific job within that workflow (like a complex integration test suite) might only run if changes are detected in the e2e directory, using an if: contains(join(github.event.pull_request.changed_files), 'e2e/') condition. This prevents expensive jobs from running unnecessarily when only unrelated code has been modified.
The next challenge to tackle is managing artifact dependencies between these path-filtered jobs.