All posts
·14 min read· release-branches· hotfix· gitflow· versioning· operations

Release branches and hotfix workflows that don't lose commits

A hotfix shipped to prod but never made it back to develop, then got reverted three weeks later. Here is how release branches and hotfix workflows actually work without losing commits.

A team I know shipped a hotfix one Friday evening. A null pointer crash, easy fix, two-line change. They branched from main, fixed it, tagged it v2.3.1, deployed, and went home.

Three weeks later, the same crash came back. They could not figure out how — the fix was in v2.3.1. After two hours of digging, they realised what happened. The fix had landed on main. It had been tagged. It had been deployed. But it had never been merged back into develop, where new work was happening. When v2.4.0 shipped from develop and replaced v2.3.1 in production, the new release did not contain the fix. The bug was, quietly, back.

This is the "forgotten hotfix" failure mode. It is one of the most common ways teams lose commits in production — not because Git lost them, but because the workflow let them fall through a crack. This post is about parallel-timeline workflows: how release branches and hotfixes actually work, and how to run them without losing commits.

Why parallel timelines exist

Trunk-based development assumes a single timeline. There is main, and you ship main. If something is broken, you fix it on main and ship again. Simple.

Some software cannot work that way. Three examples:

Versioned SDKs and libraries. You ship v2.0. Customers depend on v2.0. You then start working on v3.0 with breaking changes. A security bug is found in v2.0. You need to ship v2.0.1 with only that fix. You cannot force v2.0 customers onto v3.0.

Mobile apps. You released v4.5 to the app stores. App Store review takes a week. Mid-week, a crash bug appears. You need to ship v4.5.1 quickly while v4.6 (with bigger changes) is still in development. v4.6 cannot ship yet; it has unfinished features.

Enterprise on-premise software. A customer is on v8.2. Upgrading to v8.3 requires their IT department to schedule a maintenance window. Until then, they need security patches on v8.2.

In all three, you have parallel timelines: at least two versions exist in the wild, and at least one needs to receive patches independently of the other.

Release branches are how Git models parallel timelines.

The mechanics of release branches

A release branch is a branch cut from main (or develop, depending on workflow) at the moment you decide a version is ready to ship. After the cut, only bug fixes go onto the release branch. New features continue on main.

# At v2.0 ship time
git checkout main
git pull
git checkout -b release/2.0
git push -u origin release/2.0

# Tag the released version
git tag -a v2.0.0 -m "Release 2.0.0"
git push --tags

Now release/2.0 exists. It is the source of truth for the v2.0 line. Patch releases (v2.0.1, v2.0.2) get cut from this branch and tagged on it.

Meanwhile, main keeps moving. New features land. Eventually v2.1 is ready, and a release/2.1 branch gets cut from the new main. v2.0 and v2.1 now coexist as parallel timelines.

Microsoft's Release Flow

Microsoft has documented its own variant in its branching guidance for Azure Repos. It is sometimes called Release Flow.

The structure:

