Skip to content

Spec: change-detection across the gate components

  • Repository: gitlab.com/phpboyscout/cicd
  • Status: implemented — pilot v0.15.0 (zensical-pages); v0.16.0 (go/rust gates, hugo-pages, tofu-lint/validate); svelte-* track ships with change-detection (v0.17.0/v0.18.0).
  • Target: incremental, pilot-first (see D7).
  • Driver: "the fastest job is the one you don't run." Today every gate job runs on every MR — e.g. zensical-build (docs site) fires on a Go-only go-tool-base MR. On the single self-hosted runner (concurrent = 3) those wasted jobs cost real slots.

No component uses rules:changes today; this is a clean slate.

Decisions

D1 — Scope change-detection to MR pipelines (reliability constraint)

GitLab rules:changes is only reliable in some pipeline types:

Pipeline changes compares vs Reliable
merge_request_event MR diff (vs target merge-base)
branch push (incl. default) push before→after SHA ⚠️ new-branch / multi-commit / force-push → often always-true
tag / scheduled nothing — always true predictable (runs)

Our gate jobs are already MR-gated (go-*, rust-*, tofu-lint/validate/security, zensical-build, hugo-build), so adding changes to those MR rules is reliable. Deploy/release/schedule rules stay unfiltered (which is what we want there anyway). For the rare case of wanting reliable detection on a branch pipeline, GitLab's changes: { paths: […], compare_to: '<ref>' } (diff vs a fixed ref) is the tool — not needed for the MR-gated case.

D2 — Mechanism: a consumer-extensible changes input

Each gate component gains a changes array input with a conservative file-type default:

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 ]]
  • Default makes it work out-of-the-box per toolchain.
  • Consumers extend it for their layout — critically, embed projects add pkg/studio/web/** to go-test so a Svelte change still runs the Go embed/served-UI tests (see 2026-06-23-svelte-frontend-track.md).
  • Disable / full-gate escape hatch: set changes: ["**/*"] (or a change_detection: false toggle) to run every MR fully.

Implementation must validate that $[[ inputs.changes ]] (an array input) interpolates into rules:changes: as a YAML list. GitLab array inputs support this, but it is the one mechanism to prove in the pilot (D7). Fallback if it doesn't: per-component hardcoded defaults + a string-list extension input.

D3 — Safety rules (the three traps)

  1. False negatives are the danger. A too-narrow filter lets an untested change merge. Mitigations, baked into every default:
  2. always include the consumer's .gitlab-ci.yml (CI edits re-run everything);
  3. include all source globs and every lockfile / tool config (go.mod/go.sum, Cargo.toml/Cargo.lock, *.tf, .golangci.*, deny.toml, rustfmt.toml, rust-toolchain.toml, …);
  4. prefer running a needless job over skipping a needed one.
  5. Artifact / needs consistency. zensical-pages / hugo-pages have pagesneeds: <build>. The same changes filter applies to the build and deploy jobs so they skip/run together (otherwise deploy runs with no artifact → fails). On a no-docs default-branch push both skip → no redeploy (correct: nothing changed).
  6. The entire security category is never path-filtered (resolved — see Resolved section). Not just secrets: every scanner in go-security / rust-security / svelte-security / tofu-security stays always-on, so every MR re-checks deps against the latest CVE DB and scans every file. Change-detection applies only to lint / test / build / docs jobs.

D4 — Per-component recommendations

Component (jobs) changes default Default-on Notes
zensical-pages (build+pages) docs/**, zensical.toml, requirements-lock.txt, .gitlab-ci.yml yes Pilot. Same filter on both jobs. Highest value in code repos.
hugo-pages (build+pages) content/**, layouts/**, static/**, assets/**, themes/**, *.toml, .gitlab-ci.yml yes Keep the schedule rule unfiltered (nightly rebuild must still publish future-dated posts).
go-lint, go-test **/*.go, go.mod, go.sum, .golangci.*, .gitlab-ci.yml yes Embed projects extend with pkg/studio/web/**.
rust-lint, rust-test, rust-docs **/*.rs, Cargo.toml, Cargo.lock, rustfmt.toml, rust-toolchain.toml, .gitlab-ci.yml yes
tofu-lint, tofu-validate **/*.tf, **/*.tfvars, .tflint.hcl, .gitlab-ci.yml yes Pairs with existing paths inputs (which dirs).
svelte-lint, svelte-test <paths>/** (+ package*.json, vite/svelte/ts configs) yes Derived from the consumer's frontend paths. See svelte spec.
go-security, rust-security, svelte-security, tofu-security no (always-on) Whole security category exempt — max posture (resolved).
tofu-plan **/*.tf, **/*.tfvars opt-in Teams often prefer "always plan"; lower upside.
goreleaser, tofu-apply, tofu-module-publish, releaser-pleaser, release-plz, renovate-self no Tag/release/schedule-driven; changes is always-true there and you want them to run.

D5 — Default-on with conservative globs (policy)

Default on for the gate components — the churn reduction is the whole point, and a comprehensive glob keeps the false-negative window small. Security/secret jobs stay always-on. The changes: ["**/*"] escape hatch preserves "fully gate every MR" for anyone who wants it.

D6 — Interaction with existing rule structure

The schedule-never guard (v0.10.8) stays the first rule; the tag guard (v0.11.1) where present stays; changes attaches to the $[[ inputs.if ]] MR rule only. So a consumer widening if: still can't pull a job onto the schedule, and tag pipelines are unaffected.

D7 — Rollout (pilot-first)

  1. Pilot: zensical-pages — the exact pain point, lowest risk (docs-only, no code false-negative danger), and it proves the array input → rules:changes mechanism (D2). Own spec section / minor.
  2. hugo-pages (with the schedule carve-out).
  3. go-* / rust-* gates (conservative globs + secret-scan exemption).
  4. tofu-* gates.
  5. svelte-* track ships with change-detection from the start.

Each is a component change (spec addendum + release), propagated via Renovate.

D8 — Versioning

Adding an input → feat → minor per component-group release. Could batch 3+4 once the pilot validates the mechanism.

Resolved (review)

  • Default ON with conservative globs (2026-06-23) for the lint/test/build/docs gate components — the churn reduction is the point; comprehensive globs (all source + every lockfile/config + .gitlab-ci.yml) keep false-negatives rare, and changes: ["**/*"] restores full gating. Matches how interruptible / stable-cache-keys shipped as defaults.
  • Security components are exempt — ALWAYS-ON, no change-detection (2026-06-23). go-security, rust-security, svelte-security, tofu-security run every scanner on every MR regardless of which files changed. Rationale: maximum posture — every MR re-checks deps against the latest CVE DB and scans every file for secrets (there is no nightly re-scan to fall back on, since the schedule-never guard keeps security jobs off scheduled pipelines). Accepts that the expensive govulncheck runs even on a docs-only MR. This supersedes the earlier per-scanner split idea and the secret-scanning carve-out in D3 (now the whole security category is always-on, not just secrets).

Open questions for review

  • Confirm array-input → rules:changes interpolation in the pilot (validation task, not a policy decision).

All change-detection policy questions resolved (2026-06-23); one pilot validation item remains (array interpolation).