Home DevOps Git Tags and Releases Explained — Lightweight vs Annotated, Versioning Strategy and CI/CD Integration

Git Tags and Releases Explained — Lightweight vs Annotated, Versioning Strategy and CI/CD Integration

In Plain English 🔥
Imagine you're writing a really long book and you stick a Post-it note on page 247 that says 'THIS IS THE FINAL DRAFT'. That Post-it doesn't change the book — it just marks a moment in time so you can always flip back to that exact page. A Git tag is that Post-it note on your codebase. A GitHub/GitLab Release is what happens when you hand that marked-up book to your publisher along with release notes explaining what changed.
⚡ Quick Answer
Imagine you're writing a really long book and you stick a Post-it note on page 247 that says 'THIS IS THE FINAL DRAFT'. That Post-it doesn't change the book — it just marks a moment in time so you can always flip back to that exact page. A Git tag is that Post-it note on your codebase. A GitHub/GitLab Release is what happens when you hand that marked-up book to your publisher along with release notes explaining what changed.

Every professional software project ships versions. Version 1.0, version 2.3.1, a hotfix tagged v1.9.4 — these aren't just arbitrary numbers in a changelog. They're contracts with your users, your ops team, and your future self. Without a reliable way to mark those moments in your Git history, deployments become guesswork and rollbacks turn into archaeology projects. Git tags exist precisely to solve this: they pin a specific commit so it can be referenced by a human-readable name forever, regardless of how many commits pile on top afterward.

The problem tags solve is surprisingly subtle. Branches move — every new commit pushes a branch pointer forward. If you deployed from the tip of main on a Tuesday afternoon, two weeks later you can't easily find exactly what you shipped without digging through logs. A tag, by contrast, never moves. It points at one commit and stays there. This is what gives your deployment pipeline, your Docker image labels, your npm packages, and your GitHub Releases a stable anchor to build on.

By the end of this article you'll understand the real difference between lightweight and annotated tags (and why it actually matters), how semantic versioning maps onto a professional tagging workflow, how to push tags to a remote and share them with your team, how to delete and move tags safely when you make a mistake, and how GitHub and GitLab Releases turn a tag into a full-blown deployment artifact with release notes and downloadable assets. Let's dig in.

Lightweight vs Annotated Tags — Why the Distinction Actually Matters

Git gives you two flavours of tag and most tutorials gloss over the difference. That's a mistake, because they store fundamentally different data.

A lightweight tag is nothing more than a named pointer to a commit — exactly like a branch, except it never moves. There's no extra metadata: no tagger name, no date, no message. It costs almost nothing to create and is perfect for temporary, personal bookmarks.

An annotated tag is a full Git object. It stores the tagger's name, email, the date it was created, a tagging message, and optionally a GPG signature. When you run git log --tags or git describe, annotated tags are treated as first-class release markers. Lightweight tags are largely invisible to those commands.

Here's the practical rule: use annotated tags for anything you'll push to a remote or tie to a release. Use lightweight tags as throwaway local markers — like bookmarking a commit while you investigate a bug. If you're releasing software, your CI/CD pipeline expects the rich metadata that only annotated tags provide. GitHub Releases are built on top of annotated tags for exactly this reason.

creating_tags.sh · BASH
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────
# Demonstrating lightweight vs annotated tags on a real repo
# Run this inside any existing Git repository
# ─────────────────────────────────────────────────────────

# 1. Create a LIGHTWEIGHT tag — just a pointer, zero metadata
#    Use this for local bookmarks only; don't push these
git tag v1.0.0-local

# 2. Inspect what a lightweight tag actually is under the hood
#    It points directly to a commit object — no wrapper
git cat-file -t v1.0.0-local
# Output: commit

# 3. Create an ANNOTATED tag — this is a full Git object
#    -a  = annotated
#    -m  = inline message (skips the editor prompt)
#    This is what you should use for every real release
git tag -a v1.0.0 -m "Release v1.0.0: initial public launch with user auth and dashboard"

# 4. Inspect what an annotated tag is under the hood
#    It's a dedicated 'tag' object, not a raw commit pointer
git cat-file -t v1.0.0
# Output: tag

