GitFlow & Semantic Versioning: A Release Engineering Framework
GitFlow and semantic versioning solve two separate problems: how branches are organized, and how version numbers communicate change severity. Together, they make release engineering auditable and reversible.
GitFlow Branch Structure
%%{ init: { 'look': 'handDrawn' } }%%
gitGraph
commit id: "init"
branch develop
checkout develop
commit id: "dev setup"
branch feature/user-auth
checkout feature/user-auth
commit id: "auth: base"
commit id: "auth: login"
checkout develop
merge feature/user-auth id: "merge auth"
branch feature/billing
checkout feature/billing
commit id: "billing: base"
commit id: "billing: invoice"
checkout develop
merge feature/billing id: "merge billing"
branch release/1.0.0
checkout release/1.0.0
commit id: "freeze"
commit id: "test pass"
checkout main
merge release/1.0.0 id: "v1.0.0 ✓"
checkout develop
commit id: "v1.1.0 prep"
checkout main
branch hotfix/1.0.1
commit id: "critical fix"
checkout main
merge hotfix/1.0.1 id: "v1.0.1 ✓"
checkout develop
merge hotfix/1.0.1 id: "sync fix"
| Branch | Purpose | Lifetime |
|---|---|---|
main | Production code, tagged releases | Permanent |
develop | Integration branch for features | Permanent |
release/X.Y.Z | Freeze, test, release prep | Until release |
feature/X | Individual feature work | Until merged |
hotfix/X.Y.Z | Emergency production fixes | Until merged |
Semantic Versioning (SemVer 2.0.0)
%%{ init: { 'look': 'handDrawn' } }%%
graph LR
A[v2.3.1] --> B[MAJOR:2<br/>Breaking changes]
A --> C[MINOR:3<br/>New features<br/>backward compatible]
A --> D[PATCH:1<br/>Bug fixes<br/>backward compatible]
E[Pre-release] -->|alpha| F[v2.4.0-alpha.1]
E -->|beta| G[v2.4.0-beta.2]
E -->|rc| H[v2.4.0-rc.1]
- MAJOR: Breaking changes in the public API
- MINOR: New functionality, backward compatible
- PATCH: Bug fixes, backward compatible
- Pre-release:
alpha,beta,rcfor testing
Automated Version Tagging with Makefile
CURRENT_VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null || echo "0.0.0")
MAJOR := $(word 1,$(subst ., ,$(CURRENT_VERSION)))
MINOR := $(word 2,$(subst ., ,$(CURRENT_VERSION)))
PATCH := $(word 3,$(subst ., ,$(CURRENT_VERSION)))
tag-major:
git tag -a v$(shell expr $(MAJOR) + 1).0.0 -m "Release v$(shell expr $(MAJOR) + 1).0.0"
@echo "Tagged: v$(shell expr $(MAJOR) + 1).0.0"
tag-minor:
git tag -a v$(MAJOR).$(shell expr $(MINOR) + 1).0 -m "Release v$(MAJOR).$(shell expr $(MINOR) + 1).0"
@echo "Tagged: v$(MAJOR).$(shell expr $(MINOR) + 1).0"
tag-patch:
git tag -a v$(MAJOR).$(MINOR).$(shell expr $(PATCH) + 1) -m "Release v$(MAJOR).$(MINOR).$(shell expr $(PATCH) + 1)"
@echo "Tagged: v$(MAJOR).$(MINOR).$(shell expr $(PATCH) + 1)"
tag-rc:
@git fetch --tags
@CURRENT=$(shell git describe --tags --abbrev=0)
@MAJOR=$(shell echo $(CURRENT) | cut -d. -f1 | tr -d 'v')
@MINOR=$(shell echo $(CURRENT) | cut -d. -f2)
@PATCH=$(shell echo $(CURRENT) | cut -d. -f3)
@NEXT_RC=v$(MAJOR).$(MINOR).$(shell expr $(PATCH) + 1)-rc.1
@git tag -a $(NEXT_RC) -m "Release candidate $(NEXT_RC)"
@echo "Tagged: $(NEXT_RC)"
.PHONY: tag-major tag-minor tag-patch tag-rc
make tag-patch # v1.2.3 → v1.2.4
make tag-minor # v1.2.4 → v1.3.0
make tag-major # v1.3.0 → v2.0.0
make tag-rc # v1.3.0 → v1.3.1-rc.1
Docker Image Tagging Strategy
%%{ init: { 'look': 'handDrawn' } }%%
graph LR
A[git tag v1.2.3] --> B[CI builds image<br/>tag: 1.2.3]
A --> C[CI builds image<br/>tag: 1.2<br/>major.minor]
A --> D[CI builds image<br/>tag: 1<br/>major only]
A --> E[CI builds image<br/>tag: latest]
F[Dockerfile] -->|FROM python:3.8-slim| G[Production<br/>pin exact version]
G -->|prevents drift| H[Reproducible builds]
| Tag pattern | Use case | Safety |
|---|---|---|
python:3.8-slim | Patch-pinned, updates automatically | ⚠️ May break unexpectedly |
python:3.8 | Minor-pinned, stable | ✅ Recommended for most |
python:3 | Major-pinned, very stable | ✅ Safe for dev |
python:latest | Never use in production | ❌ |
Changelog Automation
# .github/workflows/release.yml
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Generate changelog
run: |
git fetch --tags
VERSION=${GITHUB_REF#refs/tags/v}
CHANGELOG=$(git log --oneline $(git describe --tags --abbrev=0 ^)..HEAD)
echo "## What's New in v${VERSION}" > CHANGELOG.md
echo "$$CHANGELOG" >> CHANGELOG.md
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
body_path: CHANGELOG.md
draft: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
The Hotfix Workflow
%%{ init: { 'look': 'handDrawn' } }%%
graph TD
A[main: v1.2.0] -->|critical bug| B[hotfix/1.2.1]
B --> C[Fix in hotfix branch]
C --> D[Test + validate]
D --> E{Merge to main<br/>and develop?}
E -->|yes| F[main: v1.2.1<br/>develop: v1.3.0]
E -->|if release branch exists| G[main: v1.2.1<br/>release: v1.2.1<br/>develop: v1.3.0]
Hotfixes merge to both main and develop simultaneously, ensuring the fix is in the next release.
What This Enables
- Rollback:
git checkout v1.2.0on main → exact production state - Auditable changelog: Each tag documents exactly what changed since the last release
- Reproducible deployments: Docker images tagged with exact versions, never
latest - Clear communication:
v2.0.0tells every consumer “this has breaking changes”
GitFlow decides which branches exist. It says nothing about what your history looks like once those branches land — that’s the merge strategy, and picking the wrong one will undo most of the auditability GitFlow just bought you.