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/**togo-testso a Svelte change still runs the Go embed/served-UI tests (see2026-06-23-svelte-frontend-track.md). - Disable / full-gate escape hatch: set
changes: ["**/*"](or achange_detection: falsetoggle) 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)¶
- False negatives are the danger. A too-narrow filter lets an untested change merge. Mitigations, baked into every default:
- always include the consumer's
.gitlab-ci.yml(CI edits re-run everything); - 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, …); - prefer running a needless job over skipping a needed one.
- Artifact /
needsconsistency.zensical-pages/hugo-pageshavepages→needs: <build>. The samechangesfilter 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). - 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-securitystays 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)¶
- Pilot:
zensical-pages— the exact pain point, lowest risk (docs-only, no code false-negative danger), and it proves the array input →rules:changesmechanism (D2). Own spec section / minor. hugo-pages(with the schedule carve-out).go-*/rust-*gates (conservative globs + secret-scan exemption).tofu-*gates.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, andchanges: ["**/*"]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-securityrun 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 expensivegovulncheckruns 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:changesinterpolation 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).