Skip to content
Pipelines and Pizza 🍕
Go back

Git Tags and Release Management

11 min read

“What version is running in production right now?”

If that question makes your stomach drop, you’re not alone. I’ve been on incident calls where three engineers gave three different answers. One pointed at a commit hash. Another referenced a branch name. The third said “whatever we deployed last Thursday.” Nobody was wrong, exactly — they just didn’t have a shared vocabulary for marking releases.

That’s the problem Git tags solve. A tag is a human-readable label pinned to a specific commit. It turns a4f8c2e into v2.1.0, and suddenly everyone — from developers to CI pipelines to auditors — speaks the same language about what’s deployed where.

Let’s dig into how tags work, how to combine them with GitHub Releases, and how to build a release workflow that actually scales.


Lightweight vs Annotated Tags

Git supports two types of tags, and choosing the right one matters.

Lightweight tags are just a pointer to a commit. Think of them as a bookmark — no metadata, no message, no author information.

# Create a lightweight tag
git tag v1.0.0-rc1

Annotated tags are full Git objects. They store the tagger’s name, email, date, and a message. They can also be GPG-signed for verification.

# Create an annotated tag
git tag -a v1.0.0 -m "Production release: new VPC module"

Here’s how they compare:

FeatureLightweightAnnotated
Stores metadataNoYes (author, date, msg)
GPG signingNoYes
Shows in git logOnly with --decorateFull detail with -v
Copied into forksNoYes
Pushed by --follow-tagsNoYes

My rule of thumb: Use annotated tags for anything that represents a real release or milestone. Use lightweight tags for temporary markers — bookmarking a commit you want to come back to, or marking an experimental build that might get thrown away.


Semantic Versioning (SemVer)

If you’re tagging releases, you need a versioning scheme. Semantic Versioning gives every version number meaning:

v MAJOR . MINOR . PATCH
  ^       ^       ^
  |       |       └── Bug fixes, no API changes
  |       └────────── New features, backwards compatible
  └────────────────── Breaking changes

For infrastructure code, here’s how I think about it:

  • PATCH (v1.0.0 -> v1.0.1): Fixed a typo in a resource tag, corrected a default value, updated a provider constraint.
  • MINOR (v1.0.0 -> v1.1.0): Added an optional variable to a Terraform module, added a new task to an Ansible role, introduced a new output.
  • MAJOR (v1.0.0 -> v2.0.0): Renamed required variables, changed resource structure in a way that forces a terraform state mv, removed a previously-supported parameter.

The key insight: a major version bump tells consumers “you need to read the changelog before upgrading.” That’s incredibly valuable when your Terraform module is consumed by fifteen teams.


Essential Tag Commands

Here’s your day-to-day cheat sheet:

Creating Tags

# Annotated tag on current commit
git tag -a v1.2.0 -m "Release 1.2.0: added monitoring outputs"

# Tag a specific past commit
git tag -a v1.1.1 abc1234 -m "Patch: fix security group rule"

# Lightweight tag (temporary use only)
git tag dev-checkpoint

Listing and Inspecting Tags

# List all tags
git tag

# Filter tags by pattern
git tag -l "v1.*"

# Show details of an annotated tag
git show v1.2.0

# List remote tags
git ls-remote --tags origin

Pushing Tags

# Push a single tag
git push origin v1.2.0

# Push all annotated tags (skips lightweight)
git push --follow-tags

# Push ALL tags (use carefully)
git push origin --tags

Deleting Tags

# Delete a local tag
git tag -d v1.0.0-rc1

# Delete a remote tag
git push origin --delete v1.0.0-rc1

I recommend git push --follow-tags as your default. It pushes annotated tags alongside your commits but leaves your lightweight bookmarks local — exactly the behavior you want.


GPG-Signed Tags

For production infrastructure, signed tags add a layer of trust. A signed tag proves that the person who created it actually had access to the private key.

Setup

# List your GPG keys
gpg --list-secret-keys --keyid-format=long

# Tell Git which key to use
git config --global user.signingkey YOUR_KEY_ID

# Create a signed tag
git tag -s v1.2.0 -m "Signed production release"

# Verify a signed tag
git tag -v v1.2.0

You’ll see output confirming the signature is valid and who signed it. GitHub also displays a “Verified” badge on signed tags in the UI.

