Enforce branch names

Copy-paste configuration that checks branch names against the specification, for GitHub, GitLab, Bitbucket, plain Git and AI coding agents.

Enforcement happens at two layers, and they solve different problems. A server-side rule cannot be bypassed, but the contributor only finds out when they push. A local check is instant and can be run before a branch exists, but anyone can skip it.

Every regular expression below is the one published in spec.json, copied verbatim, and a conformance check fails the build if this page and the specification ever disagree. The expression is deliberately written in a portable subset — no backreferences, no lookaround — so it compiles under RE2 as well as PCRE, which is what lets the same string work in GitHub, GitLab, Python, JavaScript, and Go alike.

That check covers the expressions, not the prose. What each platform charges for, and which of them can block a push rather than merely report one, is true as written but verified by hand — if you find it out of date, please say so.

It already permits main, master and develop unprefixed, so you do not need to carve out exceptions for your trunk branches.

GitHub

Rulesets

A ruleset rejects a non-conforming branch outright, and on github.com it is the only thing that does. The branch_name_pattern rule takes a regular expression directly.

{
  "name": "Conventional Branch",
  "target": "branch",
  "enforcement": "active",
  "conditions": {
    "ref_name": {
      "include": ["~ALL"],
      "exclude": []
    }
  },
  "rules": [
    {
      "type": "branch_name_pattern",
      "parameters": {
        "name": "Branch names must follow the Conventional Branch specification",
        "negate": false,
        "operator": "regex",
        "pattern": "^(?:main|master|develop|(?:feature|feat|bugfix|fix|hotfix|release|chore|ai|copilot|cursor|claude|codex)/[a-z0-9]+(?:\\.[a-z0-9]+)*(?:-[a-z0-9]+(?:\\.[a-z0-9]+)*)*)$"
      }
    }
  ]
}

Save it as conventional-branch.json and import it under Settings → Rules → Rulesets → New ruleset → Import a ruleset, or apply it from the command line:

gh api repos/OWNER/REPO/rulesets --method POST --input conventional-branch.json

Start with "enforcement": "evaluate" to see what the rule would have rejected without blocking anyone, then switch to "active".

Rulesets are available on public repositories with every plan, and on private repositories with GitHub Pro, Team, and Enterprise Cloud.

GitHub Actions

A pull request check, for when a ruleset is not an option or you want the failure explained in the PR. commit-check-action checks the branch name by default and reports the result as a job summary, and as a comment on the pull request if you let it:

name: Branch name

on: pull_request

jobs:
  conventional-branch:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write  # only for pr-comments
    steps:
      - uses: actions/checkout@v7
      - uses: commit-check/commit-check-action@v2
        with:
          branch: true
          message: false  # true also checks commit messages against Conventional Commits
          pr-comments: true

It reads the commit-check.toml described under local checks, so CI and every contributor’s machine apply the same configuration. As of commit-check 2.18 it validates the type prefix and the trunk names; the rules for the description — lowercase, hyphens, no doubled separators — are enforced by the ruleset above or the script below, not by the action.

A script that applies the regex from the specification in full, if you would rather not depend on an action:

name: Branch name

on: pull_request

jobs:
  conventional-branch:
    runs-on: ubuntu-latest
    steps:
      - name: Validate the branch name
        env:
          BRANCH: ${{ github.head_ref }}
        run: |
          curl -sSfLo spec.json https://conventionalbranch.org/v1.1.0/spec.json
          python3 - <<'PY'
          import json, os, re, sys

          spec = json.load(open("spec.json"))
          branch = os.environ["BRANCH"]

          if re.fullmatch(spec["grammar"]["regex"], branch):
              print(f"{branch}: ok")
              sys.exit(0)

          types = ", ".join(f"{t['type']}/" for t in spec["types"])
          print(f"::error::'{branch}' does not follow the Conventional Branch "
                f"specification. Expected <type>/<description>, where type is one "
                f"of: {types}. See https://conventionalbranch.org/")
          sys.exit(1)
          PY          