# 5. Read the full metadata stored inside the annotated tag
git cat-file -p v1.0.0
# Output:
# object 4a3f8c2d1e9b7a6f5d4c3b2a1f9e8d7c6b5a4f3e
# type commit
# tag v1.0.0
# tagger Jane Doe <jane@thecompany.io> 1718000000 +0100
# 
# Release v1.0.0: initial public launch with user auth and dashboard

# 6. Show the tag alongside its annotation in the log
#    Annotated tags appear; the lightweight tag does NOT show here
git log --oneline --decorate
# Output:
# 4a3f8c2 (HEAD -> main, tag: v1.0.0) feat: add user authentication module
# b3e1d9f feat: scaffold dashboard layout
# 91cc047 chore: initial project setup

# 7. List ALL tags (both types) in the repository
git tag --list
# Output:
# v1.0.0
# v1.0.0-local
▶ Output
commit
tag
object 4a3f8c2d1e9b7a6f5d4c3b2a1f9e8d7c6b5a4f3e
type commit
tag v1.0.0
tagger Jane Doe <jane@thecompany.io> 1718000000 +0100

Release v1.0.0: initial public launch with user auth and dashboard

4a3f8c2 (HEAD -> main, tag: v1.0.0) feat: add user authentication module
b3e1d9f feat: scaffold dashboard layout
91cc047 chore: initial project setup

v1.0.0
v1.0.0-local
⚠️
Watch Out:If your CI/CD pipeline uses `git describe` to auto-generate version numbers for build artifacts, it only counts annotated tags. A lightweight tag will cause `git describe` to walk all the way back to the last annotated tag, producing a confusingly large offset like `v0.9.0-47-g4a3f8c2` instead of `v1.0.0`.

Semantic Versioning + Tagging Strategy That Teams Actually Use

A tag name without a convention is just noise. The industry standard is Semantic Versioning (SemVer): vMAJOR.MINOR.PATCH. MAJOR bumps when you break backward compatibility. MINOR bumps when you add functionality without breaking anything. PATCH bumps for bug fixes. The v prefix is a Git convention (not part of SemVer itself) but virtually every toolchain expects it.

Beyond the basics, real projects also use pre-release identifiers to communicate intent: v2.0.0-alpha.1, v2.0.0-beta.3, v2.0.0-rc.1. These tell your users — and your CI pipeline — not to treat this tag as a stable production release.

The tagging workflow that works at scale looks like this: development happens on feature branches, gets merged to main, and a tag is created only when the team consciously decides to ship. Tags are never created mid-feature. If you discover a critical bug in v1.2.0, you create a hotfix/v1.2.1 branch off that tag, fix it, then tag the fix as v1.2.1 — you don't tag a commit on an unrelated branch.

semver_tagging_workflow.sh · BASH
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────
# A realistic SemVer tagging workflow
# ─────────────────────────────────────────────────────────

# ── SCENARIO: we just merged a minor feature to main ──

# Step 1: Make sure we're on main and fully up to date
git checkout main
git pull origin main

# Step 2: Tag the current HEAD as a minor release
#    The message should read like a brief release note
git tag -a v1.1.0 -m "Release v1.1.0: add CSV export, improve pagination performance"

# Step 3: Push JUST the new tag to the remote
#    git push origin main does NOT push tags — you must do this explicitly
git push origin v1.1.0

# ── SCENARIO: pre-release tagging before a major launch ──

# Tag a release candidate — CI will deploy this to staging, not production
git tag -a v2.0.0-rc.1 -m "Release candidate 1 for v2.0.0: new billing engine"
git push origin v2.0.0-rc.1

# Once QA signs off, promote to the real release
git tag -a v2.0.0 -m "Release v2.0.0: new billing engine (replaces legacy Stripe integration)"
git push origin v2.0.0

# ── SCENARIO: hotfix on a past version ──

# Check out the tag you need to patch — NOT main
git checkout v1.1.0

# Create a dedicated hotfix branch from this exact commit
git checkout -b hotfix/fix-csv-null-pointer

# ... make your fix, commit it ...
git commit -am "fix: handle null values in CSV export row builder"

# Tag the hotfix from this branch
git tag -a v1.1.1 -m "Hotfix v1.1.1: fix null pointer crash in CSV export"
git push origin hotfix/fix-csv-null-pointer
git push origin v1.1.1

