In a monorepo, Git’s history is the most efficient way to determine which GitLab CI jobs need to run.

Let’s say you have a monorepo with three distinct services: frontend, backend, and database. Your .gitlab-ci.yml might look something like this:

stages:
  - build
  - test
  - deploy

build_frontend:
  stage: build
  script:
    - echo "Building frontend..."
    - cd frontend && npm install && npm run build
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - frontend/**/*

build_backend:
  stage: build
  script:
    - echo "Building backend..."
    - cd backend && npm install && npm run build
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - backend/**/*

test_frontend:
  stage: test
  script:
    - echo "Testing frontend..."
    - cd frontend && npm test
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - frontend/**/*

test_backend:
  stage: test
  script:
    - echo "Testing backend..."
    - cd backend && npm test
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - backend/**/*

deploy_frontend:
  stage: deploy
  script:
    - echo "Deploying frontend..."
    - cd frontend && ./deploy.sh
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      changes:
        - frontend/**/*

deploy_backend:
  stage: deploy
  script:
    - echo "Deploying backend..."
    - cd backend && ./deploy.sh
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      changes:
        - backend/**/*

The magic here is the rules keyword with the changes directive. When a pipeline is triggered (e.g., by a push or a merge request), GitLab analyzes the files that have been modified in that specific commit or set of commits. The changes rule then acts as a filter: if any files within the specified path (like frontend/**/* or backend/**/*) have changed, the job is included in the pipeline. If no files in that path were modified, the job is skipped entirely.

This mechanism is incredibly powerful for monorepos because it avoids running tests or builds for unrelated services. If you only change a file in the frontend directory, only the build_frontend and test_frontend jobs (and potentially deploy_frontend if it’s a push to main) will be executed. The backend and database jobs will be silently skipped, saving significant CI time and resources.

The changes directive supports glob patterns, making it flexible. frontend/**/* means "any file or directory within the frontend directory, at any depth." You can also be more specific, like frontend/src/components/*.js to only trigger if JavaScript files within that specific components folder change.

The if: '$CI_PIPELINE_SOURCE == "merge_request_event"' part ensures these rules are primarily applied when you’re creating or updating a merge request. For direct pushes to a branch like main, you might have different rules that trigger deployments, as shown in the deploy_frontend and deploy_backend examples.

This approach is not just about efficiency; it’s about correctness. By only testing and deploying what has actually changed, you reduce the chance of introducing regressions in unrelated parts of your codebase and ensure that your CI pipeline accurately reflects the scope of your changes.

The most surprising thing about rules: changes is that it operates on the diff of the pipeline’s trigger commit(s) against the previous commit on the target branch. This means a change to frontend/index.js will trigger jobs dependent on frontend/**/* even if the commit also modified backend/index.js, as long as frontend/index.js is part of the diff. It’s not just about whether a file exists in that path, but whether it was touched in the specific commit that initiated the pipeline.

When you push a commit that modifies files in both frontend and backend, and your .gitlab-ci.yml has rules for both, all corresponding jobs for both services will run. The system doesn’t try to be "smart" about mutually exclusive changes within a single commit; it simply evaluates each changes rule independently against the entire diff.

The next step is to explore how to define dependencies between jobs that do need to run, ensuring that a backend build completes before a backend deployment, even when only backend changes occur.

Want structured learning?

Take the full Gitlab-ci course →