The URL is the version-pinned one on purpose: a future release of the specification cannot change what your pipeline accepts until you choose to bump it.

GitLab

Push rules reject the branch at pre-receive, before it reaches the server.

Under Settings → Repository → Push rules → Branch name, paste:

^(?:main|master|develop|(?:feature|feat|bugfix|fix|hotfix|release|chore|ai|copilot|cursor|claude|codex)/[a-z0-9]+(?:\.[a-z0-9]+)*(?:-[a-z0-9]+(?:\.[a-z0-9]+)*)*)$

Three things worth knowing before you rely on it:

  • Push rules require GitLab Premium or Ultimate. On the Free tier, use the CI job below instead.
  • GitLab evaluates the expression in multiline mode. It makes no difference to a branch name, which cannot contain a newline, but if you ever extend the pattern you can turn it off with a leading (?-m).
  • Your default branch is always allowed, whether or not the pattern matches it.

For the Free tier, the same check as a CI job:

conventional-branch:
  image: python:3-alpine
  rules:
    - if: $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME
  script:
    - apk add --no-cache curl
    - curl -sSfLo spec.json https://conventionalbranch.org/v1.1.0/spec.json
    - |
      python3 -c '
      import json, os, re, sys
      spec = json.load(open("spec.json"))
      branch = os.environ["CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"]
      if not re.fullmatch(spec["grammar"]["regex"], branch):
          sys.exit(f"{branch} does not follow https://conventionalbranch.org/")
      print(f"{branch}: ok")
      '      

Bitbucket

Bitbucket Data Center runs pre-receive hooks, so the rule can be enforced on the server. Bitbucket Cloud does not run arbitrary server-side hooks — the check has to live in Pipelines, which means it reports a failure rather than refusing the push.

pipelines:
  pull-requests:
    '**':
      - step:
          name: Branch name
          image: python:3-alpine
          script:
            - apk add --no-cache curl
            - curl -sSfLo spec.json https://conventionalbranch.org/v1.1.0/spec.json
            - |
              python3 -c '
              import json, os, re, sys
              spec = json.load(open("spec.json"))
              branch = os.environ["BITBUCKET_BRANCH"]
              if not re.fullmatch(spec["grammar"]["regex"], branch):
                  sys.exit(f"{branch} does not follow https://conventionalbranch.org/")
              print(f"{branch}: ok")
              '              

Local checks

commit-check

commit-check checks the branch name against the specification’s type prefixes out of the box — conventional_branch = true is its default — and as a pre-commit hook it runs at every commit, so a wrong name is caught before it is anywhere but your machine:

repos:
  - repo: https://github.com/commit-check/commit-check
    rev: v2.18.0
    hooks:
      - id: check-branch

Its default type list is a superset of the specification’s: it also accepts the Conventional Commits types (docs/, refactor/, …) and the dependabot/ and renovate/ prefixes. To accept exactly the types the specification defines, add a commit-check.toml at the repository root:

[branch]
conventional_branch = true
allow_branch_types = [
  "feature", "feat",
  "bugfix", "fix",
  "hotfix",
  "release",
  "chore",
  "ai", "claude", "codex", "copilot", "cursor",
]
allow_branch_names = ["main", "master", "develop"]

Both lists are the specification’s own — the types with their aliases, and the trunk branches — and the conformance check holds them to spec.json as it holds the regexes on this page, so a prefix registered in a future release cannot leave this file behind. The same file configures the GitHub Action above and commit-check --branch anywhere else. Like the action, it checks the type prefix; the description grammar is the hook below’s to enforce, or the server rule’s.

A hook with no dependencies

pre-push is the last local moment where a bad branch name can still be caught, and this needs nothing installed. Save this as .git/hooks/pre-push and make it executable:

#!/bin/sh
# Reject a push from a branch that does not follow the Conventional Branch
# specification. https://conventionalbranch.org/