# List all tags sorted by version (not alphabetically)
git tag --list --sort=version:refname
# Output:
# v1.0.0
# v1.1.0
# v1.1.1
# v2.0.0-rc.1
# v2.0.0
▶ Output
Switched to branch 'main'
Your branch is up to date with 'origin/main'.

[main 7b2e441] (tag: v1.1.0)

Enumerating objects: 1, done.
To github.com:thecompany/billing-service.git
* [new tag] v1.1.0 -> v1.1.0

v1.0.0
v1.1.0
v1.1.1
v2.0.0-rc.1
v2.0.0
⚠️
Pro Tip:Sort your tags with `git tag --list --sort=version:refname` instead of plain `git tag`. The default alphabetical sort puts `v1.10.0` before `v1.9.0`, which will drive you insane. The `version:refname` sorter treats the segments numerically.

Pushing, Deleting and Moving Tags — The Operations Nobody Warns You About

Tags have some genuinely surprising behaviour that catches even experienced developers off guard.

Pushing tags is opt-in. git push origin main does not push your tags. Ever. You must explicitly push a tag with git push origin or push all local tags at once with git push origin --tags. Most teams push tags individually to keep the signal clean — pushing all tags could accidentally push your throwaway lightweight bookmarks.

Deleting tags requires two operations if the tag has already been pushed: one to delete it locally and one to delete it on the remote. This is intentional — Git treats tags as stable historical references, so there's no shortcut.

Moving a tag (re-tagging the same name to a different commit) is possible but dangerous. If anyone has already pulled the tag, their local ref will diverge from the remote. Git won't auto-update a moved tag for anyone who already fetched it. Use this only to correct a mistake made in the last few minutes, before anyone else has pulled.

tag_operations.sh · BASH
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────
# Tag push, delete, and emergency re-tag operations
# ─────────────────────────────────────────────────────────

# ── PUSHING TAGS ──

# Push a single specific tag — preferred for release workflows
git push origin v1.2.0

# Push ALL local tags at once — useful when bootstrapping a new remote
# Warning: also pushes any messy lightweight bookmarks you forgot about
git push origin --tags

# Better: push all ANNOTATED tags only (skips lightweight ones)
git push origin --follow-tags

# ── VERIFYING A TAG EXISTS ON THE REMOTE ──

# List all tags the remote knows about
git ls-remote --tags origin
# Output (partial):
# 4a3f8c2d1e9b7a6f5d4c3b2a1f9e8d7c6b5a4f3e  refs/tags/v1.0.0
# 7b2e4419f3c1d5a8e2b6c9d0f1a4b7e8c2d3f5a6  refs/tags/v1.1.0
# a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0  refs/tags/v1.2.0

# ── DELETING A TAG ──

# Step 1: delete the tag locally
git tag --delete v1.2.0-bad-release
# Output: Deleted tag 'v1.2.0-bad-release' (was f8a9b0c)

# Step 2: delete the tag from the remote
#    The colon syntax means: "push nothing into refs/tags/v1.2.0-bad-release"
git push origin :refs/tags/v1.2.0-bad-release
# Output:
# To github.com:thecompany/billing-service.git
#  - [deleted]         v1.2.0-bad-release

# Modern equivalent — easier to remember:
git push origin --delete v1.2.0-bad-release

# ── MOVING A TAG (emergency correction only) ──

# You tagged the wrong commit — fix it IMMEDIATELY before others pull
# -f = force, moves the existing tag to HEAD (or any commit you specify)
git tag -f -a v1.2.0 -m "Release v1.2.0: correct commit this time"

# Force-push the corrected tag to the remote
#    --force is required because you're overwriting an existing ref
git push origin v1.2.0 --force
# Output:
# To github.com:thecompany/billing-service.git
#  + f8a9b0c...7b2e441 v1.2.0 -> v1.2.0 (forced update)

# ── FETCHING TAGS FROM REMOTE (for teammates) ──

# After a force-push, teammates must explicitly update their local tag
git fetch origin --tags --force
# Without --force, Git will refuse to update a tag that already exists locally
▶ Output
To github.com:thecompany/billing-service.git
* [new tag] v1.2.0 -> v1.2.0

4a3f8c2d... refs/tags/v1.0.0
7b2e4419... refs/tags/v1.1.0
a1b2c3d4... refs/tags/v1.2.0

