Skip to main content
· Git · 4 min read

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"
BranchPurposeLifetime
mainProduction code, tagged releasesPermanent
developIntegration branch for featuresPermanent
release/X.Y.ZFreeze, test, release prepUntil release
feature/XIndividual feature workUntil merged
hotfix/X.Y.ZEmergency production fixesUntil 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, rc for 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 patternUse caseSafety
python:3.8-slimPatch-pinned, updates automatically⚠️ May break unexpectedly
python:3.8Minor-pinned, stable✅ Recommended for most
python:3Major-pinned, very stable✅ Safe for dev
python:latestNever 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.0 on 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.0 tells 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.