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 branchpush: tags: ['v*']— build and publish a release only on version tagspull_request: branches: [main]— run CI only for PRs targeting mainpaths: ['services/api/**']— in a monorepo, run the API pipeline only for API changespaths-ignore: ['docs/**', '**.md']— skip CI for documentation-only commitspaths-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.
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
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 unfilteredpushon such a workflow as a defect. - Prefer
branchesoverbranches-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
pathsrather 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. Usegithub.head_refin anif: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/eviland 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_targetas 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
pathsfilter guarding a privileged job can be bypassed by including an unrelated matching file in the same commit. Use environments andif: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_TOKENand no secrets. Treat a PR that needs secrets to pass as a design problem, not as a reason to reach forpull_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 matchfeat/new/button.- Assuming prefix matching.
mainmatches onlymain;main-hotfixneeds'main*'. - Combining
branchesandbranches-ignore. Invalid on the same event — use!negation. - Expecting
tagsandbranchesto narrow each other. They are independent lists; a push matching either triggers the workflow. - Filtering
pull_requestby source branch.branches:there refers to the base branch. - Unquoted patterns.
'!wip/**'unquoted is a YAML parse error, and*foois 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, andworkflow_callaccept 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-maxbranch never has to matchbranches:; 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, andworkflow_call. branches/branches-ignorefilter by ref;tags/tags-ignorefilter tag pushes.paths/paths-ignorefilter 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.
branchesandbranches-ignorecannot be used together on the same event — use!negation.paths-ignoreskips 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_requestvspull_request_target.