Deleted tag 'v1.2.0-bad-release' (was f8a9b0c)

To github.com:thecompany/billing-service.git
- [deleted] v1.2.0-bad-release

To github.com:thecompany/billing-service.git
+ f8a9b0c...7b2e441 v1.2.0 -> v1.2.0 (forced update)
⚠️
Watch Out:Never force-push a tag that has already been used to trigger a CI/CD pipeline or generate a GitHub Release. Even if you correct the tag locally and remotely, any artifacts built from the original commit still exist in your artifact registry under that version number. You'll end up with `v1.2.0` in Git pointing to commit A, and `v1.2.0` in your Docker registry containing image built from commit B. That mismatch will hunt you down at 2am.

GitHub and GitLab Releases — Turning a Tag Into a Deployable Artifact

A Git tag is just a pointer. A Release is the layer on top that makes it useful to the outside world — it bundles the tag with auto-generated or hand-written release notes, downloadable source archives, and any binary artifacts you want to attach (installers, compiled binaries, changelogs).

GitHub automatically creates a Release when you push an annotated tag and create a Release against it via the UI or API. GitLab has an identical concept under Project > Deployments > Releases. Both platforms also support auto-generating release notes from merged pull request titles and linked issues since the last release — which is a huge time saver for active projects.

The real power emerges when you wire releases into your CI/CD pipeline. A common pattern: a push to main runs tests; if tests pass and the commit is tagged, the pipeline publishes a GitHub Release, builds a Docker image tagged with the version number, and pushes it to your container registry. The tag becomes the single source of truth that coordinates everything downstream.

release_pipeline.yml · YAML
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990
# ─────────────────────────────────────────────────────────
# GitHub Actions workflow that creates a Release and builds
# a Docker image only when an annotated version tag is pushed.
#
# File location: .github/workflows/release_pipeline.yml
# ─────────────────────────────────────────────────────────

name: Release Pipeline

on:
  push:
    # This workflow ONLY triggers when a tag matching v*.*.* is pushed
    # It will NOT run on regular branch pushes — tags are the gate
    tags:
      - 'v[0-9]+.[0-9]+.[0-9]+'

jobs:
  run-tests:
    name: Run Test Suite Before Releasing
    runs-on: ubuntu-latest
    steps:
      - name: Check out repository at the tagged commit
        uses: actions/checkout@v4

      - name: Set up Node.js runtime
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install project dependencies
        run: npm ci

      - name: Execute full test suite — release is blocked if any test fails
        run: npm test

  create-github-release:
    name: Publish GitHub Release with Auto-Generated Notes
    runs-on: ubuntu-latest
    needs: run-tests          # Only runs if tests passed
    permissions:
      contents: write         # Required to create a Release
    steps:
      - name: Check out repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0      # Full history needed for changelog generation

      - name: Extract version number from the Git tag
        # GITHUB_REF is 'refs/tags/v1.2.0' — strip the prefix to get '1.2.0'
        id: extract_version
        run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT

      - name: Create Release with auto-generated PR-based changelog
        uses: softprops/action-gh-release@v2
        with:
          # generate_release_notes pulls titles from merged PRs since last release
          generate_release_notes: true
          name: "Release v${{ steps.extract_version.outputs.VERSION }}"
          # Mark as a pre-release if the tag contains a hyphen (e.g. v2.0.0-rc.1)
          prerelease: ${{ contains(github.ref, '-') }}

  build-and-push-docker-image:
    name: Build Docker Image Tagged with Release Version
    runs-on: ubuntu-latest
    needs: create-github-release
    steps:
      - name: Check out repository
        uses: actions/checkout@v4

      - name: Extract version tag for Docker labelling
        id: extract_version
        run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          push: true
          # Image is tagged both with the specific version AND 'latest'
          # This lets consumers pin to a version OR always pull the newest
          tags: |
            ghcr.io/${{ github.repository }}:${{ steps.extract_version.outputs.VERSION }}
            ghcr.io/${{ github.repository }}:latest