The cherry-pick direction is important. Fixes flow mainrelease/*, not the other way. This guarantees that anything fixed on a release branch is also on main — because it started there.

# Bug found in v2.0, also affects v2.1 in development
git checkout main
git checkout -b fix-null-crash
# ... fix and commit ...
git push -u origin fix-null-crash
# Open PR, merge to main

# Cherry-pick into the release branch
git checkout release/2.0
git cherry-pick <commit-sha-from-main>
git push origin release/2.0

# Tag and ship the patch
git tag -a v2.0.1 -m "Patch release 2.0.1"
git push --tags

This pattern is simple and almost impossible to lose commits with, because every fix exists on main by construction.

Gitflow's hotfix mechanism

Driessen's original Gitflow uses a different mechanism. Hotfixes branch directly from main, get fixed there, and merge back into both main and develop.

# Critical production bug
git checkout main
git checkout -b hotfix/null-crash
# ... fix and commit ...
git push -u origin hotfix/null-crash

# Merge to main and tag
git checkout main
git merge --no-ff hotfix/null-crash
git tag -a v2.0.1 -m "Hotfix 2.0.1"
git push origin main --tags

# Merge to develop (this is the step people forget!)
git checkout develop
git merge --no-ff hotfix/null-crash
git push origin develop

The danger: that last merge into develop is easy to forget. When someone is fixing a production fire at 11pm, they ship to main, breathe a sigh of relief, and go home. Three weeks later, the fix gets reverted by the next release from develop.

Cherry-pick vs forward-merge

The two strategies — cherry-pick (Microsoft Release Flow) and forward-merge (Gitflow hotfix) — solve the same problem differently.

Cherry-pick (Release Flow style). Fix on main first. Cherry-pick the commit to the release branch. The release branch always lags main.

Pros:

Cons:

Forward-merge (Gitflow hotfix style). Branch from main/release branch. Fix there. Merge into both the release line and develop/main.

Pros:

Cons:

For most modern teams, cherry-pick from main to release branches is the safer default. The "fix exists on main by construction" property is hard to give up.

The forgotten-hotfix failure mode

The bug in the story at the top of this post had a name. It is the most common way hotfix workflows lose commits.

The pattern:

  1. Production fire. Engineer branches from main, fixes, ships.
  2. Engineer is exhausted, goes home.
  3. Engineer forgets to merge the fix back into develop (or back-port to other release branches).
  4. Days or weeks pass.
  5. Next release ships from develop. That release does not contain the fix.
  6. The bug re-appears in production.
  7. Several engineering hours of confusion follow.

How to prevent it:

Process-level fixes:

Tool-level fixes:

# Commits on main that are not on develop
git log develop..main --oneline

# Commits on a release branch that are not on main
git log main..release/2.0 --oneline

If either list contains anything other than expected merges, investigate.

Semantic versioning, briefly

If you have parallel timelines, you need a versioning scheme. Semantic versioning is the de facto standard. The summary, taken directly from the spec:

Given a version number MAJOR.MINOR.PATCH, increment the:

  • MAJOR version when you make incompatible API changes,
  • MINOR version when you add functionality in a backward compatible manner,
  • PATCH version when you make backward compatible bug fixes.

For release-branch workflows, this gives you a clear naming scheme: release/2.0 receives patches that increment the PATCH portion (2.0.1, 2.0.2). New minor releases get a new branch (release/2.1). New major releases get a new branch (release/3.0).

Semver is not the only scheme — calendar versioning (CalVer) is also widely used, especially for software with date-driven releases (Ubuntu's 24.04, for instance). Whatever scheme you pick, write it down and stick to it.

A practical hotfix runbook

A template you can adapt. This assumes Release Flow (cherry-pick from main).

  1. Reproduce the bug. Confirm it exists in the released version. Confirm it exists on main (otherwise, the fix is already there).
  2. Open an incident ticket. Capture the version affected, the symptom, the customer impact.
  3. Branch from main. Write the fix. Add a test that fails without the fix.
  4. Open a PR to main. Get expedited review. Merge.
  5. Cherry-pick to each affected release branch.
    git checkout release/2.0
    git cherry-pick <sha>
    git push origin release/2.0
    
  6. Tag the patch. v2.0.1. Push the tag.
  7. Deploy the patched version. Verify the fix.
  8. Update the incident ticket. Close.
  9. Write a one-paragraph post-incident note. What broke, what fixed it, what would prevent it.

The runbook fits on one page. Print it. Tape it to the wall. Practice it before you need it.

How long should release branches live?

This is a question with no universal answer, but two clear principles.

As short as the support window requires. If you ship v2.0 and only support it until v2.1 ships a month later, release/2.0 can be deleted (or merged-and-archived) at that point. There is no need to keep dormant branches around.

As long as customers are running the version. If you ship v8 of an enterprise product and customers run v8 for two years, release/8.x may need to live for two years to receive security patches.

A common pattern for SDKs and frameworks: keep release branches for the last N major versions alive, where N matches your support policy. Older release branches get tagged for archival and deleted.

# Archive an old release branch
git checkout release/1.x
git tag archived/release-1.x-final
git push --tags
git push origin --delete release/1.x

The tag preserves history; the branch deletion stops it from cluttering the branch list.

Back-merging vs forward-cherry-picking, decided

Worth saying directly: for most modern teams, cherry-pick from main to release branches is the recommended default for hotfix workflows.

Why:

The original Gitflow forward-merge pattern (fix on hotfix branch, merge to both main and develop) is workable but failure-prone. Unless you have a strong reason to use it, prefer cherry-pick.

A concrete cherry-pick recipe

# Find the bug, fix on main first
git checkout main
git checkout -b fix-issue-1234
# ... fix, commit ...
git push -u origin fix-issue-1234
# Open PR, review, merge to main. Note the merge commit SHA.

# Cherry-pick with -x to record the source SHA in the commit message
git checkout release/2.0
git pull
git cherry-pick -x <commit-sha-on-main>
git push origin release/2.0

# Verify the fix is in both places
git log main --oneline | grep <issue>
git log release/2.0 --oneline | grep <issue>

# Tag and ship
git tag -a v2.0.1 -m "Patch release: fix issue 1234"
git push --tags

The -x flag adds a line like (cherry picked from commit abc1234) to the cherry-picked commit's message. Months later, when you wonder where that fix came from, the SHA is right there in the message.

Common pitfalls

Cherry-picking a non-atomic change. If the fix is one logical change spread across three commits, cherry-pick all three or squash first. Cherry-picking only one of three gets you a half-fix that compiles but does not work.

Cherry-picking through a refactor. If main has refactored the affected file since the release branch was cut, the cherry-pick will conflict. Resolve carefully — the fix logic must still be correct in the old shape of the code.

Tagging the wrong commit. Always tag the exact commit you deployed, not a later commit on the same branch. git tag -a v2.0.1 <commit-sha> is safer than tagging "wherever HEAD is right now."

Force-pushing a release branch. Never. Release branches are public artifacts that other people (and other systems) reference by SHA. A force-push silently invalidates all those references.

Common myths

Myth 1: "Release branches are only for Gitflow." Wrong. Release branches exist independently of Gitflow. Microsoft's Release Flow uses them with a trunk-based front end. Many teams use long-lived release branches without using develop. The branch is a tool; Gitflow is one assembly that uses it.

Myth 2: "Hotfixes should always skip code review." Wrong, mostly. Hotfix review can be lightweight — one approver instead of two, async approval acceptable — but skipping entirely is how production-breaking "fixes" get shipped at 11pm. Even a 60-second second-pair-of-eyes catches surprising things.

Myth 3: "If you tag releases, you don't need release branches." Wrong if you need to patch old versions. Tags are bookmarks; they do not allow new commits. To ship v2.0.1 six months after v2.0, you need a branch you can commit to. The branch is the writable timeline; the tag is the read-only marker on it.

What to read next

If you want to feel the mechanics of finding a regression that crept in between two releases, the Bisect lesson below opens a live terminal where you can practise binary-searching commit history in two minutes.