Back

Cutting a Release by Hand: Gitflow with Plain Git


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 main and develop. 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.

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:

  • main only ever receives merges from release/* and hotfix/*. Never merge develop or a feature branch directly into main.
  • Every commit on main gets 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, develop is free again — the next cycle's features can land there while the release is being polished.
main ──●────────────────────●───────────●── (tags: v1.0.0, v1.1.0, v1.1.1) \ / / release \ ●──●──●─/ / release/1.1.0 \ / \ / develop ──●───●────●──────────●────●───●───── (release fixes merged back) \ / \ feature ●──● ●──● feature/…

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 VersioningMAJOR.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..develop reviewed
  • release/X.Y.Z created from develop and pushed
  • Version bumped in all version-bearing files
  • CHANGELOG.md updated
  • Full test suite green on the release branch
  • Build and packaging verified from the release branch
  • Merged into main with --no-ff
  • Annotated tag vX.Y.Z created and pushed (git push origin vX.Y.Z)
  • Merged back into develop with --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.