▶ Output
✓ run-tests completed in 43s
✓ create-github-release completed in 12s
→ Release 'Release v1.2.0' created
→ 7 pull requests included in auto-generated notes
→ Marked as latest release
✓ build-and-push-docker-image completed in 98s
→ ghcr.io/thecompany/billing-service:1.2.0 pushed
→ ghcr.io/thecompany/billing-service:latest pushed
⚠️
Pro Tip:Use the `contains(github.ref, '-')` expression to automatically mark any tag with a hyphen (like `v2.0.0-rc.1` or `v2.0.0-beta.2`) as a pre-release in GitHub. This prevents your release tooling from treating a release candidate as the latest stable version — no extra configuration needed.
AspectLightweight TagAnnotated Tag
Git object typeAlias to a commit (no object created)Full tag object with its own SHA
Stores tagger metadataNo — no name, email or timestampYes — tagger name, email, and date
Stores a messageNoYes — required when creating with -a
GPG signing supportNoYes — use `git tag -s` instead of `-a`
Visible to `git describe`No — skipped by defaultYes — used as version anchor
Visible in `git log --tags`NoYes
Should you push to remote?No — local bookmarks onlyYes — this is the release record
GitHub Release compatibleTechnically yes, but not recommendedYes — intended use case
Use caseQuick personal bookmark while debuggingEvery version release in your project

🎯 Key Takeaways

  • Lightweight tags are throwaway bookmarks — annotated tags are the official record. Never push a lightweight tag to a shared remote.
  • Tags do not push automatically with your commits. You must push them explicitly with git push origin or git push --follow-tags.
  • git tag --list --sort=version:refname sorts tags numerically — without it, v1.10.0 alphabetically precedes v1.9.0 and your release history looks scrambled.
  • A GitHub/GitLab Release is the human-readable layer on top of a tag — it adds release notes, binary assets, and the ability to mark something as a pre-release. Wiring it to CI/CD via a tag-trigger pattern is the professional standard.

⚠ Common Mistakes to Avoid

  • Mistake 1: Assuming git push origin main sends your tags — It silently doesn't. Your CI/CD pipeline sees no new tag, no release is triggered, and you get confused why nothing deployed. Fix: always push tags explicitly with git push origin v1.2.0 or add git push --follow-tags to your post-release checklist.
  • Mistake 2: Tagging from a feature branch instead of main — Your tag points to a commit that exists only on a non-merged branch. The tag is real but the code it represents was never reviewed or merged, causing a release to ship code that bypassed your PR process. Fix: always run git checkout main && git pull origin main and confirm you're on the right commit with git log --oneline -5 before tagging.
  • Mistake 3: Deleting and recreating a tag to 'fix' a message without force-fetching on all machines — After you delete and recreate v1.0.0, teammates who already pulled it still have the old tag locally. git fetch --tags won't overwrite an existing tag unless --force is specified. Their pipeline then builds from the old commit thinking it's the corrected one. Fix: communicate tag corrections to the whole team immediately and have everyone run git fetch origin --tags --force.

Interview Questions on This Topic

  • QWhat's the practical difference between a lightweight and an annotated tag, and which would you use in a production release workflow — and why?
  • QIf a colleague accidentally tagged the wrong commit and already pushed the tag to the remote, what exact steps would you take to correct it, and what downstream systems would you need to check afterward?
  • QHow does `git describe` work, and why might a freshly-tagged repository return something like `v1.0.0-3-g7b2e441` instead of just `v1.0.0`?

Frequently Asked Questions

How do I tag a previous commit in Git, not the latest one?

Pass the commit SHA as the final argument to the tag command: git tag -a v1.0.1 -m "Hotfix" 4a3f8c2. You can find the SHA using git log --oneline. This is especially useful for retroactively tagging a release you forgot to mark at the time.

What's the difference between a Git tag and a Git branch?

A branch is a moving pointer — every new commit advances it forward. A tag is a fixed pointer — it permanently marks one specific commit and never moves on its own. Branches represent lines of ongoing work; tags represent frozen moments in history, like a version release.

Can I create a GitHub Release without using the web UI?

Yes. You can use the GitHub CLI with gh release create v1.2.0 --generate-notes, the GitHub REST API via a POST /repos/{owner}/{repo}/releases request, or a GitHub Actions step using the softprops/action-gh-release action triggered on a tag push. The CLI approach is fastest for manual releases; the Actions approach is best for fully automated pipelines.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousGitHub Pull Requests and Code ReviewNext →Resolving Git Merge Conflicts
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged