Branching Strategies and Releases
How teams organize branches and versions: GitHub flow, trunk-based development and Git flow, release branches, tags, semantic versioning and conventional commit messages.
Why a team needs a strategy
Git does not care how you use branches; any pattern works technically. People are the problem: if one engineer keeps a branch open for a month while another merges small changes every hour, the first merge back becomes a week of conflicts. A branching strategy (also called a branching model or workflow) is the team's written agreement on questions like:
-
Which long-lived branches exist, and what does each one mean?
("
mainis always deployable.") - Where does new work start, how long may a branch live, and how is it named?
- How does work get back: a pull request with review, required checks, which merge method?
- How is a release made, and how is an urgent fix shipped?
The right answer depends mostly on how you ship. A web application or an internal tool that is deployed many times a day needs a different model than firmware or a product that customers install and keep running for years. The three best-known models below sit at different points on that scale. The continuous integration they all rely on is described in DevOps and CI/CD.
GitHub flow
GitHub flow is the lightweight
model GitHub describes for its own platform. It has one long-lived
branch, main, which is always deployable. Everything
else is a short-lived topic branch:
-
Create a branch from
mainwith a descriptive name (fix-ntp-servers). - Commit and push the branch regularly.
- Open a pull request; colleagues review it and automated checks run.
-
Merge into
mainonce approved and green, and delete the branch.
Because main is always deployable, many teams deploy it
automatically after each merge.
It is easy to learn and maps directly onto pull requests (see Pull Requests and Code Review). Its weak point is the same as its strength: there is only one version, the current one. It does not by itself tell you how to maintain version 2 while version 3 is out.
Trunk-based development
In
trunk-based development, everyone integrates into one shared branch, the
trunk (usually main), in very small steps:
directly or through branches that live hours, at most a day or two.
It takes the idea of continuous integration literally: integrate
continuously, so conflicts stay tiny and the trunk always reflects
the real state of the product.
Work that takes weeks still has to reach the trunk in small pieces before it is finished. Teams use two techniques for that:
- A feature flag hides unfinished functionality at run time, so incomplete code can be merged and deployed safely while it stays switched off.
- Branch by abstraction: introduce an interface first, build the new implementation behind it, and switch over when it is ready.
Trunk-based development demands discipline and good automated tests, because a broken trunk blocks everyone. The DevOps Research and Assessment (DORA) research program lists it among the technical practices associated with high-performing delivery teams. GitHub flow with branches that are merged within a day is, in practice, a form of trunk-based development.
Git flow
Git flow is the model Vincent Driessen published in 2010 as "A successful Git branching model". It uses two permanent branches and three kinds of temporary ones:
| Branch | Lives | Purpose |
|---|---|---|
main (master in Driessen's
article)
|
Forever | Only released versions; every commit on it is a tagged release |
develop |
Forever | Integration branch for the next release |
feature/* |
Days to weeks |
One feature, branched from and merged back into
develop
|
release/* |
Until the release ships |
Stabilize a version: only fixes, version numbers and release
notes; merged into main (and tagged) and back
into develop
|
hotfix/* |
Hours to days |
Urgent fix branched from main, merged into
main (new patch tag) and develop
|
Git flow brings order when releases are rare, planned events. It also costs a lot: two branches to keep in sync, long-lived feature branches, and double merges for every release and hotfix. In 2020 its author added a note to the original article recommending a simpler workflow such as GitHub flow for software that is delivered continuously, such as web applications, and keeping Git flow for explicitly versioned software, especially when several versions must be supported in the field. Network teams meet it in vendor projects and in tooling that ships as installable packages.
Release branches
A release branch is a branch
cut from the main line to prepare or maintain one version, for
example release/2.3. New features continue on the main
line; the release branch only receives fixes. Release branches are
not exclusive to Git flow: trunk-based teams often cut one shortly
before a release ("release from a branch, develop on trunk"), and
products that support several versions keep one per supported
version and apply fixes there, usually by fixing on the main line
first and
cherry-picking the
fix to each release branch.
git switch -c release/2.3 main # cut the release branch
# ...only fixes are committed here, tested, then:
git tag -a v2.3.0 -m "Release 2.3.0"
git switch main
git cherry-pick <fix-commit> # bring a fix made on the release branch back
Comparing the strategies
| GitHub flow | Trunk-based development | Git flow | |
|---|---|---|---|
| Permanent branches | main |
main (the trunk) |
main and develop |
| Topic branch lifetime | Days | Hours, at most a day or two | Days to weeks |
| Unfinished work | Stays on its branch | Merged, hidden behind feature flags | Stays on its feature branch |
| Releases | Deploy main, often continuously |
From trunk, or a short-lived release branch | Release branches, tags on main |
| Best fit | Web apps, internal tools, small teams | Teams with strong automated testing that deploy frequently | Versioned products with several supported versions |
Tags
A tag is a reference that gives one
commit a permanent, human-readable name, typically a version such as
v1.4.2. Unlike a branch, a tag does not move when you
commit. Git has two kinds:
- A lightweight tag is just a name pointing to a commit, like a branch that never moves.
- An annotated tag is a separate object in the object database, with its own tagger name, date and message, and it can be signed. Use annotated tags for releases; lightweight tags are for private bookmarks.
Creating and inspecting tags
git init -q net-tools && cd net-tools
echo "# Network tools" > README.md
git add README.md && git commit -q -m "Add README"
git tag v1.0.0
printf 'hostname R1\n' > router.cfg
git add router.cfg && git commit -q -m "feat: add R1 config"
git tag -a v1.1.0 -m "Release 1.1.0: R1 configuration"
git tag
git cat-file -t v1.0.0
git cat-file -t v1.1.0
git show --stat v1.1.0
v1.0.0
v1.1.0
commit
tag
tag v1.1.0
Tagger: Sam Rivera <sam@example.com>
Date: Mon Mar 2 09:00:00 2026 +0000
Release 1.1.0: R1 configuration
commit 3d5e2b15f7d11bcb644667ab78711af82b55f7ac
Author: Sam Rivera <sam@example.com>
Date: Mon Mar 2 09:00:00 2026 +0000
feat: add R1 config
router.cfg | 1 +
1 file changed, 1 insertion(+)
git cat-file -t shows the difference:
v1.0.0 points straight at a commit, while
v1.1.0 points at a tag object, which in turn
points at the commit. git show on an annotated tag
prints the tag's own header (who tagged, when and why) before the
commit. To tag an older commit, name it:
git tag -a v0.9.0 -m "…" e9f93d2. Tags are meant to be
permanent; if a tag is wrong, publish a new version instead of
moving it.
git describe
git describe names any commit relative to the nearest
annotated tag, which is handy for build numbers and "which version
is running on this box?":
git init -q net-tools && cd net-tools
echo "# Network tools" > README.md
git add README.md && git commit -q -m "Add README"
git tag -a v1.0.0 -m "Release 1.0.0"
printf 'hostname R1\n' > router.cfg
git add router.cfg && git commit -q -m "feat: add R1 config"
printf 'hostname R2\n' > r2.cfg
git add r2.cfg && git commit -q -m "feat: add R2 config"
git describe
git tag lab-tested
git describe
git describe --tags
v1.0.0-2-g0956434 v1.0.0-2-g0956434 lab-tested
v1.0.0-2-g0956434 reads: tag v1.0.0, plus
2 commits, at commit 0956434 (the g stands
for Git). By default only annotated tags count, so the lightweight
bookmark lab-tested is ignored until you add
--tags. That is one more reason to make release tags
annotated.
Pushing tags
git push does not send tags. Push them explicitly, one
at a time or all annotated tags that belong to the commits you push:
git init -q --bare shared.git
git init -q net-tools && cd net-tools
git remote add origin ../shared.git
echo "# Network tools" > README.md
git add README.md && git commit -q -m "Add README"
git tag -a v1.0.0 -m "Release 1.0.0"
git push -q -u origin main
git ls-remote --tags origin
git push origin v1.0.0
git ls-remote --tags origin
To ../shared.git
* [new tag] v1.0.0 -> v1.0.0
39e942a755d237335c828f194b719c0642d5a093 refs/tags/v1.0.0
e9f93d2fb391d65b509ecdd4b1e81340f82f2aa0 refs/tags/v1.0.0^{}
The first git ls-remote --tags printed nothing: pushing
main did not take the tag along. After the explicit
push, the remote lists the tag object (39e942a…) and,
marked with ^{}, the commit it points to
(e9f93d2…, the README commit).
| Command | Pushes |
|---|---|
git push origin v1.0.0 |
One tag |
git push --follow-tags |
The branch, plus annotated tags on the commits being pushed |
git push --tags |
Every tag, including stray lightweight ones (use with care) |
git push origin --delete v1.0.0 |
Deletes the tag on the remote |
On GitHub, a release is built on a tag: it adds release notes (which GitHub can generate from the merged pull requests) and downloadable files. In the ITSM sense a release is more than a tag; see Release and Deployment Management for why deploying and releasing are separate decisions.
Semantic versioning
Semantic versioning
(SemVer, specification 2.0.0 at semver.org) gives a version number a
precise meaning, so users can tell from the number alone whether an
upgrade is safe. A version is MAJOR.MINOR.PATCH, and
you increase:
| Part | When | Example |
|---|---|---|
| MAJOR | You make incompatible changes to the public API (application programming interface) | 1.9.3 → 2.0.0 |
| MINOR | You add functionality in a backward-compatible way | 1.9.3 → 1.10.0 |
| PATCH | You make backward-compatible bug fixes | 1.9.3 → 1.9.4 |
-
Increasing a part resets the parts to its right to zero, and
numbers have no leading zeros.
1.10.0comes after1.9.0: each part is compared as a number, not as text. -
0.y.zis initial development: anything may change at any time.1.0.0defines the public API. -
A pre-release is marked with a hyphen, as in
2.0.0-alpha.1or2.0.0-rc.1, and comes before the normal version:1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-beta < 1.0.0. -
Build metadata follows a plus sign
(
1.0.0+20260302) and is ignored when comparing versions. - A released version must never be changed. Any change, however small, needs a new version number.
The "public API" is whatever others depend on: function signatures
for a library, command-line options and output format for a tool,
the variable names a playbook expects. The Git tag is usually the
version with a v prefix (v2.0.0); the
prefix is a common convention, not part of the version itself.
Conventional Commits
Conventional Commits (specification 1.0.0) is a convention for commit messages that makes them readable for machines as well as people. The first line has the form:
<type>[optional scope][!]: <description> [optional body] [optional footer(s)]
-
fix:patches a bug and corresponds to a PATCH release. -
feat:adds a feature and corresponds to a MINOR release. -
A
!before the colon, or a footer starting withBREAKING CHANGE:, marks a breaking change and corresponds to a MAJOR release.BREAKING CHANGEmust be uppercase (BREAKING-CHANGEis accepted as a synonym); everything else is case-insensitive. -
Other types are allowed and common, for example from the Angular
convention:
build,chore,ci,docs,style,refactor,perfandtest. On their own they do not change the version.
feat(inventory)!: group devices by site Devices are now listed under their site instead of in one flat list, so playbooks can target a single location. BREAKING CHANGE: the top-level "routers" key is replaced by "sites". Refs: #42
Changelogs and automated releases
Because the type of every change is in its message, tools can go
from commits to a release without human bookkeeping: read the
commits since the last tag, compute the next SemVer version, write a
CHANGELOG.md section with "Features" and "Bug fixes",
create the tag and publish the release. Widely used examples are
semantic-release, release-please and the conventional-changelog
tools; teams usually run them in a CI pipeline (see
What Is CI/CD?). A linter such as
commitlint can reject messages that do not follow the format.
Computing the next version
The core rule of those release tools fits in a few lines of Python. The highest level wins: one breaking change makes a major release, however many fixes come with it.
import re
# <type>[optional scope][!]: <description>
HEADER = re.compile(r"^(?P<type>\w+)(\([^)]*\))?(?P<bang>!)?: \S")
BREAKING_FOOTERS = ("BREAKING CHANGE:", "BREAKING-CHANGE:")
def bump_level(message):
"""Return "major", "minor", "patch" or None for one commit message."""
lines = message.splitlines()
match = HEADER.match(lines[0])
if match is None:
return None # not a conventional commit: no version change
if match["bang"] or any(line.startswith(BREAKING_FOOTERS) for line in lines[1:]):
return "major"
kind = match["type"].lower()
if kind == "feat":
return "minor"
if kind == "fix":
return "patch"
return None # docs, chore, ci, test, refactor, ...
def next_version(current, messages):
major, minor, patch = (int(part) for part in current.split("."))
levels = {bump_level(message) for message in messages}
if "major" in levels:
return f"{major + 1}.0.0"
if "minor" in levels:
return f"{major}.{minor + 1}.0"
if "patch" in levels:
return f"{major}.{minor}.{patch + 1}"
return current
releases = {
"docs only": ["docs: explain the inventory format", "ci: cache pip downloads"],
"a bug fix": ["fix(backup): keep 30 days of configs", "docs: update README"],
"a feature": ["feat(inventory): add site field", "fix: trim spaces in hostnames"],
"breaking": [
"feat(cli)!: rename --host to --device",
"fix: handle empty inventory",
],
"footer": [
"refactor: split loader\n\nBREAKING CHANGE: load() now returns a dict",
],
}
for label, messages in releases.items():
print(f"{label:10} 1.4.2 -> {next_version('1.4.2', messages)}")
docs only 1.4.2 -> 1.4.2 a bug fix 1.4.2 -> 1.4.3 a feature 1.4.2 -> 1.5.0 breaking 1.4.2 -> 2.0.0 footer 1.4.2 -> 2.0.0
The last case shows that the footer counts as much as the
!: a refactor is normally invisible in the
version, but this one changes what load() returns, so
users must be warned with a major release. Real tools add details
this sketch leaves out, such as pre-releases and the convention many
projects follow of bumping only MINOR for breaking changes while
still in 0.y.z.