The Gitflow workflow is usually presented through the git-flow CLI extension, which is fine until the day it is not installed, or the release has to happen from a CI runner, or something goes wrong halfway through and you need to know which of the six things release finish does had already happened.
Every step below is raw git. No extension required. The git flow equivalents are in Appendix A for anyone who does have it installed and just wants to know what it wraps.
Two details account for most of the release bugs I have seen, so they are worth stating before the mechanics:
- A release branch merges into both
mainanddevelop. Skipping the second merge silently loses the version bump and every stabilisation fix. - The tag is the release. Everything else — the branch, the merge commit, the changelog — is bookkeeping around a single annotated tag on
main.
Table of Contents
1. The Branch Model
Five kinds of branch, two of which live forever:
| Branch | Lives | Branches from | Merges into | Purpose |
|---|---|---|---|---|
main |
forever | — | — | Official release history. Every commit is a tagged release. |
develop |
forever | main (once) |
— | Integration branch. Holds the complete history of features. |
feature/* |
temporary | develop |
develop |
One new capability. Never touches main. |
release/* |
temporary | develop |
main and develop |
Stabilise and bump the version. No new features. |
hotfix/* |
temporary | main |
main and develop |
Urgent fix against production. |
The rules that actually matter:
mainonly ever receives merges fromrelease/*andhotfix/*. Never mergedevelopor a feature branch directly intomain.- Every commit on
maingets a version tag. - A release branch merges into both long-lived branches, so the version bump and any stabilisation fixes survive.
- Once a release branch exists,
developis free again — the next cycle's features can land there while the release is being polished.
That fork-and-rejoin shape on the release branch is the whole workflow in one picture. The branch leaves develop, collects the version bump and whatever the QA pass turns up, lands on main as a single revertable node, and then comes back down into develop.
2. One-Time Repository Setup
Only needed if develop does not exist yet.
git clone <repo-url>
cd <repo>
# Create develop off main and publish it
git checkout main
git pull origin main
git checkout -b develop
git push -u origin develop
Optionally make develop the default branch on the host (GitHub, GitLab, Bitbucket) so pull requests target it automatically, and protect both long-lived branches.
3. Cutting a Release
Assume the next version is 1.1.0. Substitute your own version everywhere.
3.1 Make sure develop is what you want to ship
git checkout develop
git pull origin develop
git log --oneline main..develop # review exactly what is going out
Everything intended for this release must already be merged into develop. Anything not merged yet waits for the next release — do not add features once the release branch is cut. That single rule is what makes the release branch stabilise instead of drifting.
3.2 Create the release branch
git checkout -b release/1.1.0 develop
git push -u origin release/1.1.0
From this moment develop is open for the next cycle's work.
3.3 Prepare the release on the branch
Only release-preparation commits belong here: version bumps, changelog, docs, and bug fixes found during testing. No new features.
# 1. Bump the version in whatever file holds it, e.g.:
# package.json / pyproject.toml / Cargo.toml / VERSION / CMakeLists.txt
$EDITOR VERSION
# 2. Update the changelog
$EDITOR CHANGELOG.md
git add -A
git commit -m "Bump version to 1.1.0"
git push
Then run the full test suite, build and QA against this branch. Fix problems by committing on the release branch:
git checkout release/1.1.0
$EDITOR src/thing.c
git commit -am "Fix off-by-one in thing parser"
git push
3.4 Merge the release into main and tag it
git checkout main
git pull origin main
git merge --no-ff release/1.1.0 -m "Release 1.1.0"
--no-ff is deliberate: it keeps an explicit merge commit, so the release is a single, revertable node in main's history rather than a run of commits you would have to unpick individually.
Tag the merge commit — this is the release artefact:
git tag -a v1.1.0 -m "Release 1.1.0"
git push origin main
git push origin v1.1.0
Use annotated tags
Use an annotated (-a) or signed (-s) tag, not a lightweight one. Annotated tags carry the tagger, date and message, and are what git describe picks up — which is usually what ends up embedded in a build as its version string.
3.5 Merge the release back into develop
This is the step people forget. Without it, the version bump and any fixes made during stabilisation exist only on main, and will be clobbered by the next release.
git checkout develop
git pull origin develop
git merge --no-ff release/1.1.0 -m "Merge release 1.1.0 back into develop"
git push origin develop
If this conflicts — usually only on the version file — resolve in favour of the release branch's content, then continue:
git status # see conflicted files
$EDITOR <conflicted-file>
git add <conflicted-file>
git commit # accept the default merge message
git push origin develop
3.6 Delete the release branch
git branch -d release/1.1.0
git push origin --delete release/1.1.0
3.7 Verify
git checkout main
git log --oneline -3
git describe --tags # should print v1.1.0
git log --oneline main..develop # release commits should NOT appear here
That last command is the cheap check on step 3.5. If the release commits still show up as "on develop but not on main", the merge back did not happen.
4. Hotfix Releases
A hotfix branches off main, not develop, so an urgent fix ships without dragging in unreleased work. Assume production is v1.1.0 and the fix is 1.1.1.
# 1. Branch from main
git checkout main
git pull origin main
git checkout -b hotfix/1.1.1
git push -u origin hotfix/1.1.1
# 2. Fix the bug and bump the patch version
$EDITOR src/broken.c
$EDITOR VERSION CHANGELOG.md
git commit -am "Fix crash on empty config; bump version to 1.1.1"
git push
# 3. Merge into main and tag
git checkout main
git merge --no-ff hotfix/1.1.1 -m "Hotfix 1.1.1"
git tag -a v1.1.1 -m "Hotfix 1.1.1"
git push origin main
git push origin v1.1.1
# 4. Merge into develop as well, so the fix is not lost
git checkout develop
git pull origin develop
git merge --no-ff hotfix/1.1.1 -m "Merge hotfix 1.1.1 into develop"
git push origin develop
# 5. Clean up
git branch -d hotfix/1.1.1
git push origin --delete hotfix/1.1.1
Exception: an open release branch
If a release branch is currently open, merge the hotfix into the release branch instead of develop. The release branch will carry the fix into develop when it is merged back in step 3.5. Merging into both produces a duplicated fix and a conflict later.
5. Feature Branches, for Context
Features never interact with a release directly; they land in develop first.
git checkout develop
git pull origin develop
git checkout -b feature/user-avatars
# ...commit work...
git push -u origin feature/user-avatars
# When approved (via PR, or locally):
git checkout develop
git pull origin develop
git merge --no-ff feature/user-avatars
git push origin develop
git branch -d feature/user-avatars
git push origin --delete feature/user-avatars
6. Versioning
Use Semantic Versioning — MAJOR.MINOR.PATCH:
| Component | Means | Usually ships via |
|---|---|---|
| MAJOR | Incompatible API changes | release/* |
| MINOR | Backwards-compatible functionality | release/* |
| PATCH | Backwards-compatible bug fixes | hotfix/* |
Tags are prefixed with v (v1.1.0); branches are not (release/1.1.0). Worth being consistent about, because git describe output and release-note tooling both key off the tag name.
7. The Release Checklist
Copy this into the release ticket or PR description.
- Everything intended for the release is merged into
develop git log --oneline main..developreviewedrelease/X.Y.Zcreated fromdevelopand pushed- Version bumped in all version-bearing files
CHANGELOG.mdupdated- Full test suite green on the release branch
- Build and packaging verified from the release branch
- Merged into
mainwith--no-ff - Annotated tag
vX.Y.Zcreated and pushed (git push origin vX.Y.Z) - Merged back into
developwith--no-ff - Release branch deleted locally and on the remote
- Release notes published on the host, from the tag
- Artifacts published — registry, images, binaries — if applicable
8. Recovering from Mistakes
Forgot to merge back into develop
Just do it now. The release branch may already be deleted, so merge the tag instead:
git checkout develop
git merge --no-ff v1.1.0
git push origin develop
Tagged the wrong commit, tag not yet pushed
git tag -d v1.1.0
git tag -a v1.1.0 -m "Release 1.1.0" <correct-sha>
Tagged the wrong commit, tag already pushed
Prefer cutting v1.1.1 over moving a published tag; anyone who already fetched the old tag keeps it, and no amount of remote surgery changes that. If you must:
git push origin :refs/tags/v1.1.0 # delete remote tag
git tag -d v1.1.0
git tag -a v1.1.0 -m "Release 1.1.0" <correct-sha>
git push origin v1.1.0
# then tell everyone to run: git fetch --tags --force
Bad release merged into main
Revert the merge commit, which keeps history honest, then fix forward on a new hotfix branch:
git checkout main
git revert -m 1 <merge-commit-sha>
git push origin main
-m 1 selects the first parent — the state of main before the release — as the mainline to revert back to.
Feature accidentally committed on the release branch
Move it to develop and take it back off the release:
git checkout develop
git cherry-pick <sha>
git push origin develop
git checkout release/1.1.0
git revert <sha>
git push
Appendix A — git flow CLI Equivalents
If the git-flow extension is installed, these wrap the manual steps above:
| Manual steps | git flow |
|---|---|
| §2 setup | git flow init |
| §3.2 create release | git flow release start 1.1.0 |
| §3.4–3.6 merge, tag, merge back, delete | git flow release finish 1.1.0 |
| §4 step 1 | git flow hotfix start 1.1.1 |
| §4 steps 3–5 | git flow hotfix finish 1.1.1 |
| §5 create feature | git flow feature start user-avatars |
| §5 merge feature | git flow feature finish user-avatars |
Note that finish does not push — follow it with:
git push origin main develop --tags
Appendix B — Quick Reference
# Release
git checkout -b release/X.Y.Z develop
# ...bump version, changelog, fixes, test...
git checkout main && git merge --no-ff release/X.Y.Z
git tag -a vX.Y.Z -m "Release X.Y.Z"
git checkout develop && git merge --no-ff release/X.Y.Z
git push origin main develop vX.Y.Z
git branch -d release/X.Y.Z && git push origin --delete release/X.Y.Z
# Hotfix
git checkout -b hotfix/X.Y.Z main
# ...fix, bump patch version...
git checkout main && git merge --no-ff hotfix/X.Y.Z
git tag -a vX.Y.Z -m "Hotfix X.Y.Z"
git checkout develop && git merge --no-ff hotfix/X.Y.Z
git push origin main develop vX.Y.Z
git branch -d hotfix/X.Y.Z && git push origin --delete hotfix/X.Y.Z
Source
This post is the expanded version of a gist I keep as a working reference: Manual Release Process (Gitflow). Background reading: Atlassian's Gitflow workflow tutorial and Vincent Driessen's original A successful Git branching model, which the model comes from.