pattern='^(main|master|develop|(feature|feat|bugfix|fix|hotfix|release|chore|ai|copilot|cursor|claude|codex)/[a-z0-9]+(\.[a-z0-9]+)*(-[a-z0-9]+(\.[a-z0-9]+)*)*)$'
branch=$(git symbolic-ref --short HEAD)

if ! printf '%s' "$branch" | grep -qE "$pattern"; then
  echo "Branch '$branch' does not follow the Conventional Branch specification." >&2
  echo "Expected <type>/<description>, for example feature/add-login-page." >&2
  echo "See https://conventionalbranch.org/" >&2
  exit 1
fi

This is the only place on this page where the expression differs from spec.json, and only in that the non-capturing groups (?: are written as plain (. POSIX grep -E does not understand (?:, and grep -P is a GNU extension that macOS does not ship — on a Mac it would fail to run and reject every branch. The conformance check verifies that this form is the mechanical translation of the published expression and that it accepts exactly the same branch names.

Git hooks are not copied by git clone. To share them with the team, commit the script to the repository and point Git at it once:

git config core.hooksPath .githooks

A branching alias

Enforcement tells people they are wrong. An alias makes it easier to be right:

[alias]
    feature = "!f() { git switch -c \"feature/$(printf '%s' \"$*\" | tr 'A-Z _' 'a-z--')\"; }; f"
    bugfix  = "!f() { git switch -c \"bugfix/$(printf '%s' \"$*\" | tr 'A-Z _' 'a-z--')\"; }; f"
    hotfix  = "!f() { git switch -c \"hotfix/$(printf '%s' \"$*\" | tr 'A-Z _' 'a-z--')\"; }; f"
    chore   = "!f() { git switch -c \"chore/$(printf '%s' \"$*\" | tr 'A-Z _' 'a-z--')\"; }; f"
git feature add login page   # creates and switches to feature/add-login-page
git feature Fix_Header       # creates and switches to feature/fix-header

The tr handles the three ways people usually get it wrong without noticing: uppercase, underscores, and words typed with spaces instead of hyphens. It is a convenience rather than a check — a description with other punctuation in it still gets through, which is what the hook above is for.

AI coding agents

Agents open pull requests now, which makes their branch names your branch names. Two ways to teach them the convention.

The agent skill

npx skills add conventional-branch/conventional-branch --skill conventional-branch

A snippet for AGENTS.md or CLAUDE.md

Several projects on the adopters list already document the convention this way rather than for people. Paste it into whichever instruction file your tooling reads:

## Branch naming

Branch names follow the [Conventional Branch](https://conventionalbranch.org/)
specification: `<type>/<description>`, lowercase, hyphen-separated.

Types: `feature/` (or `feat/`), `bugfix/` (or `fix/`), `hotfix/`, `release/`, `chore/`.
`main`, `master` and `develop` take no prefix.

Examples: `feature/add-login-page`, `bugfix/fix-header-overflow`, `release/v1.2.0`.

When you create a branch yourself rather than at a person's direction, prefix it with
your own agent name instead — `claude/`, `codex/`, `copilot/`, `cursor/`, or the
vendor-neutral `ai/` — so the branch records who wrote it.

Any other platform

The portable artifact is the regular expression, and the most reliable way to get it is from the specification itself rather than by copying it out of a document:

import json, re, urllib.request

SPEC = "https://conventionalbranch.org/v1.1.0/spec.json"  # pinned, never changes

spec = json.load(urllib.request.urlopen(SPEC))
valid = re.compile(spec["grammar"]["regex"])

print(bool(valid.fullmatch("feature/add-login-page")))  # True

If you are implementing validation in a tool, run it against fixtures.json as well, so your behavior matches every other implementation.

If you would rather not implement anything, pip install commit-check puts the pre-commit check above on any CI that can run Python — GitLab’s Free tier, Bitbucket Pipelines, Jenkins — as commit-check --branch, reading the same commit-check.toml.