The most surprising thing about monorepos is how much faster they can make your development cycle, provided you don’t build everything every time.
Imagine this: a single Git repository holding all your code – frontend, backend, mobile apps, shared libraries. Your team grows, more code gets added, and suddenly a git push triggers a build that takes an hour, even for a tiny change. That’s where path filtering in Jenkins Pipeline comes in. It’s not about making Jenkins faster in absolute terms; it’s about making your builds faster by only running the necessary steps for a given change.
Let’s see it in action. Suppose you have a monorepo structured like this:
monorepo/
├── services/
│ ├── auth/
│ │ ├── src/
│ │ └── Jenkinsfile
│ ├── user/
│ │ ├── src/
│ │ └── Jenkinsfile
├── frontend/
│ ├── web/
│ │ ├── src/
│ │ └── Jenkinsfile
│ └── mobile/
│ ├── src/
│ └── Jenkinsfile
├── shared/
│ ├── libs/
│ │ ├── data/
│ │ └── utils/
│ └── Jenkinsfile
└── Jenkinsfile (root)
When someone pushes a change to services/auth/src/, we don’t want to rebuild the frontend/web app or retest shared/libs/data. Path filtering tells Jenkins exactly which directories were affected by the commit.
Here’s how a root Jenkinsfile might look using the pathFiltering directive:
pipeline {
agent any
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Build and Test') {
options {
pathFiltering() // Enable path filtering for this stage
}
steps {
script {
// This is where the magic happens.
// We'll define sub-pipelines or conditional steps based on paths.
// Example: Build and test only if auth service changed
when {
expression { return pathIsIncluded('services/auth/**') }
}
steps {
dir('services/auth') {
sh './build.sh' // Or your specific build command
}
}
// Example: Build and test only if web frontend changed
when {
expression { return pathIsIncluded('frontend/web/**') }
}
steps {
dir('frontend/web') {
sh './build.sh'
}
}
// Example: Build and test shared libraries if they changed
when {
expression { return pathIsIncluded('shared/libs/**') }
}
steps {
dir('shared/libs') {
sh './build.sh'
}
}
// Add more 'when' blocks for other services/apps
}
}
}
stage('Deploy') {
// Deployment stages would also use path filtering
// to deploy only the affected service/app.
when {
expression { return pathIsIncluded('services/auth/**') || pathIsIncluded('frontend/web/**') }
}
steps {
echo "Deploying changes for affected services/apps..."
// Actual deployment steps here
}
}
}
}
The core concept is the pathFiltering() option and the pathIsIncluded() function. When pathFiltering() is enabled for a stage, Jenkins analyzes the Git log of the current commit. For each when block that uses pathIsIncluded(), Jenkins checks if any of the files modified in the commit fall within the specified glob pattern. If a pattern matches, the when block’s steps are executed. If no patterns match for a given when block, its steps are skipped.
This isn’t just about skipping stages; it’s about granular control within stages. You can have multiple when blocks within a single stage, each triggering different sub-pipelines or commands based on the changed paths. For instance, you might want to run unit tests for services/auth if services/auth/** changed, but run integration tests for services/auth and services/user if a shared library in shared/libs/data/** changed.
The pathIsIncluded() function takes glob patterns. services/auth/** means any file or directory within services/auth or its subdirectories. You can be very specific: frontend/web/src/components/** would only trigger if changes are within the components directory of the web frontend.
This pattern allows you to implement a "build what changed" strategy. Each service or application within the monorepo can have its own pipeline logic defined either in its own Jenkinsfile (which the root Jenkinsfile could load) or directly within the root Jenkinsfile as shown above. The root Jenkinsfile acts as the orchestrator, using path filtering to decide which specific sub-pipelines or commands to invoke.
A common pitfall is not being granular enough with the glob patterns, leading to unnecessary builds. Another is forgetting to enable pathFiltering() on the stage. Without it, pathIsIncluded() will always return true, defeating the purpose.
The real power comes when you combine this with a robust monorepo tool like Lerna, Nx, or Bazel. These tools understand the dependency graph within your monorepo. You can use Jenkins path filtering to trigger the monorepo tool, and then the tool itself decides what to build based on its internal dependency analysis, often triggered by the same path changes. For example, your Jenkinsfile might simply be:
pipeline {
agent any
stages {
stage('Checkout') {
steps { checkout scm }
}
stage('Build & Test with Nx') {
options { pathFiltering() }
steps {
script {
// Run Nx affected commands only if something relevant changed
def affected = sh(returnStdout: true, script: 'npx nx print-affected --base=origin/main --target=build --json').trim()
if (affected) {
sh 'npx nx affected --target=build --parallel=3'
sh 'npx nx affected --target=test --parallel=3'
} else {
echo "No affected projects to build or test."
}
}
}
}
}
}
Here, the Jenkins path filtering ensures we only run the nx print-affected command if there’s a chance something in the monorepo has changed that nx cares about. Then nx itself handles the fine-grained dependency analysis.
The next logical step after efficient builds is optimizing your deployment pipelines using similar path-based logic, ensuring that only code that actually changed gets deployed.