skip to content

In GitHub Actions, how do branches: and tags: filters on push behave?

level: juniorimportance: must knowfreq 68%

answer

  1. Two independent allow-lists over one ref
  2. A release workflow that never fires
  3. Globs, and one wildcard stops at a slash
  4. You cannot mix the positive and negative form
  5. For pull requests, it is the target side that is filtered

basics

~20 s

They are independent allow-lists over the pushed ref. A branches filter alone means tag pushes never trigger the workflow, and a tags filter alone means branch pushes never do; declaring both matches either. Patterns are globs, not regexes.

solid answer

~40 s

Under `on: push`, `branches` and `tags` filter the ref that was pushed, using glob patterns rather than regular expressions. They are separate namespaces: if you list only `branches`, pushing a tag matches nothing and the workflow does not run — which is the classic reason a release workflow never fires. Listing both means a push matching *either* one triggers a run. `branches-ignore` and `tags-ignore` are the negative forms, and you cannot use `branches` and `branches-ignore` for the same event — the workflow is rejected; put a `!` negation inside the positive list instead. In the globs `*` does not cross a `/` while `**` does, so `release/*` matches `release/v1` but not `release/2024/q1`. Under `pull_request`, `branches` filters the **base** branch, not the source branch.

code

yaml · 15 lines
yaml
on:
  push:
    branches:
      - main
      - 'release/**'          # ** crosses slashes; * would not
    tags:
      - 'v[0-9]+.[0-9]+.[0-9]+'
  pull_request:
    branches: [main]          # filters the BASE branch of the PR

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

go deeper

for a junior

Be able to write an on: push block with branches and tags and explain that a tag push does not match a branches filter. Know the patterns are globs, not regexes.

for a middle

Explain the mechanics: * versus ** across path separators, ordered evaluation with ! negations, the branches/branches-ignore mutual-exclusion error, and that pull_request filters the base branch.

for a senior

Demonstrate that ref filters are evaluated before a run exists, so they cost nothing, and show where they cannot reach — schedule and workflow_dispatch take no ref filters, so conditions move to a job-level if.

for a principal

Own the filtering strategy across a repository's workflow set: which runs are worth creating at all, how ref and path filters interact with required checks, and where filtering should live to keep the trigger surface legible.

## What the filters act on A GitHub Actions `push` event carries the full ref that was pushed — `refs/heads/main`, `refs/tags/v1.2.0`. The `branches`, `branches-ignore`, `tags` and `tags-ignore` filters test that ref against glob patterns, written **without** the `refs/heads/` or `refs/tags/` prefix: ``` on: push: branches: - main - 'release/**' tags: - 'v[0-9]+.[0-9]+.[0-9]+' ``` Unfiltered, `on: push` fires for every branch and every tag in the repository. Every filter you add narrows that. ## Branches and tags are separate namespaces This is the single most common misunderstanding. The two lists do not intersect — they are alternative ways for the pushed ref to match: - Only `branches` declared → a tag push matches no branch pattern, so **no run**. - Only `tags` declared → a branch push matches no tag pattern, so **no run**. - Both declared → a push matching *either* list triggers a run. - Neither declared → every push to any branch or tag triggers a run. So a workflow with `branches: [main]` will never fire on `git push origin v1.0.0`, and a release workflow with `tags: ['v*']` will never fire on ordinary commits to `main`. Teams routinely lose an afternoon to this, then add the missing list. ## Glob syntax, not regex The patterns are filter-pattern globs with a small, specific vocabulary: - `*` matches zero or more characters but **stops at `/`**. - `**` matches zero or more characters **including `/`**. - `?` matches one character; `+` matches one or more of the preceding character; `[]` matches one character from a set or range. - A leading `!` negates a pattern. So `release/*` matches `release/v1` but not `release/2024/q1`; `release/**` matches both. Characters like `*`, `[` and `!` are YAML-significant at the start of a scalar, which is why patterns are conventionally quoted. Negation only makes sense *after* something has matched: patterns are evaluated in order and the last one that matches decides. `['releases/**', '!releases/**-alpha']` runs for every release branch except the alpha ones. A list containing only negations matches nothing useful, and a `!` pattern placed before the positive pattern it was meant to carve out is simply overridden by it. ## The mutual-exclusion rule You may not use `branches` and `branches-ignore` for the same event — nor `tags` with `tags-ignore`, nor `paths` with `paths-ignore`. GitHub rejects the workflow with a syntax error rather than guessing your intent. When you need "everything except X", use the positive form with a `!` entry: ``` branches: - '**' - '!dependabot/**' ``` ## The pull_request twist `pull_request` also accepts `branches`/`branches-ignore`, but the semantics differ in a way interviewers like to probe: the filter applies to the pull request's **base** branch — where it would merge *to* — not the head branch it comes *from*. `on: pull_request: branches: [main]` therefore means "run for PRs targeting main", regardless of the contributor's branch name. `pull_request` has no `tags` filter at all, because a pull request does not target a tag. ## Interaction with paths `paths`/`paths-ignore` are a third, independent axis available on `push` and `pull_request`. When branch and path filters are both present they are ANDed: the ref must match *and* the changed files must match. Path filtering has real consequences for required status checks — a workflow filtered out by `paths` reports nothing at all — but that is a separate design problem from ref filtering. ## Where filters cannot help Ref filters are evaluated by GitHub before a run is created, so a filtered-out push costs no minutes and produces no entry in the run list. That is exactly why they are the cheapest control you have. But they are only available on events that *have* a ref to filter: `schedule` and `workflow_dispatch` take no `branches`/`tags`/`paths` filters, and a scheduled workflow always runs from the default branch. If you need conditional behaviour there, you do it with a job-level `if` instead, which costs a queued (if trivially short) run. ## Debugging checklist When a push workflow does not fire: confirm the pushed ref type is represented in the filters at all; check that a glob using `*` is not being defeated by a `/` in the ref name; check for an accidental `branches` + `branches-ignore` pair (the run list will be empty because the file failed to parse); and remember that for a *new* workflow file, the run you are waiting for is the push that added it — which itself has to match the filters.

  • A workflow with on: push: branches: [main] never runs when you push tag v1.0.0. Why, and what is the fix?
    A `branches` filter only matches branch refs; a tag push matches nothing in that list, so no run is created. Add a `tags` list to the same `push` event — `tags: ['v*']` — and pushes matching either list trigger the workflow. If the release job should only run for tags, gate it with `if: startsWith(github.ref, 'refs/tags/')`.
  • How do you express "every branch except dependabot ones" in a GitHub Actions push filter?
    Use the positive `branches` list with a negation entry: `['**', '!dependabot/**']`. You cannot pair `branches` with `branches-ignore` on the same event — GitHub rejects the workflow. Patterns are evaluated in order and the last match wins, so the negation must come after the broad pattern it carves out.
  • Under on: pull_request, which branch does the branches filter test?
    The base branch — the branch the pull request would merge into. `branches: [main]` means "pull requests targeting main", whatever the contributor's head branch is called. There is no `tags` filter for `pull_request`, since a pull request cannot target a tag.

saying these in an interview costs you the question

  • Thinks a branches filter also covers pushed tags
  • Treats the patterns as regular expressions
  • Pairs branches with branches-ignore on one event
  • Believes release/* matches release/2024/q1
  • Says pull_request branches filters the source branch

context