Skip to content
ExponaBLOG
GIT & GITHUB FOR PROFESSIONAL DEVELOPERS · DAY 044 / 100

Release branches: Git & GitHub best practices explained

Maintain multiple versions.

Expona Admin6 min read

Release branches: Git & GitHub best practices explained

When you ship production software, you rarely support just one version at a time. Teams managing SaaS platforms, mobile apps, and libraries need to maintain multiple release versions simultaneously—patching security holes in the current release while working on next quarter's features. Release branches: Git & GitHub best practices explained in this article will show you how to structure your workflow so you can backport critical fixes, tag patch releases, and gracefully retire old versions without tangling your main development line.

The challenge is real: your customer runs v2.1 in production and finds a critical bug. Your main branch is already three sprints ahead with breaking changes for v3.0. You need a clean way to apply a fix to v2.1 without pulling in those breaking changes. This is where release branches become indispensable.

Branch from a tag for maintenance

The lifecycle of a release branch starts the moment you're ready to stabilize a version for production. Instead of branching from main whenever a bug surfaces, you create a release branch from the exact tag that represents a released version. This gives you a stable baseline—no surprises from commits that landed on main while you were working.

Let's say you're running version 2.1.0 in production. That version has a tag v2.1.0 on a specific commit. Now a critical security vulnerability is discovered. You don't branch from main; you branch from that tag:

bash
git checkout v2.1.0
git checkout -b release/2.1.x

This creates a release branch called release/2.1.x anchored to the exact code state of v2.1.0. The x in the branch name signals that this branch will handle all patch releases for the 2.1 line (2.1.1, 2.1.2, etc.). The branch exists independently of main and won't inherit any features or changes that have since landed there.

When you check out the tag directly and then create a branch, you get a clean starting point. Many teams push this branch to a shared repository immediately so multiple developers can contribute fixes:

bash
git push -u origin release/2.1.x

From this point forward, release/2.1.x becomes the maintenance line for version 2.1. It's isolated, stable, and ready to accept targeted fixes. Other developers know that any commits to this branch are intended for v2.1 patch releases, not for next-generation development.

Cherry-pick fixes from main

In a healthy development workflow, most bug fixes happen on main first. Your team finds a bug, fixes it on main, tests it with the full feature set, and merges it. Then, if that bug also affects a released version, you cherry-pick the commit to the release branch. This keeps the fix logic consistent across versions.

Imagine the security fix from the previous scenario: you develop and test the patch on main, and it's approved and merged as commit abc1234. Now you need that same fix on release/2.1.x. You cherry-pick it:

bash
git checkout release/2.1.x
git cherry-pick abc1234

The cherry-pick command applies that commit's changes to the release branch. If there are no conflicts, the fix lands cleanly. If the code structure differs between main and the release branch, you'll get a conflict that you resolve manually—ensuring the fix still makes sense in the older version's context.

Why not just merge main into the release branch? Because main is the development branch; it contains work-in-progress features, refactors, and changes that may break backward compatibility. Merging main into a release branch would pull in all that noise and risk introducing new bugs. Cherry-picking is surgical: you take only the commits you need.

The workflow typically looks like this:

  1. Bug found in production (v2.1.0).
  2. Developer creates a fix commit on main, tests thoroughly, opens a pull request.
  3. Team reviews and merges to main.
  4. Developer (or automation) cherry-picks that commit hash to release/2.1.x.
  5. A quick smoke test confirms the fix works on the release branch too.
  6. The release branch is ready for a patch release.

This approach scales well. If you're supporting three versions simultaneously and a fix applies to all three, you cherry-pick the same commit to all three release branches. Each gets its own patch release, and customers can upgrade within their version line.

Tag patch releases

Every time you release a patch, you tag it. Tags are immutable markers in your Git history that point to a specific commit. For release branches, tags follow semantic versioning: if the last release on the 2.1 line was v2.1.0, the next patch is v2.1.1, then v2.1.2, and so on.

After cherry-picking a fix to release/2.1.x and running final tests, you bump the version in your code (if you have a version file), commit that change, and tag it:

bash
git checkout release/2.1.x
git log --oneline -5  # Verify your fixes are there
# Update version file or package version
git add .
git commit -m "Release: v2.1.1"
git tag -a v2.1.1 -m "Security patch: XYZ vulnerability fixed"
git push origin release/2.1.x
git push origin v2.1.1

The annotated tag (-a) includes metadata—a message, the tagger's name, and a timestamp. This makes it easy for other developers to see why a tag exists. Always push tags explicitly; they don't go up with branch pushes by default.

On GitHub, tags appear as releases in the repository's Release section. Teams often automatically build and deploy from tags, triggering CI/CD pipelines that produce binaries, Docker images, or deploy to production. A clean tag workflow ensures deployments are reproducible and traceable.

If you're managing a library or framework, tags are especially important because downstream consumers pin dependencies to specific versions. A missed tag can cause confusion about what code is actually deployed.

Delete when support ends

Release branches aren't forever. The moment a version reaches end-of-life (EOL), its release branch becomes dead weight. Keeping old branches around clutters your repository and confuses new team members about which versions are actually supported.

Let's say v2.1 reached EOL on December 31st, and your policy is to support it for 12 months. After that date, no more patches. The branch release/2.1.x should be deleted:

bash
# Delete locally
git branch -d release/2.1.x

# Delete from remote
git push origin --delete release/2.1.x

Before you delete, make sure every commit on that branch has a corresponding tag. The tag is permanent; the branch can vanish. If a commit exists only on the branch and has no tag, you'll lose it (though Git's reflog can sometimes help). The rule: tag before you delete.

Some teams keep a document listing which versions are supported and until when. When EOL arrives, that version's release branch gets deleted, and the documentation is updated. This clarity helps support teams know which versions deserve backports and which ones don't.

Deleting old release branches also keeps your GitHub interface clean. When you open the branches page, you see only active versions—the ones your team is actually maintaining.

Common mistakes

Merging main into release branches: As mentioned, this pulls in too much unrelated work. Stick to cherry-picks for surgical control.

Forgetting to tag patch releases: Without tags, you lose the historical record of what code was deployed. Tags are your deployment anchors.

Branching from main instead of tags: If you branch from main at the time you want to release, main may already have commits that aren't in the released version. Start from the tag to guarantee you have the exact code customers are running.

Not documenting support windows: Teams create release branches but never publish EOL dates. Support staff waste time applying fixes to deprecated versions. Document which versions are supported and until when.

Deleting branches without tagging all commits: Commits without tags can become orphaned. Always ensure every meaningful state on a release branch is tagged before deletion.

Key takeaways

  • Branch from tags, not from main, to ensure you're working with the exact released code.
  • Cherry-pick fixes from main to release branches rather than merging, keeping patch releases focused and clean.
  • Tag every patch release with annotated tags following semantic versioning (v2.1.1, v2.1.2, etc.).
  • Delete release branches only after their EOL date and only after verifying all commits are tagged.
  • Document support windows for each version so your team knows which branches to maintain.
  • Push tags explicitly to your remote; they don't travel with branch pushes by default.
  • Use release branch naming conventions (e.g., release/X.Y.x) to make branch purpose obvious at a glance.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles