Choosing a branching model for an open-source project Jump to heading

Open-source projects have constraints internal teams do not. Most contributors work from forks and cannot push branches to the main repository. Maintainers are few, volunteer their time, and review in bursts. Releases are versioned and users stay on old versions for years. Contributors arrive with no context, read the contributing guide once if at all, and give up if the process is confusing. A branching model that works well for a product team — develop branches, environment branches, elaborate release trains — becomes friction here. The model that serves most projects is simple: one default branch that is always in a releasable state, pull requests from forks, tags for releases, and release branches only when there are versions to support. This page walks through that choice and its supporting rules, within GitFlow vs GitHub Flow comparison.

When to use this approach Jump to heading

  • You maintain an open-source project, or are starting one.
  • Contributors find your branching rules confusing, or open pull requests against the wrong branch.
  • You inherited a GitFlow setup and wonder whether develop earns its keep.
  • Your project ships versioned releases that users install.

Step 1 — Use one default branch that contributors target Jump to heading

Contributors open pull requests against the default branch, whatever it is called. Make that branch the one where development happens, so the default is right. A separate develop branch as the default and main for releases confuses newcomers — some target main anyway — and adds a merge step for maintainers.

gh repo edit --default-branch main
gh api "repos/$OWNER/$REPO" --jq .default_branch
GitFlow against a single default branch for open sourceWith GitFlow, contributors must target develop, maintainers merge develop into main for releases, and pull requests against main are common mistakes. With a single default branch, contributors target the default without thinking, releases are tags on it, and release branches appear only for supported old versions.GitFlowSingle default branchPR target for contributorsdevelop (often wrong)main (the default)release stepmerge develop → maintag mainold versionshotfix branchesrelease/X.Y if supportedconcepts to learnfive branch typesoneevery rule a contributor must learn is a reason some of them will not contribute

Step 2 — Keep the default branch releasable Jump to heading

Because users may install from the default branch and releases are cut from it, it must always pass the full test suite. Require checks on pull requests, test the merge result, and keep releases a matter of tagging.

# Required checks on main — runs for fork pull requests without secrets
on:
  pull_request:
  push:
    branches: [main]
permissions: { contents: read }
jobs:
  test:
    strategy: { matrix: { python: ["3.10", "3.11", "3.12", "3.13"] } }
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "${{ matrix.python }}" }
      - run: pip install -e .[test] && pytest -q

Fork pull requests run without secrets; make sure required checks do not need any, as covered in running CI for fork pull requests safely.

Step 3 — Release by tagging, with release branches only for supported versions Jump to heading

A release is a signed tag on the default branch. When an older version needs fixes after a newer one ships, create its release branch from the old tag at that point — not in advance.

git tag -s v3.2.0 -m "Release 3.2.0" && git push origin v3.2.0
# Only when 3.1 needs a fix after 3.2 has shipped:
git switch -c release/3.1 v3.1.4 && git push -u origin release/3.1

Creating release branches lazily means most projects have none, or one or two, rather than a branch per version nobody will ever patch. The maintenance side is covered in supporting multiple maintained versions.

Step 4 — Write the rules contributors actually need Jump to heading

Contributors need three things from the branching model: which branch to target, how to keep their pull request up to date, and whether to squash. Say it in a few lines in CONTRIBUTING.md, and nothing more.

## Pull requests
- Fork the repository and branch from `main`. Open your pull request against `main`.
- You do not need to rebase or squash; maintainers squash-merge.
- If your branch falls behind, merging `main` into it is fine.

Squash merging on the maintainers’ side removes the need for contributors to learn interactive rebase, and keeps main readable regardless of how a contributor’s branch looks. The trade-offs are in squash vs merge vs rebase decision matrix.

A contribution's path in a simple open-source modelA contributor forks, branches from main and opens a pull request against main. CI runs on the merge result without secrets. A maintainer reviews and squash-merges. Releases are tags on main, so the contribution ships in the next tag without any further branch work.Fork + branchfrom mainPR to mainthe defaultCIno secretsSquash mergemaintainerNext tagships itno step requires the contributor to know anything about branches beyond 'main' A project's branches over its lifeA new project starts with only main and releases by tagging. When 2.0 ships and 1.x users still need security fixes, a release/1.x branch is created from the last 1.x tag. When 1.x leaves support, that branch is locked, and the project returns to a single active branch.main onlyv1.0 … v1.8 tagsyear 1v2.0 released1.x users remainyear 2release/1.xfrom v1.8.3year 21.x end of lifebranch lockedyear 3main onlyagainyear 3release branches come and go with supported versions, not with every release

Step 5 — Reduce maintainer load with automation Jump to heading

Maintainers’ time is the scarcest resource in most projects. Automate the parts of the model that do not need judgement: labelling pull requests by area, closing stale ones politely, generating release notes from merged pull requests, and backporting labelled fixes to supported release branches.

# Release notes from merged PR titles and labels, generated by the forge on tag
# .github/release.yml
changelog:
  categories:
    - { title: "Breaking changes", labels: ["breaking"] }
    - { title: "Features", labels: ["enhancement"] }
    - { title: "Fixes", labels: ["bug"] }
    - { title: "Other", labels: ["*"] }

Related automation is covered in auto-labelling pull requests by changed path and closing stale branches and pull requests.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Is GitFlow ever right for open source? Jump to heading

For large projects with scheduled releases and dedicated release managers, a stabilisation branch can help. Even then, a release branch cut from main before each release achieves the same without making contributors target a separate develop branch.

Should maintainers push directly to main? Jump to heading

Going through pull requests keeps CI and review consistent for everyone, maintainers included. Small projects sometimes allow maintainers to push trivial changes; require CI to pass regardless.

What about signed commits from contributors? Jump to heading

Requiring contributors to sign commits raises the bar to contributing considerably. Most projects instead sign release tags and rely on review; the options are discussed in handling unsigned commits from outside contributors.