Git Tags and Releases Explained — Lightweight vs Annotated, Versioning Strategy and CI/CD Integration
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.
#!/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
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
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.
#!/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
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
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.
#!/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
* [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)
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.
# ───────────────────────────────────────────────────────── # 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
✓ 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
| Aspect | Lightweight Tag | Annotated Tag |
|---|---|---|
| Git object type | Alias to a commit (no object created) | Full tag object with its own SHA |
| Stores tagger metadata | No — no name, email or timestamp | Yes — tagger name, email, and date |
| Stores a message | No | Yes — required when creating with -a |
| GPG signing support | No | Yes — use `git tag -s` instead of `-a` |
| Visible to `git describe` | No — skipped by default | Yes — used as version anchor |
| Visible in `git log --tags` | No | Yes |
| Should you push to remote? | No — local bookmarks only | Yes — this is the release record |
| GitHub Release compatible | Technically yes, but not recommended | Yes — intended use case |
| Use case | Quick personal bookmark while debugging | Every 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 originorgit push --follow-tags. git tag --list --sort=version:refnamesorts tags numerically — without it,v1.10.0alphabetically precedesv1.9.0and 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 mainsends 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 withgit push origin v1.2.0or addgit push --follow-tagsto 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 mainand confirm you're on the right commit withgit log --oneline -5before 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 --tagswon't overwrite an existing tag unless--forceis 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 rungit 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.
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.