Overview
Tagging creates immutable references to specific commits in version control systems, marking significant points such as release versions, milestones, or stable states. Tags differ from branches in that they represent fixed snapshots rather than evolving lines of development. The tag itself contains metadata including the creator, timestamp, and optional descriptive message or cryptographic signature.
Releases extend tagging by packaging tagged commits for distribution. A release combines the source code snapshot with compiled artifacts, documentation, release notes, and distribution channels. The release process transforms internal development artifacts into consumable software packages for end users, whether through package managers, download pages, or deployment pipelines.
The relationship between tags and releases forms a pipeline: development produces commits, tags mark specific commits as candidates, and releases package those tagged commits for distribution. This separation allows development to proceed continuously while controlling what users receive.
# Git tag creation
system("git tag -a v2.1.0 -m 'Release version 2.1.0'")
# Ruby gem release preparation
spec = Gem::Specification.new do |s|
s.name = 'myapp'
s.version = '2.1.0'
s.files = Dir['lib/**/*']
end
Modern development workflows integrate tagging and releases with continuous integration systems, automated testing, and deployment pipelines. Tags trigger build processes, while releases initiate distribution and notification workflows.
Key Principles
Immutability forms the foundation of tagging systems. Once created, a tag references a specific commit that cannot change. This guarantees that version 1.0.0 always points to identical code, regardless of when the tag is referenced. Immutability enables reproducible builds, audit trails, and reliable rollbacks. Version control systems enforce this through internal data structures that prevent tag modification without explicit force operations.
Semantic versioning structures version numbers as MAJOR.MINOR.PATCH, where each component signals specific types of changes. MAJOR increments indicate breaking changes that require user code modifications. MINOR increments add functionality while maintaining backward compatibility. PATCH increments fix bugs without adding features. Pre-release versions append identifiers like -alpha.1 or -rc.2 to indicate unstable code. Build metadata uses + separators for information that doesn't affect version precedence.
Tag types vary in purpose and content. Lightweight tags act as named pointers to commits, storing only the reference without additional metadata. Annotated tags create full Git objects containing tagger information, timestamp, message, and optional GPG signature. Signed tags add cryptographic verification through GPG keys, proving tag authenticity and integrity. The choice depends on auditability requirements and workflow formality.
# Semantic version parsing
require 'semantic'
version = Semantic::Version.new('2.3.1-beta.1+build.123')
version.major # => 2
version.minor # => 3
version.patch # => 1
version.pre # => "beta.1"
version.build # => "build.123"
Release artifacts package source code with additional materials for distribution. Source distributions include raw code files that users compile or interpret. Binary distributions contain pre-compiled executables for specific platforms. Package manager distributions format releases for ecosystem-specific tools like RubyGems, npm, or apt. Container images bundle applications with dependencies and runtime environments. Each artifact type targets different deployment scenarios and user capabilities.
Changelog management documents changes between releases in structured formats. Changelog entries categorize modifications as additions, changes, deprecations, removals, fixes, or security updates. Automated changelog generation parses commit messages using conventional commit formats, extracting change descriptions from commit metadata. Manual curation adds context, groups related changes, and highlights significant updates that automated tools might miss.
Release cadences establish timing patterns for publishing versions. Time-based releases occur on fixed schedules like monthly or quarterly intervals, prioritizing predictability over feature completion. Feature-based releases publish when specific functionality reaches completion, prioritizing quality over schedule. Continuous delivery releases every commit that passes validation, eliminating batching delays. Hotfix releases address critical issues outside normal cadence.
Implementation Approaches
Trunk-based development with tags maintains a single main branch where all development occurs. Developers commit directly to trunk or use short-lived feature branches that merge within days. Tags mark specific trunk commits as releases when quality gates pass. This approach minimizes merge complexity and enables rapid iteration but requires robust automated testing since trunk must always be release-ready.
# Automated tagging on trunk
def tag_release(version)
# Ensure trunk is clean
`git checkout main`
`git pull origin main`
# Run full test suite
raise "Tests failed" unless system("bundle exec rspec")
# Create annotated tag
tag_name = "v#{version}"
message = "Release #{version}\n\n#{generate_changelog(version)}"
`git tag -a #{tag_name} -m "#{message}"`
# Push tag
`git push origin #{tag_name}`
end
Git Flow with release branches separates development, release preparation, and production code across multiple long-lived branches. Development occurs on develop branch. Release branches fork from develop when features for a version complete, allowing bug fixes and preparation without blocking new development. Tags mark release branch commits before merging to both main and develop. Hotfixes branch from main for urgent production issues.
GitHub Flow for continuous deployment uses main branch as production state with feature branches for all changes. Pull requests trigger automated testing and review before merging. Every merge to main triggers automatic deployment to production. Tags mark significant versions retroactively for reference, but deployment occurs independently of tagging. This approach suits web applications with automated rollback capabilities.
Release trains with scheduled branches create time-boxed release branches on fixed schedules. Features that merge to trunk before the branch cut make the release; others wait for the next train. Each train follows a stabilization period where only bug fixes apply. Multiple release branches may exist simultaneously in different stabilization phases. This balances predictability with continuous development.
# Release train branch creation
def create_release_branch(version)
# Branch from current trunk state
`git checkout main`
`git pull origin main`
`git checkout -b release/#{version}`
# Update version file
File.write('lib/myapp/version.rb', <<~RUBY)
module MyApp
VERSION = "#{version}"
end
RUBY
# Commit and push
`git add lib/myapp/version.rb`
`git commit -m "Prepare release #{version}"`
`git push origin release/#{version}`
end
Tag-based deployment pipelines use tag creation as deployment triggers. Pushing a tag matching version patterns initiates CI/CD workflows that build artifacts, run test suites, and deploy to appropriate environments. Tags matching stable version patterns deploy to production. Pre-release tags deploy to staging environments. This decouples deployment timing from commit activity.
Tools & Ecosystem
Git tagging commands provide core version control operations. The git tag command lists, creates, and deletes tags. The -a flag creates annotated tags with metadata. The -s flag generates signed tags using GPG keys. The -v flag verifies signed tag signatures. Pushing tags requires explicit git push --tags or git push origin tagname since default push operations exclude tags.
# Ruby interface to Git tagging
require 'rugged'
repo = Rugged::Repository.new('.')
tag = repo.tags.create(
'v1.2.0',
repo.head.target_id,
tagger: { name: 'Release Bot', email: 'bot@example.com' },
message: 'Version 1.2.0 release'
)
GitHub Releases adds distribution features to Git tags through web interface and API. Releases attach binary files, installation instructions, and release notes to tags. The releases page provides download URLs and version history. Release drafts allow preparation before publication. Pre-release flags indicate unstable versions. GitHub generates source archives automatically for each release.
GitLab Release Management integrates releases with CI/CD pipelines, issue tracking, and container registries. Releases link to deployment environments, associated merge requests, and milestone tracking. Evidence collection captures deployment artifacts, test results, and compliance data. Release API enables programmatic release creation from pipeline jobs.
RubyGems for Ruby packages manages versioning and distribution of Ruby libraries. The gemspec file declares version numbers, dependencies, and metadata. The gem build command packages gems for distribution. RubyGems.org hosts public gems with version history and download statistics. The gem push command publishes versions to the repository.
# Gemspec with versioning
Gem::Specification.new do |spec|
spec.name = 'mylib'
spec.version = File.read('VERSION').strip
spec.authors = ['Developer']
spec.summary = 'Library description'
spec.files = Dir['lib/**/*', 'VERSION']
spec.require_paths = ['lib']
spec.add_dependency 'activesupport', '~> 7.0'
spec.add_development_dependency 'rspec', '~> 3.12'
end
Semantic Release automates version determination, changelog generation, and release publication. The tool analyzes commit messages following conventional commit format to determine version increments. Configuration files define branches, plugins, and release assets. Integration with CI systems enables fully automated releases when commits merge to release branches.
Conventional Commits structures commit messages with type prefixes (feat, fix, docs, etc.) that indicate change categories. Breaking changes use BREAKING CHANGE footer or ! suffix. This machine-readable format enables automated changelog generation and version calculation. Commit linting enforces format compliance.
Bundler for dependency management specifies version constraints in Gemfiles. The ~> operator permits patch updates. Comparison operators allow ranges. The Gemfile.lock records exact versions for reproducible installations. The bundle update command refreshes dependencies within constraints.
# Gemfile with version constraints
source 'https://rubygems.org'
gem 'rails', '~> 7.0.4' # >= 7.0.4, < 7.1.0
gem 'pg', '>= 1.1', '< 2.0' # Range constraint
gem 'puma', '~> 6.0' # Latest 6.x
gem 'redis', '5.0.6' # Exact version
group :development do
gem 'debug', '~> 1.7'
end
Changelog generators parse commit history to produce release notes. GitHub's release notes generator groups pull requests by labels. git-changelog uses conventional commits to categorize changes. keepachangelog maintains human-curated changelog files following standardized format with version sections and change categories.
Ruby Implementation
Version constants in modules define version strings accessible throughout the application. The convention places VERSION constant in the root module, often in lib/myapp/version.rb. This file becomes the single source of truth for version numbers, referenced by gemspecs, documentation, and runtime code.
# lib/myapp/version.rb
module MyApp
VERSION = "1.4.2"
def self.version
VERSION
end
def self.gem_version
Gem::Version.new(VERSION)
end
end
Dynamic version loading reads version from external files to avoid code changes during releases. The VERSION file in repository root contains only the version string. The gemspec and module load this file at build time and runtime. This separation allows version updates without touching Ruby source files.
# lib/myapp/version.rb with file loading
module MyApp
VERSION = File.read(File.join(__dir__, '../../VERSION')).strip.freeze
end
# myapp.gemspec
require_relative 'lib/myapp/version'
Gem::Specification.new do |spec|
spec.version = MyApp::VERSION
# ... other specifications
end
Version comparison and constraints use Gem::Version for semantic comparison. The class implements comparison operators considering semantic version rules. Gem::Requirement handles version constraints with operators and ranges.
require 'rubygems'
v1 = Gem::Version.new('2.1.0')
v2 = Gem::Version.new('2.0.3')
v1 > v2 # => true
v1.segments # => [2, 1, 0]
# Requirement checking
req = Gem::Requirement.new('~> 2.1')
req.satisfied_by?(v1) # => true
req.satisfied_by?(v2) # => false
Rake tasks for release automation encapsulate release steps in reusable tasks. The release task typically runs tests, builds gems, creates tags, and pushes to repositories. Task dependencies ensure prerequisites execute in correct order.
# Rakefile
require 'bundler/gem_tasks'
require 'rspec/core/rake_task'
RSpec::Core::RakeTask.new(:spec)
namespace :release do
desc 'Prepare release'
task :prepare do
# Verify clean working directory
status = `git status --porcelain`
abort "Working directory not clean" unless status.empty?
# Run full test suite
Rake::Task[:spec].invoke
# Update version timestamp
version = MyApp::VERSION
puts "Preparing release #{version}"
end
task :tag do
version = MyApp::VERSION
system "git tag -a v#{version} -m 'Release #{version}'"
system "git push origin v#{version}"
end
end
task release: ['release:prepare', 'build', 'release:tag', 'release:rubygem_push']
Pre-release version handling appends qualifiers to version numbers for development builds. The prerelease method on Gem::Version returns the qualifier string. Comparisons treat pre-release versions as lower than release versions.
# Version with pre-release
module MyApp
VERSION = "2.0.0-rc.1"
def self.prerelease?
Gem::Version.new(VERSION).prerelease?
end
def self.release_version
VERSION.split('-').first
end
end
MyApp.prerelease? # => true
MyApp.release_version # => "2.0.0"
Version file generators create and update version files through scripts or generators. Rails generators can add versioning infrastructure to applications. Scripts parse current version, increment appropriately, and write back to version files.
# Script to bump version
#!/usr/bin/env ruby
require 'semantic'
VERSION_FILE = 'VERSION'
current = Semantic::Version.new(File.read(VERSION_FILE).strip)
type = ARGV[0] || 'patch'
new_version = case type
when 'major'
current.increment!(:major)
when 'minor'
current.increment!(:minor)
when 'patch'
current.increment!(:patch)
else
abort "Unknown version type: #{type}"
end
File.write(VERSION_FILE, "#{new_version}\n")
puts "Bumped version to #{new_version}"
Practical Examples
Basic release workflow for Ruby gem demonstrates complete process from development to publication. This example shows version update, testing, tagging, building, and pushing a gem to RubyGems.org.
# Step 1: Update version
# Edit lib/mylib/version.rb
module MyLib
VERSION = "1.2.0"
end
# Step 2: Update changelog
# Edit CHANGELOG.md to add release notes
# Step 3: Commit version changes
`git add lib/mylib/version.rb CHANGELOG.md`
`git commit -m "Bump version to 1.2.0"`
# Step 4: Run test suite
abort "Tests failed" unless system("bundle exec rspec")
# Step 5: Build gem
system("gem build mylib.gemspec")
# Step 6: Create annotated tag
`git tag -a v1.2.0 -m "Release version 1.2.0"`
# Step 7: Push commits and tags
`git push origin main`
`git push origin v1.2.0`
# Step 8: Publish to RubyGems
system("gem push mylib-1.2.0.gem")
Hotfix release from production tag shows creating emergency releases from tagged production versions without including ongoing development work. This preserves production stability while addressing critical issues.
# Current state: v2.1.0 in production, v2.2.0 in development
# Step 1: Create hotfix branch from production tag
`git checkout v2.1.0`
`git checkout -b hotfix/2.1.1`
# Step 2: Apply fix
# Edit files to fix critical bug
`git add lib/security_fix.rb`
`git commit -m "fix: Address security vulnerability CVE-2024-1234"`
# Step 3: Update version to patch level
# Edit lib/myapp/version.rb
module MyApp
VERSION = "2.1.1"
end
`git add lib/myapp/version.rb`
`git commit -m "Bump version to 2.1.1"`
# Step 4: Test thoroughly
system("bundle exec rspec")
# Step 5: Tag hotfix
`git tag -a v2.1.1 -m "Hotfix release 2.1.1 - Security patch"`
# Step 6: Merge back to main and develop
`git checkout main`
`git merge hotfix/2.1.1`
`git checkout develop`
`git merge hotfix/2.1.1`
# Step 7: Push everything
`git push origin main develop v2.1.1`
`git branch -d hotfix/2.1.1`
Pre-release versioning workflow demonstrates creating and publishing beta or release candidate versions for testing before final release. Pre-release versions allow user testing without committing to stable API.
# Create beta release
module MyApp
VERSION = "3.0.0-beta.1"
end
# Build and tag
system("gem build myapp.gemspec")
`git tag -a v3.0.0-beta.1 -m "Beta release for 3.0.0"`
# Push with pre-release flag
system("gem push myapp-3.0.0-beta.1.gem")
# After testing, create release candidate
module MyApp
VERSION = "3.0.0-rc.1"
end
system("gem build myapp.gemspec")
`git tag -a v3.0.0-rc.1 -m "Release candidate for 3.0.0"`
system("gem push myapp-3.0.0-rc.1.gem")
# Final release after validation
module MyApp
VERSION = "3.0.0"
end
system("gem build myapp.gemspec")
`git tag -a v3.0.0 -m "Release version 3.0.0"`
system("gem push myapp-3.0.0.gem")
Automated release from CI pipeline integrates release creation with continuous integration. The pipeline executes on tag push, runs tests, builds artifacts, and publishes release.
# .github/workflows/release.yml equivalent in Ruby script
def ci_release_workflow
# Triggered when tag matching v*.*.* is pushed
return unless ENV['GITHUB_REF']&.match?(/^refs\/tags\/v\d+\.\d+\.\d+/)
tag = ENV['GITHUB_REF'].split('/').last
version = tag.sub(/^v/, '')
# Install dependencies
system("bundle install --jobs=3 --retry=3")
# Run test suite
abort "Tests failed" unless system("bundle exec rspec")
# Build gem
system("gem build myapp.gemspec")
# Create GitHub release with changelog
changelog = extract_changelog_for_version(version)
create_github_release(tag, changelog)
# Publish to RubyGems
configure_rubygems_credentials
system("gem push myapp-#{version}.gem")
end
def extract_changelog_for_version(version)
changelog = File.read('CHANGELOG.md')
# Extract section between version headers
section = changelog[/## \[#{version}\].*?(?=## \[|\z)/m]
section || "Release #{version}"
end
Common Patterns
Version file as single source of truth centralizes version declaration in one location. All other references load this file rather than duplicating version strings. This prevents version inconsistencies across gemspec, documentation, and code.
# VERSION file contains: 1.5.2
# lib/myapp/version.rb
module MyApp
VERSION = File.read(File.expand_path('../../VERSION', __dir__)).strip
end
# myapp.gemspec
lib = File.expand_path('../lib', __FILE__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require 'myapp/version'
Gem::Specification.new do |spec|
spec.version = MyApp::VERSION
end
# README.md generation
File.write('README.md', <<~MD)
# MyApp v#{MyApp::VERSION}
MD
Semantic version bumping script automates version incrementing based on change type. The script reads current version, applies increment rules, and updates all version references consistently.
#!/usr/bin/env ruby
require 'semantic'
class VersionBumper
VERSION_FILE = 'VERSION'
def self.bump(type)
current = read_version
new_version = increment_version(current, type)
write_version(new_version)
new_version
end
def self.read_version
Semantic::Version.new(File.read(VERSION_FILE).strip)
end
def self.increment_version(version, type)
case type
when 'major'
version.increment!(:major)
when 'minor'
version.increment!(:minor)
when 'patch'
version.increment!(:patch)
else
raise "Unknown version type: #{type}"
end
end
def self.write_version(version)
File.write(VERSION_FILE, "#{version}\n")
puts "Version bumped to #{version}"
version
end
end
# Usage: ruby bump_version.rb major|minor|patch
VersionBumper.bump(ARGV[0] || 'patch')
Changelog automation from commits generates release notes by parsing Git commit history. Commits following conventional format provide structured input for automatic categorization.
require 'time'
class ChangelogGenerator
TYPES = {
'feat' => 'Features',
'fix' => 'Bug Fixes',
'docs' => 'Documentation',
'perf' => 'Performance',
'refactor' => 'Refactoring'
}
def self.generate(from_tag, to_tag = 'HEAD')
commits = `git log #{from_tag}..#{to_tag} --pretty=format:'%s|%an|%ad' --date=short`
grouped = Hash.new { |h, k| h[k] = [] }
commits.each_line do |line|
subject, author, date = line.chomp.split('|')
if subject =~ /^(\w+)(\(.+\))?: (.+)/
type = $1
scope = $2
message = $3
category = TYPES[type] || 'Other'
grouped[category] << "- #{message}"
end
end
output = ["## [#{to_tag}] - #{Time.now.strftime('%Y-%m-%d')}\n"]
TYPES.values.each do |category|
next unless grouped[category].any?
output << "\n### #{category}\n"
output.concat(grouped[category])
end
output.join("\n")
end
end
# Generate changelog between tags
puts ChangelogGenerator.generate('v1.4.0', 'v1.5.0')
Multi-environment release configuration manages different version schemes and release processes across environments. Development builds use timestamp-based versions while production uses semantic versions.
class VersionManager
def self.version_for_environment(env = ENV['RAILS_ENV'])
base = File.read('VERSION').strip
case env
when 'production'
base
when 'staging'
"#{base}-rc.#{build_number}"
when 'development'
"#{base}-dev.#{timestamp}"
else
"#{base}-#{env}.#{timestamp}"
end
end
def self.build_number
ENV['CI_PIPELINE_ID'] || '0'
end
def self.timestamp
Time.now.strftime('%Y%m%d%H%M%S')
end
end
module MyApp
VERSION = VersionManager.version_for_environment
end
Tag verification and signing ensures release authenticity through GPG signatures. Signed tags prove the release came from authorized sources and hasn't been tampered with.
# Create signed tag
def create_signed_tag(version, message)
tag_name = "v#{version}"
# Requires GPG key configured
system("git tag -s #{tag_name} -m '#{message}'")
# Verify signature
verify_result = `git tag -v #{tag_name} 2>&1`
if verify_result.include?('Good signature')
puts "Tag #{tag_name} created and verified"
true
else
puts "Warning: Tag signature verification failed"
false
end
end
# Verify existing tag
def verify_tag(tag_name)
output = `git tag -v #{tag_name} 2>&1`
{
valid: output.include?('Good signature'),
signer: output[/gpg: Good signature from "([^"]+)"/, 1],
fingerprint: output[/Primary key fingerprint: (.+)/, 1]
}
end
Reference
Version Number Format
| Component | Format | Meaning | Example |
|---|---|---|---|
| Major | X.0.0 | Breaking changes, incompatible API | 2.0.0 |
| Minor | x.Y.0 | New features, backward compatible | 1.3.0 |
| Patch | x.y.Z | Bug fixes, backward compatible | 1.2.5 |
| Pre-release | x.y.z-identifier.N | Unstable version | 2.0.0-beta.1 |
| Build metadata | x.y.z+metadata | Build information | 1.0.0+20240115 |
Git Tag Commands
| Command | Purpose | Example |
|---|---|---|
| git tag | List all tags | git tag |
| git tag name | Create lightweight tag | git tag v1.0.0 |
| git tag -a | Create annotated tag | git tag -a v1.0.0 -m "Release 1.0.0" |
| git tag -s | Create signed tag | git tag -s v1.0.0 -m "Signed release" |
| git tag -v | Verify signed tag | git tag -v v1.0.0 |
| git push --tags | Push all tags | git push origin --tags |
| git push origin tagname | Push specific tag | git push origin v1.0.0 |
| git tag -d | Delete local tag | git tag -d v1.0.0 |
| git push --delete | Delete remote tag | git push origin --delete v1.0.0 |
Gem Version Constraints
| Operator | Meaning | Example | Matches |
|---|---|---|---|
| = | Exact version | = 1.0.0 | 1.0.0 only |
| != | Not equal | != 1.0.0 | Any except 1.0.0 |
| > | Greater than | > 1.0.0 | 1.0.1, 1.1.0, 2.0.0 |
| >= | Greater or equal | >= 1.0.0 | 1.0.0, 1.0.1, 1.1.0 |
| < | Less than | < 2.0.0 | 1.x.x, 0.x.x |
| <= | Less or equal | <= 2.0.0 | 2.0.0 and below |
| ~> | Pessimistic | ~> 1.2.0 | >= 1.2.0, < 1.3.0 |
Release Checklist
| Step | Action | Verification |
|---|---|---|
| 1 | Update version number in VERSION or version.rb | Grep for old version |
| 2 | Update CHANGELOG.md with release notes | Review changes since last tag |
| 3 | Run full test suite | All tests pass |
| 4 | Update documentation if needed | Docs reflect new version |
| 5 | Commit version changes | Clean git status |
| 6 | Create annotated or signed tag | git tag -v tagname |
| 7 | Push commits to repository | git log origin/main |
| 8 | Push tag to repository | git ls-remote --tags |
| 9 | Build release artifacts | Gem builds successfully |
| 10 | Publish to package repository | Gem appears on RubyGems.org |
| 11 | Create GitHub/GitLab release | Release page shows notes |
| 12 | Announce release if appropriate | Communication sent |
Conventional Commit Types
| Type | Description | Version Impact |
|---|---|---|
| feat | New feature | Minor increment |
| fix | Bug fix | Patch increment |
| docs | Documentation only | No version change |
| style | Code style changes | No version change |
| refactor | Code restructuring | No version change |
| perf | Performance improvement | Patch or minor |
| test | Test additions or fixes | No version change |
| chore | Build or tooling changes | No version change |
| BREAKING CHANGE | Breaking API change | Major increment |
Common Release Patterns
| Pattern | Branches | Tag Timing | Use Case |
|---|---|---|---|
| Trunk-based | main only | After merge to main | Continuous deployment |
| Git Flow | main, develop, release/* | On release branch | Formal release cycles |
| GitHub Flow | main, feature/* | After merge to main | Web applications |
| Release trains | main, release/YY.MM | At train departure | Scheduled releases |
| Semantic release | main, next | Automated on merge | Automated versioning |
Version Comparison Examples
| Version A | Operator | Version B | Result | Reason |
|---|---|---|---|---|
| 2.0.0 | > | 1.9.9 | true | Major version higher |
| 1.2.3 | < | 1.2.4 | true | Patch version lower |
| 1.0.0 | > | 1.0.0-rc.1 | true | Release > pre-release |
| 2.1.0-beta.2 | > | 2.1.0-beta.1 | true | Pre-release number higher |
| 1.0.0+build.1 | = | 1.0.0+build.2 | true | Build metadata ignored |