Git, CI/CD & GitOpsXLVIII · Conditional ExecutionTriggers
Branch and path filters — narrowing triggers by branch name and changed files
What you'll learn
- Write `branches:` and `branches-ignore:` filters in GitHub Actions and the equivalent in GitLab CI
- Write `paths:` and `paths-ignore:` filters and predict the order of evaluation
- Recognise the difference between "no paths filter" and "match-all paths filter"
- Combine branch and path filters safely without producing an unreachable trigger
Prerequisites
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
A branch filter says only run for this branch. A path filter says only run when these files changed. Both evaluate before the runner is allocated, so the guard they express is the cheapest you can write.
Branch and path filters
on:
push:
branches:
- main
- 'releases/**'
branches-ignore:
- 'wip/**'
paths:
- 'src/**'
- 'infra/**'
paths-ignore:
- 'docs/**'
- '**.md'
branches: is the allow list; the trigger fires only
when the ref matches. branches-ignore: is the deny
list. If both are present, both must be satisfied.
Pattern grammar is glob: * matches one segment,
** matches many. The GitLab CI equivalent is a
rules: clause at the job level.
flowchart TD
A[Event arrives] --> B{Branch filter passes?}
B -- "no" --> Z[Skip silently]
B -- "yes" --> C{Path filter present?}
C -- "no" --> R[Run - implicit match-all]
C -- "yes" --> D{Any changed file in paths?}
D -- "yes" --> R
D -- "no" --> E{Any changed file in paths-ignore?}
E -- "yes" --> Z
E -- "no" --> R
Three rules that catch the common errors:
- No
paths:filter means match-all. Emptypaths: []is not the same as nopaths:key. paths-ignore:only fires whenpaths:produced no match. A change touching bothsrc/foo(inpaths:) anddocs/index.md(inpaths-ignore:) still triggers.- Filter by directory, not extension.
infra/**outlives**.tf.
Combining them safely
The pattern that avoids the failure mode is one declarative statement of intent:
on:
push:
branches: [main]
paths: ['infra/**']
paths-ignore: ['infra/experimental/**']
pull_request:
branches: [main]
paths: ['infra/**']
Read aloud: on push or pull request to main, where the diff touches infra but not the experimental subtree. The intersection is the case you actually care about. Path filters evaluate client-side at the API layer before any workflow file is read - the cost is a diff comparison, not a runner allocation. The trade-off is they only see the diff at the commit boundary.
Production discipline
- Default-deny, not default-allow. An explicit
branches:andpaths:list says run only for these. - Quote patterns.
'releases/**'today; the unquoted form will break when YAML 1.3 lands. - Test the empty intersection. Is there a plausible change that satisfies all clauses? If not, the workflow never runs.
- Do not filter by extension. Filter by directory.
Cross-course references
- Ansible for Production Sysadmins - Part VI covers directory-based inventory patterns.
- Terraform for Production Sysadmins - Part XII
covers the layout of
infra/in a repository.
Quiz
Knowledge check · 4 questions
Q1. A workflow has `branches: ['docs/**']` and `paths: ['infra/**']` in its `on.push` block. Which failure mode is most likely?
Q2. A `paths-ignore:` clause suppresses a trigger even when a file in `paths:` was also changed in the same commit.
Q3. Write a GitHub Actions `on:` block that runs only on pull requests to `main` whose diff touches any Terraform file.
Q4. Diagnose why a Terraform plan workflow stopped firing after a directory restructure.
Terraform code now lives under `infra/prod/`, `infra/staging/`, `infra/legacy/`. The plan workflow was updated to `paths: ['infra/**']` but no plans fire on feature-branch pushes.
Passing score: 75%. Answers are checked in this browser.