Skip to content

Change-detection

"The fastest job is the one you don't run." Before change-detection, every gate job ran on every merge request — a zensical-build (the docs site) would fire on a Go-only MR just as readily as on a docs MR. On a constrained, self-hosted runner (a fixed concurrency ceiling), that's real wasted capacity, contended by everyone else's pipeline. Change-detection is GitLab's rules:changes mechanism, wired into every relevant gate component as a default-on, consumer-extensible input.

The mechanism

Each gate component gains a changes array input with a conservative, toolchain-appropriate default, attached to the job's existing MR rule:

spec:
  inputs:
    changes:
      type: array
      default: ["**/*.go", "go.mod", "go.sum", ".gitlab-ci.yml"]
---
<job>:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "schedule"'
      when: never
    - if: '$[[ inputs.if ]]'
      changes: $[[ inputs.changes ]]

The schedule-never guard stays the first rule, unconditionally; changes attaches only to the MR rule — so a consumer widening if: still can't pull a job onto the schedule, and tag pipelines (where a component has one) are unaffected either way.

Consumers extend the default rather than replace it. The one case that matters in practice: a Go project embedding a Svelte UI extends go-test's changes with its frontend path (pkg/studio/web/**), so a frontend-only change still runs the Go embed/served-UI tests — see the Svelte frontend track. Setting changes: ["**/*"] on any component is the universal escape hatch back to "run on every MR."

The reliability constraint (why this is scoped to MRs)

rules:changes doesn't mean the same thing on every pipeline type:

Pipeline changes compares against Reliable?
merge_request_event the MR diff, vs. the target merge-base yes
branch push (incl. default branch) the push's before→after SHA often — but a new branch, a multi-commit push, or a force-push can make it always-true
tag / scheduled nothing meaningful always true

Every gate this applies to is already MR-gated, so adding changes to those MR rules is reliable by construction. Deploy, release, and schedule rules stay unfiltered — which is exactly what's wanted there: a release tag should always run its publish job regardless of which files a changes filter would (unreliably) match against.

The three traps a default has to avoid

  1. False negatives are the real danger — not false positives. A filter that's too narrow lets an untested change merge silently. Every default therefore includes all the relevant source globs, every lockfile and tool config (go.mod/go.sum, Cargo.toml/Cargo.lock, rustfmt.toml, rust-toolchain.toml, *.tf, …), and — critically — the consumer's own .gitlab-ci.yml, so a CI edit always re-runs everything. The bias throughout is "run a needless job" over "skip a needed one."
  2. Build and deploy must skip or run together. Where a component has a pages job that needs: a build job (zensical-pages, hugo-pages), both jobs share the exact same changes filter. Without that, a build could skip while deploy still tries to run — a deploy with no artifact, which fails outright. With matching filters, a no-content push to the default branch correctly skips both: nothing changed, nothing redeploys.
  3. The whole security category is exempt. See security, always-on — this was significant enough to become its own decision, not a footnote here.

Rollout: pilot first, then propagate

Change-detection didn't land everywhere at once. It shipped as a pilot on zensical-pages first — docs-only, no code false-negative danger, and the one component that could prove the mechanism actually works: whether a type: array component input interpolates correctly into a rules:changes: YAML list. Only once that was validated did it propagate to hugo-pages (with a schedule carve-out — see static sites), then the Go/Rust gates, then the Tofu gates. The Svelte track shipped with change-detection built in from day one, since it landed after the mechanism was already proven.

See also