Is signing strictly necessary for every team? No. But if you work in a regulated environment or your infrastructure modules are consumed across an organization, signed tags are a straightforward way to prove provenance.


GitHub Releases

Tags mark a point in history. GitHub Releases build on that by adding release notes, changelogs, and downloadable assets.

Creating a Release from a Tag

# Create a tag first
git tag -a v2.0.0 -m "Major release: restructured VPC module"
git push origin v2.0.0

# Create a GitHub Release from the tag
gh release create v2.0.0 \
  --title "v2.0.0 - VPC Module Restructure" \
  --notes "## Breaking Changes
- Renamed \`vpc_cidr\` variable to \`cidr_block\`
- Removed \`enable_nat\` variable (now always enabled)

## Migration
See UPGRADE.md for state migration steps."

Auto-Generated Release Notes

GitHub can automatically generate release notes from your merged PRs:

gh release create v2.1.0 --generate-notes

This pulls PR titles and contributor names into the release notes. It works best when your PR titles are descriptive and follow a consistent format.


Conventional Commits and Automated Releases

If you’ve been following this series, you know I’m a fan of Conventional Commits. They aren’t just for clean history — they’re the input that powers automated release tooling.

The Format

feat(vpc): add IPv6 support
fix(sg): correct egress rule for port 443
chore(deps): update AWS provider to 5.40
feat!: rename cidr variable (BREAKING CHANGE)

The ! after the type signals a breaking change, which triggers a major version bump.

release-please

Google’s release-please tool reads your Conventional Commit history and automatically:

  1. Determines the next version number (major, minor, or patch)
  2. Generates a changelog from commit messages
  3. Opens a “Release PR” that you merge when you’re ready to ship

Here’s the GitHub Actions workflow:

name: Release Please
on:
  push:
    branches:
      - main

permissions:
  contents: write
  pull-requests: write

jobs:
  release-please:
    runs-on: ubuntu-latest
    steps:
      - uses: googleapis/release-please-action@v4
        with:
          release-type: simple
          token: ${{ secrets.GITHUB_TOKEN }}

When you merge commits to main, release-please updates a standing PR with the accumulated changelog. Merge that PR, and it creates the tag and GitHub Release for you. No manual version bumping, no forgetting to update the changelog.


Tagging Strategies for Infrastructure Code

Terraform Modules

Tags are how consumers pin module versions. This is arguably the most important use of tags in infrastructure work.

module "vpc" {
  source = "git::https://github.com/myorg/terraform-aws-vpc.git?ref=v2.1.0"

  cidr_block  = "10.0.0.0/16"
  environment = "production"
}

The ?ref=v2.1.0 pins this module to that exact tag. Even if someone pushes v3.0.0 with breaking changes tomorrow, your configuration stays stable until you explicitly update the ref.

Never pin to a branch in production. I’ve seen ?ref=main cause outages when someone merged a breaking change that got pulled into a plan nobody expected.

For the Terraform registry (public or private), tags are the mechanism for publishing new versions. Push a semver tag, and the registry picks it up automatically.

Ansible Roles

Ansible Galaxy uses the same approach. Tags matching semver format are imported as role versions:

# requirements.yml
roles:
  - name: myorg.nginx
    src: https://github.com/myorg/ansible-role-nginx
    version: v1.4.0

When you push a new semver tag to the role repository, Galaxy imports it as a new version. Start at 0.0.1 during development, and bump to 1.0.0 when the role is stable and ready for production consumption.


CI/CD Triggers Based on Tags

One of the most powerful patterns: trigger your deployment pipeline only when a tag is pushed. This separates “merging code” from “releasing code.”

GitHub Actions: Tag-Triggered Deploy

name: Deploy Infrastructure Module
on:
  push:
    tags:
      - 'v[0-9]+.[0-9]+.[0-9]+'

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3

      - name: Terraform Format Check
        run: terraform fmt -check -recursive

      - name: Terraform Validate
        run: |
          terraform init -backend=false
          terraform validate

  publish:
    needs: validate
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Extract version from tag
        id: version
        run: echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v2
        with:
          tag_name: ${{ steps.version.outputs.VERSION }}
          generate_release_notes: true

The tag pattern v[0-9]+.[0-9]+.[0-9]+ ensures this workflow only fires on proper semver tags — not on branches, not on lightweight bookmarks, only on intentional releases.

Key point: Defining only tags: (without branches:) means the workflow is skipped entirely for regular branch pushes. This is by design.


Hands-On Lab: Build a Tagged Release Workflow

Let’s put it all together. By the end of this lab, you’ll have a repository with conventional commits, automated changelogs, and tag-triggered CI.

Step 1: Create a Sample Module Repository

mkdir terraform-demo-module && cd terraform-demo-module
git init

Step 2: Add a Simple Terraform Module

cat > main.tf <<'EOF'
variable "name" {
  type        = string
  description = "Name tag for the resource"
}

variable "environment" {
  type        = string
  default     = "dev"
  description = "Environment name"
}

output "resource_name" {
  value = "${var.name}-${var.environment}"
}
EOF

Step 3: Initial Commit and Tag

git add main.tf
git commit -m "feat: initial module with name and environment variables"
git tag -a v0.1.0 -m "Initial release"

Step 4: Make a Backwards-Compatible Change

cat >> main.tf <<'EOF'

variable "tags" {
  type        = map(string)
  default     = {}
  description = "Additional tags to apply"
}
EOF

git add main.tf
git commit -m "feat: add optional tags variable"
git tag -a v0.2.0 -m "Added optional tags support"

Step 5: Make a Breaking Change

# Rename the variable
sed -i '' 's/variable "name"/variable "resource_name"/' main.tf
sed -i '' 's/var.name/var.resource_name/' main.tf

git add main.tf
git commit -m "feat!: rename 'name' variable to 'resource_name'

BREAKING CHANGE: Consumers must update variable references from 'name' to 'resource_name'."
git tag -a v1.0.0 -m "v1.0.0 - Breaking: renamed name to resource_name"

Step 6: Add the GitHub Actions Workflow

mkdir -p .github/workflows

cat > .github/workflows/release.yml <<'EOF'
name: Release
on:
  push:
    tags:
      - 'v[0-9]+.[0-9]+.[0-9]+'

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Create GitHub Release
        uses: softprops/action-gh-release@v2
        with:
          generate_release_notes: true
EOF

git add .github/
git commit -m "ci: add tag-triggered release workflow"

Step 7: Push Everything

git remote add origin https://github.com/YOUR_ORG/terraform-demo-module.git
git push -u origin main
git push origin --tags

Check your GitHub repository — you should see three releases (v0.1.0, v0.2.0, v1.0.0), each with auto-generated notes from the commits between tags.


Troubleshooting Guide

ProblemCauseFix
git push --follow-tags skips my tagTag is lightweight, not annotatedRecreate with git tag -a v1.0.0 -m "msg"
Tag pushed but GitHub Actions didn’t triggerTag pattern doesn’t match workflow filterCheck your tags: glob — v* is more permissive than v[0-9]+.*
Wrong commit taggedTagged before merging or from wrong branchDelete tag locally and remotely, re-tag the correct commit
terraform init pulls old module versionTerraform caches modules locallyRun terraform get -update or delete .terraform/modules/
Release notes are emptyPR titles are vague (“fix stuff”)Use descriptive PR titles or Conventional Commits
GPG signing failsKey expired or not configuredCheck gpg --list-secret-keys and update user.signingkey in git config
Tag exists locally but not on remoteForgot to push the tagRun git push origin v1.0.0
Duplicate tag error on pushTag already exists on remoteDelete remote tag first: git push origin --delete v1.0.0

Quick Reference

# Create annotated tag
git tag -a v1.0.0 -m "Release message"

# Create signed tag
git tag -s v1.0.0 -m "Signed release"

# List tags matching a pattern
git tag -l "v2.*"

# Push single tag to remote
git push origin v1.0.0

# Push all annotated tags
git push --follow-tags

# Delete local + remote tag
git tag -d v1.0.0-bad && git push origin --delete v1.0.0-bad

# Create GitHub Release from CLI
gh release create v1.0.0 --generate-notes

# Verify a signed tag
git tag -v v1.0.0

# See which tag you're on
git describe --tags

What’s Next

Next post: Git Stash, Cherry-Pick, and Bisect. We’ll cover the Git power tools that help you juggle work in progress, surgically move commits between branches, and track down the exact commit that introduced a bug. These are the commands that separate “I use Git” from “I actually know Git.”

Happy automating!