Skip to content

2.02 Event Filters: Branches, Tags, and Paths

Overview

Activity types control which variation of an event fires a workflow. Filters control which target it fired against — the branch, the tag, or the files that changed.

Abstract

Filters are keys nested under an event: branches, branches-ignore, tags, tags-ignore, paths, and paths-ignore. They are supported on a short list of events — push, pull_request, pull_request_target, and workflow_call. Patterns accept globs, where * matches any characters except / and ** matches across slashes. Filters of different kinds combine with AND: a push must match both the branch list and the path rules to trigger the workflow.

Why It Matters in Production

A deploy workflow with a bare push trigger runs on every branch, so any experimental commit publishes to production. branches: [main] is the one-line difference between a pipeline that deploys deliberately and one that deploys by accident.

Path filters are what keep monorepo CI affordable. Without them, a README typo runs the full integration suite for every service. With them, each pipeline reacts only to the directory it owns.

The cost is subtlety: filters are evaluated on the changed files in the push, not the repository state, and a filtered workflow that is also a required status check can leave pull requests stuck waiting for a run that never starts.

Key Concepts

Filter Applies to Effect
branches push, pull_request Only refs matching the list trigger the workflow
branches-ignore push, pull_request Everything except the listed refs triggers it
tags push Only matching tag pushes trigger it
tags-ignore push All tags except the listed ones
paths push, pull_request Run only if at least one changed file matches
paths-ignore push, pull_request Run only if at least one changed file does not match

Glob patterns

Pattern Matches Does not match
main main exactly main-2, dev/main
'dev-*' dev-new, dev-this-is-new dev-new/thing, developer-1
'feat/**' feat/new, feat/new/button feature/new
'releases/**' any depth under releases/ release/1
'v1.*' tags v1.0, v1.9 v2.0
'**.js' any .js file at any depth app.ts
'!docs/**' negation — exclude previously matched paths —

* vs **

A single asterisk matches any run of characters other than /. A double asterisk crosses slashes. So 'dev-*' covers dev-new but not dev/new, while 'feat/**' covers feat/new/button to any depth.

How filters combine

Within one filter, entries are OR'd — any match counts. Across different filters, they are AND'd — a push must satisfy the branch list and the path rules. You cannot use branches and branches-ignore on the same event; pick one and use ! negation inside it if you need both behaviors.

