Skip to content
Fran Gonzalez
← Back to blog
(updated Jul 16, 2026)·Clanker·6 min read

Scoping a GitLab CI monorepo pipeline by what actually changed

A lockfile change rebuilt five unrelated jobs. The fix was to stop using the lockfile as a build trigger and let the cache key encode toolchain changes instead.

Some matmuls wrote this slop, sorry. My goal with this content is to document some work I (a real human bean) do while poking the Clanker, and try to learn something along the way.

A one-line tool addition to mise.toml triggered five unrelated jobs across three projects in the next merge-request pipeline. The pipeline was doing what its trigger rules said, not what anyone wanted. The fix was a principle, not a patch: shared toolchain files belong in cache keys, not in build triggers.

Overbroad pipeline

The repo is a polyglot monorepo (Java + Node + Python + React Native) on a self-hosted GitLab runner. Its CI cache strategy already leans on path-based rules:changes to keep each project’s jobs from running on every push. Each project file defines a YAML anchor of paths that should trigger its build, quality, and test jobs:

# ci/projects/cms.yml
.cms_changes: &cms_changes
  - .gitlab-ci.yml
  - bin/mise
  - mise.lock
  - cms/**/*
  - infrastructure/scripts/**/*

That looks reasonable. It broke when I added "npm:renovate" to mise.toml to manage the self-hosted Renovate version the same way as every other tool. The change touched mise.toml and mise.lock and nothing else application-related. The next MR pipeline auto-ran:

  • cms:build:production, a full Docker image build
  • mobile-app:quality and mobile-app:deploy-units:validate
  • api-contracts:drift
  • web-app:prime:python-tools, a cache-warmer

Renovate is a developer-facing CLI. No application build consumes it. Those five jobs ran because mise.lock was in their changes: lists, and mise lock rewrites the entire lockfile on any tool change, so the file always looks “changed” whenever any tool moves.

Worse, the coupling was inconsistent by accident, not design. A grep across the project files showed mise.lock listed as a trigger in some, mise.toml in others, both in a couple, and neither in web-app. And web-app:prime:python-tools had no changes: filter at all: it ran on every MR, every push, every webhook, unconditionally.

Scope design

The key realization came from reading the cache keys, not the triggers. Every project’s CI cache already keys on mise.lock:

# ci/projects/cms.yml
.cms_python_cache:
  cache:
    key:
      files:
        - mise.lock
        - infrastructure/scripts/uv.lock
      prefix: "uv-v1"
    paths:
      - .mise/
      - .uv_cache/
    policy: pull-push

When mise.lock changes, the cache key changes, the cache misses, and the next build reinstalls tools from scratch. The cache key already encodes “tools changed.” So listing mise.lock in the trigger is redundant: it forces a rebuild now for correctness that the cache would guarantee anyway on the next real change. The trigger and the cache key were both trying to answer the same question.

The principle I adopted:

Auto-run jobs trigger on application source and that app’s own config only. Shared toolchain files (mise.toml/mise.lock) are not app-build triggers. Cache-warming jobs are the exception: they exist to react to tool changes, so they trigger on the lockfile.

Concretely, four surgical edits:

  1. Gave web-app:prime:python-tools a real changes: filter (the inputs that affect its cache: mise.lock, infrastructure/scripts/**, its own CI file, .gitlab-ci.yml) instead of running on everything.
  2. Removed mise.lock from cms’s trigger anchor.
  3. Removed mise.toml and mise.lock from two mobile-app jobs’ trigger blocks.
  4. Removed mise.toml from api-contracts’s trigger anchor.

Every mise.lock cache key stayed exactly where it was. No job script changed. The diff was +5/-8 lines.

The cache-warmer exception is what makes this safe to reason about. prime:python-tools is the one job whose entire purpose is to warm a cache keyed on mise.lock. Gating it on mise.lock means it runs precisely when its output would change, and nowhere else. It carries allow_failure: true and feeds only manual deploy jobs through an optional needs:, so a narrow trigger has no failure-mode cost.

One correction worth admitting: my first pass over-reported the problem. I assumed every project watched the root mise.lock, and planned edits to six files. Reading the ground truth showed that admin-app, web-app, and backend used app-scoped lockfile globs (admin-app/*.lock, web-app/*.lock, backend/*.lock) that never matched the root mise.lock in the first place. They were already correctly scoped. Only four files actually leaked.

Verification

The next MR that touched only mise.* and Renovate config dropped four of the five collateral jobs from the pipeline. prime:python-tools still ran, which is correct: its cache input changed. The toolchain-isolation rule is now structural in the four files that needed it, and the cache keys guarantee correctness without a trigger.

Tradeoffs

This fix treats a symptom at the right altitude, but the structural cause was still there: each job duplicated its changes path list across two if blocks (one for merge requests, one for push/web/api), roughly forty copies across the project files. GitLab’s recommended monorepo pattern since 16.4 is a control-plane include with rules:changes in the root .gitlab-ci.yml, conditionally including a per-project file based on which directory changed. The per-job-anchor approach this repo uses is what GitLab’s own monorepo guide calls the legacy pattern: “a lot of redundant code and room for human error.”

I did the follow-up, and I did not adopt the control plane. Evaluating it turned up three blockers: it removes a job from the pipeline entirely when no paths match (there is no “present but manual” state, so it would have deleted the manual rebuild fallback this repo relies on), my local gitlab_ci_lint renderer cannot yet inline a local: include paired with a sibling rules: key, and it would have doubled the file count for a single-maintainer repo. Instead I collapsed each job’s two if blocks into one combined source-gated rule and kept the fallback: about 170 lines removed, with every job still resolving on both its auto-run and manual paths. The full reasoning is in Collapsing GitLab CI rules instead of the control-plane pattern.

The honest tradeoff of the current fix: removing mise.lock from triggers means a tool bump that breaks an app build surfaces on the next application-code push, not immediately. For a single-team repo where the person shipping the tool bump can manually trigger the affected build to verify it, that is an acceptable cost. For a repo where toolchain changes ship without anyone watching, I would keep the lockfile as a trigger and accept the collateral rebuilds.

References

This post was written with AI assistance.