Skip to content

Filtering Triggers


An unfiltered push trigger fires on every branch, every commit, no matter what changed. That’s fine for a five-file toy repo. It’s a disaster for a real one — imagine a monorepo where a one-line change to a README kicks off a twenty-minute test suite, or where a workflow meant only for main also runs on every developer’s throwaway branch. Filtering is how you narrow “run on any push” down to “run on exactly the pushes that matter.”

Filtering By Branch

The most common filter restricts a trigger to specific branches using branches (an allow-list) or branches-ignore (a deny-list). You can’t use both on the same event.

name: Branch Filter Demo
on:
  push:
    branches:
      - main
      - 'release/**'

jobs:
  announce:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Push landed on ${{ github.ref_name }}"

Try it:

mkdir gha-filter-demo && cd gha-filter-demo
git init
mkdir -p .github/workflows
cat > .github/workflows/branch-filter.yml << 'EOF'
name: Branch Filter Demo
on:
  push:
    branches:
      - main
      - 'release/**'

jobs:
  announce:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Push landed on ${{ github.ref_name }}"
EOF
git add .
git commit -m "Add branch filter demo"
git branch -M main

Push this to GitHub, then create and push a scratch/testing branch — no workflow run happens. Push to main or release/1.0 — it runs. The ** is a glob pattern matching any number of path segments, so release/** matches release/1.0, release/1.0/hotfix, and so on.

Note

Branch filters on pull_request behave differently than on push: they match against the PR’s base branch (the branch it’s merging into), not the branch the PR was opened from.

Filtering By Tag

tags and tags-ignore work identically to their branch counterparts, but only apply when a tag is pushed.

on:
  push:
    tags:
      - 'v*.*.*'

This is the classic pattern for release workflows — build and publish an artifact only when someone pushes a semantic-version tag like v1.4.2, not on every regular commit.

Warning

branches and tags filters are independent of each other within push. If you specify only tags, ordinary branch pushes are excluded entirely — not just unfiltered. To match both branches and tags you must specify both filters explicitly.

Filtering By Path

paths and paths-ignore restrict the trigger based on which files changed in the push or PR — arguably the filter with the biggest real-world payoff, since it’s what stops a docs-only change from running your full test matrix.

name: Path Filter Demo
on:
  push:
    branches: [main]
    paths:
      - 'src/**'
      - '!src/**/*.md'

jobs:
  announce:
    runs-on: ubuntu-latest
    steps:
      - run: echo "A relevant source file changed"

Extend the demo to see this in action:

cat > .github/workflows/path-filter.yml << 'EOF'
name: Path Filter Demo
on:
  push:
    branches: [main]
    paths:
      - 'src/**'
      - '!src/**/*.md'

jobs:
  announce:
    runs-on: ubuntu-latest
    steps:
      - run: echo "A relevant source file changed"
EOF
mkdir src
echo "print('hello')" > src/app.py
echo "# notes" > src/notes.md
git add .
git commit -m "Add path filter demo and sample source files"

Push to GitHub. If your next commit only touches src/notes.md, the workflow will not run — the ! prefix excludes markdown files inside src/. If it touches src/app.py, it runs.

Note

paths-ignore and paths can’t both be used on the same trigger, same rule as branches/branches-ignore. Pick whichever reads more clearly for your case — an allow-list (paths) when only a small part of the repo matters, a deny-list (paths-ignore) when almost everything matters except a few noisy files like **.md or docs/**.

Combining Filters

Filters combine with AND logic across categories, but OR logic within a single list.

on:
  push:
    branches: [main, 'release/**']
    paths: ['src/**', 'Dockerfile']

This reads as: (branch is main OR matches release/**) AND (a changed file is under src/ OR is Dockerfile). Both conditions must hold for the workflow to fire.

Filter Quirks Worth Knowing

Path filters only apply to push and pull_request — they have no effect on workflow_dispatch or schedule, since those events aren’t tied to a set of changed files at all.

A push that includes multiple commits is evaluated against the combined set of changed files across all of them, not commit-by-commit. So if commit A only touches README.md and commit B (pushed in the same git push) touches src/app.py, a paths: ['src/**'] filter still matches, because somewhere in that push, a relevant file changed.

Required status checks (branch protection rules) interact awkwardly with path filters: if a check is marked required but its workflow never runs because the path filter excluded it, GitHub will show the PR as permanently blocked, waiting on a check that will never report. This catches people off guard constantly — if you mark a path-filtered workflow as a required check, be certain every possible PR either matches the path or you’ve configured a fallback.

How Real DevOps Engineers Use This

In a monorepo with a frontend and backend, it’s standard to have two nearly identical CI workflows, one filtered to frontend/** and one to backend/**, so a backend-only change doesn’t waste CI minutes running a frontend test suite (and vice versa).

Release engineers lean heavily on tag filters like v*.*.* to separate “every commit gets tested” from “only tagged releases get published” — this is the backbone of most semantic-versioning release pipelines.

Path-ignore lists for **.md, docs/**, and .github/ISSUE_TEMPLATE/** are close to universal in mature repositories, since documentation changes genuinely don’t need a full build-and-deploy cycle.

Clean up:

cd .. && rm -rf gha-filter-demo
Last updated on