Quoting: patterns containing *, [, !, or / should be quoted so YAML doesn't misparse them. main is fine bare; 'dev-*' is not.

Forked pull requests: matching the filters is not enough

A pull request opened from a fork by a first-time contributor does not start a workflow run automatically, even when it satisfies every activity type and filter. The run appears in the Actions tab marked with a warning and waits for a maintainer to approve it.

Who opens the PR Behavior
Repository owner or collaborator Runs immediately
Fork contributor, first PR Queued pending maintainer approval
Fork contributor, previously approved Runs automatically
Fork contributor whose PRs were never approved Keeps requiring approval

The reason is abuse prevention: anyone can fork a public repository, so without the gate a stranger could open hundreds of pull requests to spam the repository with runs, consume Actions minutes, or probe the workflow for exploitable behavior. Trusted collaborators are exempt because adding someone as a collaborator is itself a trust decision.

Filters apply to the target branch, not the fork's branch

A fork's source branch name is irrelevant to branches: filters. A PR from dev-max on a fork into main matches branches: [main] because the filter inspects the branch being targeted.

Approval is per contributor, not per pull request

Once a maintainer approves a fork contributor's first run, that contributor's later pull requests trigger workflows automatically. Approving a run therefore grants ongoing CI access, not a one-time exception — review the diff before clicking, especially any change under .github/workflows/.

The policy is configurable under Settings → Actions → General → Fork pull request workflows from outside collaborators, with options ranging from requiring approval for all outside collaborators to requiring it only for first-time contributors.

Common Use Cases

  • push: branches: [main] — deploy only from the default branch
  • push: tags: ['v*'] — build and publish a release only on version tags
  • pull_request: branches: [main] — run CI only for PRs targeting main
  • paths: ['services/api/**'] — in a monorepo, run the API pipeline only for API changes
  • paths-ignore: ['docs/**', '**.md'] — skip CI for documentation-only commits
  • paths-ignore: ['.github/workflows/*'] — avoid re-running a pipeline when only its own definition changed

Example Configuration or Commands

Branch filters on both events, plus path filtering on pushes:

# .github/workflows/demo1.yml
name: Events Demo 1
on:
  pull_request:
    types:
      - opened
    branches:
      - main                 # main
      - 'dev-*'              # dev-new, dev-this-is-new
      - 'feat/**'            # feat/new, feat/new/button
  workflow_dispatch:
  push:
    branches:
      - main
      - 'dev-*'
      - 'feat/**'            # developer-1 is NOT covered by any of these
    paths-ignore:
      - '.github/workflows/*'
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Output event data
        run: echo "${{ toJSON(github.event) }}"
      - name: Get code
        uses: actions/checkout@v4
      - name: Install dependencies
        run: npm ci
      - name: Test code
        run: npm run test
      - name: Build code
        run: npm run build
      - name: Deploy project
        run: echo "Deploying..."

With that configuration, committing a change to the workflow file itself and pushing to main starts no run: the branch matches, but the only changed file sits in the ignored path.

on:
  push:
    branches:
      - main
      - 'release/**'
# Everything except these — cannot be combined with `branches`
on:
  push:
    branches-ignore:
      - 'wip/**'
      - 'dependabot/**'
on:
  push:
    branches:
      - '**'
      - '!wip/**'       # order matters: later patterns override earlier ones
on:
  push:
    tags:
      - 'v*.*.*'        # v1.0.0, v2.3.1
# Runs only when at least one changed file matches
on:
  push:
    branches: [main]
    paths:
      - 'src/**'
      - 'package.json'
      - 'package-lock.json'
# Runs unless every changed file matches these patterns
on:
  push:
    branches: [main]
    paths-ignore:
      - 'docs/**'
      - '**.md'

Exact paths-ignore semantics

The workflow runs when at least one changed file is not matched by the ignore patterns. A commit touching only docs/readme.md is skipped; a commit touching docs/readme.md and src/app.js still runs, because src/app.js falls outside the ignore list.

Monorepo: one workflow per service

# .github/workflows/api.yml
on:
  push:
    branches: [main]
    paths: ['services/api/**', 'libs/shared/**']

# .github/workflows/web.yml
on:
  push:
    branches: [main]
    paths: ['services/web/**', 'libs/shared/**']
Shared library changes trigger both; a change confined to one service triggers only its own pipeline.

Verifying behavior:

# Which files did the last commit touch? This is what path filters evaluate.
git diff-tree --no-commit-id --name-only -r HEAD

# Did a run start for that push?
gh run list --limit 5 --json event,headBranch,displayTitle,status

# Confirm how the trigger block parsed
yq '.on' .github/workflows/demo1.yml

Best Practices

Best Practices

  • Put branches: [main] on any workflow that deploys, publishes, or releases. Treat an unfiltered push on such a workflow as a defect.
  • Prefer branches over branches-ignore — an allowlist fails closed when someone invents a new branch prefix.
  • Quote every pattern containing a glob character.
  • In monorepos, scope each pipeline with paths rather than running everything and exiting early — a skipped run costs nothing, an early-exit run still allocates a runner.
  • Remember pull_request: branches: filters the target branch, not the source branch. Use github.head_ref in an if: to inspect the source.
  • Keep comments next to patterns showing what they match; 'feat/**' is not self-explanatory to everyone reading the file.
  • If a filtered workflow is a required status check, pair it with a skip-job of the same name so PRs that legitimately skip it can still merge.

Security Best Practices

Security

  • Branch filters are convenience, not authorization. Anyone with write access can create a branch named release/evil and match 'release/**'. Enforce real control with branch protection rules and deployment environments requiring reviewers.
  • Tag filters are weaker still — tags are movable and can be deleted and recreated against a different commit. For release pipelines, verify the tagged commit or require signed tags.
  • paths-ignore: ['.github/workflows/*'] is convenient but means workflow changes ship without their own pipeline running. Review workflow edits through pull requests instead of relying on CI to catch them.
  • Never filter pull_request_target as a way to make it "safe". The risk is the context it runs in — base-repository secrets with a fork's code — and no branch or path filter changes that.
  • A paths filter guarding a privileged job can be bypassed by including an unrelated matching file in the same commit. Use environments and if: conditions for anything that deploys.
  • Keep least-privilege permissions: regardless of filters; filters decide when a workflow runs, never what its token may do.
  • Do not disable the fork-approval requirement to make contributor PRs "just work". It is the control that stops strangers from running your workflows at will, and it exists precisely because filters cannot distinguish a trusted contributor from an anonymous one.
  • Read the diff before approving a fork run. A PR that edits .github/workflows/ is asking you to execute its author's workflow definition on your runners.
  • Fork PRs receive a read-only GITHUB_TOKEN and no secrets. Treat a PR that needs secrets to pass as a design problem, not as a reason to reach for pull_request_target.
  • Approval is granted to the contributor, not the pull request. One careless approval turns into standing CI access for that account.

Do and Don't

✅ Do ❌ Don't
Filter deploy workflows to branches: [main] Leave on: push unfiltered on a deploy pipeline
Quote glob patterns Write dev-* unquoted in YAML
Use ** when the pattern must cross slashes Expect 'feat/*' to match feat/new/button
Pick either branches or branches-ignore Use both on the same event
Scope monorepo pipelines with paths Run every pipeline on every commit
Treat branch filters as routing Treat branch filters as access control
Check the filter cheat sheet when unsure Guess at pattern semantics
Review a fork PR's diff before approving its run Approve queued fork runs to clear the backlog
Expect fork PRs to need approval Assume a matching filter guarantees a run

Common Mistakes

Common Mistakes

  • * where ** was needed. 'feat/*' does not match feat/new/button.
  • Assuming prefix matching. main matches only main; main-hotfix needs 'main*'.
  • Combining branches and branches-ignore. Invalid on the same event — use ! negation.
  • Expecting tags and branches to narrow each other. They are independent lists; a push matching either triggers the workflow.
  • Filtering pull_request by source branch. branches: there refers to the base branch.
  • Unquoted patterns. '!wip/**' unquoted is a YAML parse error, and *foo is a YAML alias reference.
  • Stuck required checks. A filtered workflow that never runs leaves its required status check pending forever, blocking the merge.
  • Forgetting path filters compare changed files. A revert that restores a file still counts as a change to it.
  • Expecting filters on unsupported events. Only push, pull_request, pull_request_target, and workflow_call accept them.
  • Debugging filters when the real cause is fork approval. A fork PR that matches everything still shows up as pending approval rather than running — the filters are fine.
  • Reading the fork source branch as the filtered branch. A fork's dev-max branch never has to match branches:; only the target branch does.

Breaks in production

paths-ignore: ['.github/workflows/*'] plus a required status check means a PR that only edits workflow files never produces the check it needs to merge — and the edited workflow is never validated before it lands on main.

Troubleshooting

# Files changed in the push — the input to paths/paths-ignore
git diff-tree --no-commit-id --name-only -r HEAD

# Files changed in a PR, as the filter sees them
gh pr diff <number> --name-only

# Did anything trigger, and on which branch?
gh run list --limit 10 --json event,headBranch,status,conclusion

# Test a glob locally before committing
git branch --list 'feat/**'

# Is the PR from a fork?  If so, its first run needs approval.
gh pr view <number> --json isCrossRepository,headRepositoryOwner,baseRefName

# Runs waiting on maintainer approval
gh run list --status action_required
Symptom Likely cause
No run after pushing a workflow change Its own path is in paths-ignore — expected
Workflow runs on branches you didn't expect Pattern broader than intended, e.g. 'dev*' matching developer-1
Workflow never runs on a feature branch Branch name not covered by any pattern
"Invalid workflow file" on the on: block Unquoted glob, or branches + branches-ignore together
PR blocked on a check that never ran Filtered workflow is a required status check
Tag push doesn't trigger tags not specified, or the pattern doesn't match the tag name
Path filter seems ignored Filter placed under the wrong event, or on an event that doesn't support it
Run listed with a warning icon, never starts Fork PR from a first-time contributor awaiting approval
Fork PR run passes but a secret-dependent step fails Fork PRs get a read-only token and no secrets
Contributor's second PR ran without asking Expected — approval persists per contributor

Tip

The workflow syntax reference ends with a filter pattern cheat sheet covering branch, tag, and path pattern rules together — the fastest way to settle a * vs ** question. See Workflow syntax for GitHub Actions.

Quick Recap

  • Filters are supported on push, pull_request, pull_request_target, and workflow_call.
  • branches / branches-ignore filter by ref; tags / tags-ignore filter tag pushes.
  • paths / paths-ignore filter by the files a commit or PR changed.
  • * matches any characters except /; ** crosses slashes.
  • Entries within one filter are OR'd; different filters are AND'd.
  • branches and branches-ignore cannot be used together on the same event — use ! negation.
  • paths-ignore skips a run only when every changed file matches the ignore list.
  • On pull_request, branches: refers to the target branch.
  • Filters are routing, not security.
  • A fork PR from a first-time contributor matches the filters but still waits for maintainer approval; once approved, that contributor's later PRs run automatically.
  • Collaborators are exempt from the approval gate; fork PRs run with a read-only token and no secrets.

Interview / Revision Notes

Quick revision

Q: Which events support filters? push, pull_request, pull_request_target, and workflow_call.

Q: Difference between * and **? * matches any characters except /; ** matches across slashes.

Q: What does 'dev-*' match? dev-new, dev-this-is-new — not developer-1 and not dev-new/thing.

Q: Can you use branches and branches-ignore on the same event? No. Use one, with ! negation if you need exclusions.

Q: How do paths and branches interact? AND — both must match for the workflow to trigger.

Q: When does paths-ignore let a run through? When at least one changed file falls outside the ignore patterns.

Q: On pull_request, which branch does branches: filter? The target (base) branch.

Q: How do you trigger a workflow only for release tags? push: tags: ['v*.*.*'].

Q: Why shouldn't branch filters be used as access control? Anyone with write access can create a matching branch; use branch protection and environments.

Q: A fork PR matches every filter but no run starts. Why? First-time fork contributors require manual approval before their workflow runs execute.

Q: Why does that gate exist? Anyone can fork a public repository, so without it strangers could spam runs, consume Actions minutes, and probe workflows for exploitable behavior.

Q: Who is exempt? Collaborators, and fork contributors whose earlier runs you already approved.

Q: Does the fork's source branch name have to match branches:? No — the filter only looks at the target branch.

Q: What access does a fork PR run get? A read-only GITHUB_TOKEN and no repository secrets.

This topic can be expanded into sub-pages: the full filter pattern cheat sheet, monorepo pipeline strategies, required status checks with filtered workflows, tag-driven release pipelines, and pull_request vs pull_request_target.