Deepen PracticeOrdered learning track

Tag Mutability and Release Integrity

Learn Git In Action - Part 091

Tag mutability and release integrity: how Git tags work, why mutable tags break supply-chain trust, and how to design immutable release identity.

13 min read2565 words
PrevNext
Lesson 91126 lesson track69–103 Deepen Practice
#git#release-engineering#security#supply-chain+4 more

Part 091 — Tag Mutability and Release Integrity

A Git tag looks simple:

# create a release tag
git tag -a v1.8.0 -m "Release v1.8.0"

# publish it
git push origin v1.8.0

But a release tag is not just a label.

In production engineering, a release tag is an identity boundary.

It answers:

  • what source code was released?
  • what commit produced the artifact?
  • which review and CI checks applied?
  • which migration/config/runtime contract belongs to this version?
  • which dependency consumers are allowed to trust this release?
  • which evidence should auditors inspect later?

The problem: Git tags are technically mutable.

A team may intend tags to be immutable, but Git itself allows a tag ref to be deleted, recreated, and force-pushed unless policy prevents it.

That gap between social immutability and technical mutability is where many release integrity failures happen.


1. Core Mental Model

A Git tag is a ref under refs/tags/*.

A tag can point directly to an object, or it can point to a tag object which then points to another object.

The important distinction:

Tag formWhat the ref points toMetadataRelease suitability
Lightweight tagusually commit objectno tagger/date/message/signature objectpoor for production release identity
Annotated tagtag objecttagger/date/messagegood baseline for releases
Signed annotated tagtag objecttagger/date/message/signaturestronger release provenance

A release should usually use an annotated tag, and security-sensitive release processes should use signed annotated tags.

This is not because lightweight tags are invalid Git. It is because they are weak release records.


2. The Invariant: Tag Name Is Not the Release

A tag name is human-friendly.

A commit hash is object identity.

An artifact digest is artifact identity.

A release record is the binding between them.

A mature release system does not say:

We deployed v1.8.0.

It says:

We deployed artifact digest sha256:..., built from commit abc123..., referenced by signed tag v1.8.0, produced by build run ..., under release approval ....

The tag is only one link in the chain.

If the tag moves, the chain breaks.


3. Why Tag Mutation Is Dangerous

A mutable branch is expected.

main moves every day.

A mutable release tag is dangerous because many systems treat it as stable:

  • package managers
  • Docker build scripts
  • deployment manifests
  • SBOM/provenance records
  • CI release jobs
  • changelog generators
  • downstream repositories
  • documentation links
  • audit evidence
  • rollback instructions
  • incident reports

A moved tag creates two realities:

Both artifacts may claim v1.8.0.

Only one can be the canonical release.

In supply-chain terms, this is not a cosmetic problem. It is an identity collision.


4. Common Tag Mutation Failure Modes

4.1 Accidental Retag Before Public Exposure

A developer creates the wrong tag locally:

git tag -a v1.8.0 -m "Release v1.8.0" wrong-commit

If the tag has not been pushed and no artifact has been published, correction is simple:

git tag -d v1.8.0
git tag -a v1.8.0 -m "Release v1.8.0" correct-commit

This is not a supply-chain incident yet. It is a local mistake.

4.2 Pushed Tag, No Artifact Consumed Yet

The tag exists on remote, but no release artifact was published and no consumer has fetched it.

Correction may still be acceptable, but it must be controlled:

git tag -d v1.8.0
git tag -a v1.8.0 -m "Release v1.8.0" correct-commit
git push origin :refs/tags/v1.8.0
git push origin refs/tags/v1.8.0

This should require release-owner approval, not casual developer action.

4.3 Pushed Tag and Published Artifact

The moment an artifact, release page, container image, package, or deployment references the tag, treat the release as public.

Do not silently move the tag.

Preferred remediation:

v1.8.0     bad release, documented as bad
v1.8.1     corrective release

If the tag must be removed for legal/security reasons, keep a signed incident record.

4.4 Attacker or Compromised Token Moves Tag

An attacker with push rights can replace a tag so that future consumers resolve a trusted version name to malicious source.

This is why tag protection, signed tags, immutable releases, and artifact digest verification matter.

4.5 Release Automation Reuses Existing Tag

A CI job uses:

git tag -f v1.8.0

or publishes release assets under a tag that already exists.

This is a workflow bug.

Release automation should fail closed if the tag exists:

if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then
  echo "Tag $VERSION already exists. Refusing to overwrite." >&2
  exit 1
fi

5. Lightweight vs Annotated vs Signed Tags

5.1 Lightweight Tag

git tag v1.8.0 abc123

A lightweight tag is just a ref.

Inspect:

git show-ref --tags v1.8.0
git cat-file -t v1.8.0

Likely output:

commit

There is no independent tag object.

5.2 Annotated Tag

git tag -a v1.8.0 abc123 -m "Release v1.8.0"

Inspect:

git cat-file -t v1.8.0
git cat-file -p v1.8.0

Expected type:

tag

The tag object contains:

  • target object
  • target object type
  • tag name
  • tagger identity
  • date
  • message

5.3 Signed Tag

git tag -s v1.8.0 abc123 -m "Release v1.8.0"

Verify:

git tag -v v1.8.0

A signed tag strengthens the release record because the tag object itself is signed.

But it does not solve every problem.

A signed tag can still be deleted and replaced by another signed tag if policy allows tag mutation.

Therefore:

Signature proves who signed the tag object. Protection policy controls whether the tag ref can be replaced.

You need both for strong release integrity.


6. Tag Object vs Peeled Commit

Annotated tags point to tag objects, not directly to commits.

Use peeling to resolve the commit behind a tag:

# tag object SHA if annotated, commit SHA if lightweight
git rev-parse v1.8.0

# commit object behind tag
git rev-parse v1.8.0^{commit}

# show both ref and peeled target
git show-ref --dereference --tags v1.8.0

For release automation, do not accidentally compare the tag object SHA when you intend to compare the commit SHA.

A safe verification script should record both:

TAG="v1.8.0"
TAG_OBJECT=$(git rev-parse --verify "$TAG")
TAG_COMMIT=$(git rev-parse --verify "$TAG^{commit}")

printf 'tag=%s\n' "$TAG"
printf 'tag_object=%s\n' "$TAG_OBJECT"
printf 'tag_commit=%s\n' "$TAG_COMMIT"

For lightweight tags, tag_object and tag_commit may be the same commit SHA.

For annotated tags, they differ.


7. Release Integrity Invariants

A strong release process enforces these invariants:

InvariantWhy it mattersExample enforcement
Release tags are annotatedcarries tagger/date/messagerelease script rejects lightweight tags
Release tags are signedstrengthens provenancegit tag -v / hosting verification
Release tags are immutable after publicationprevents silent source mutationprotected tags / immutable release settings
Artifact records commit SHAtag name alone is insufficientbuild metadata contains git rev-parse HEAD
Artifact records digestartifact identity is independent from GitSBOM/provenance/deployment record
CI builds from tag commit, not moving branchprevents branch driftcheckout by SHA or tag-ref with verification
Release notes are generated from commit rangeavoids wrong boundaryprevious tag commit to current tag commit
Rollback references artifact digest and commitavoids ambiguous rollbackdeployment manifest pins digest/SHA
Tag creation is controlledreduces accidental retaggingrelease bot + restricted permissions
Tag deletion/replacement is auditableincident reconstructionserver audit logs + signed incident note

8. Dangerous Anti-Patterns

8.1 latest as Release Tag

v1.8.0
v1.8.1
latest   -> moves repeatedly

A moving tag named latest is semantically a branch or channel, not a release.

Better:

NeedBetter representation
Immutable releasev1.8.1 annotated/signed tag
Moving channelbranch release/stable or package dist-tag/channel
Docker latest-like UXmutable image tag plus immutable digest record
Deployment pointerenvironment record, not source release tag

8.2 Retagging to Fix a Release

Bad:

git tag -f v1.8.0 fixed-commit
git push --force origin v1.8.0

Better:

git tag -a v1.8.1 fixed-commit -m "Release v1.8.1: fix v1.8.0 regression"
git push origin v1.8.1

A new version tells the truth.

A moved tag edits history after consumers may have trusted it.

8.3 Build by Tag Name Without Verification

Risky:

git checkout v1.8.0
./build.sh

Safer:

TAG=v1.8.0
EXPECTED_COMMIT=abc123...

git fetch --tags --force origin
ACTUAL_COMMIT=$(git rev-parse --verify "$TAG^{commit}")

test "$ACTUAL_COMMIT" = "$EXPECTED_COMMIT"
git checkout --detach "$ACTUAL_COMMIT"
./build.sh

The safer version treats the tag as a lookup that must match an expected identity.

8.4 Release Notes from Moving Boundaries

Risky:

git log v1.7.0..v1.8.0

This is fine only if both tags are immutable and correct.

If either tag moved, release notes change after publication.

For serious releases, archive the commit boundary used to generate notes:

previous_tag: v1.7.0
previous_commit: 1111111...
current_tag: v1.8.0
current_commit: 2222222...
range: 1111111..2222222

9. Designing Protected Tag Policy

A mature repository treats release tags as protected refs.

Example policy:

refs/tags/v[0-9]*
  create: release-bot only
  update: nobody
  delete: nobody
  require signed tag: true
  require CI release workflow: true
  require release approval: true

If your hosting platform supports immutable releases or protected tags, use them.

If it does not, approximate with server-side hooks.

Server-Side Hook Concept

#!/usr/bin/env bash
# pre-receive pseudo-hook

while read oldrev newrev refname; do
  case "$refname" in
    refs/tags/v*)
      ZERO=0000000000000000000000000000000000000000

      if [ "$oldrev" != "$ZERO" ]; then
        echo "Release tag update/delete is forbidden: $refname" >&2
        exit 1
      fi

      type=$(git cat-file -t "$newrev")
      if [ "$type" != "tag" ]; then
        echo "Release tags must be annotated tag objects: $refname" >&2
        exit 1
      fi
      ;;
  esac
done

This hook blocks updates to existing release tags and rejects lightweight release tags.

In a real server policy, also verify signature and actor identity.


10. Release Tag Creation Playbook

Step 1: Freeze Source Identity

git fetch origin --tags
git switch main
git pull --ff-only origin main

Record:

RELEASE_COMMIT=$(git rev-parse HEAD)

Step 2: Verify Clean State

git status --short

git diff --quiet
git diff --cached --quiet

No dirty state should exist in release tagging.

Step 3: Verify CI and Review Evidence

Do not tag just because local tests pass.

Release tag should point to a commit that passed the release gates.

Evidence should include:

  • CI run ID
  • approval ID or PR IDs
  • source commit SHA
  • dependency lockfile state
  • migration review
  • security scan output
  • artifact digest after build

Step 4: Create Signed Annotated Tag

git tag -s v1.8.0 "$RELEASE_COMMIT" -m "Release v1.8.0

Commit: $RELEASE_COMMIT
Approved-by: release-owner@example.com
CI-Run: <ci-run-id>
"

Step 5: Verify Before Push

git tag -v v1.8.0
git rev-parse --verify v1.8.0^{commit}
git show --stat --decorate v1.8.0

Step 6: Push Tag Explicitly

git push origin refs/tags/v1.8.0

Avoid broad tag push from dirty developer environments:

# risky in release process if local has experimental tags
git push --tags

Step 7: Build Artifact from Resolved Commit

git checkout --detach "$(git rev-parse v1.8.0^{commit})"
./build.sh

Record artifact digest:

sha256sum dist/my-service.tar.gz

Step 8: Publish Release Record

Minimum release record:

version: v1.8.0
tag: v1.8.0
tagObject: <sha>
commit: <sha>
sourceRepository: <url>
ciRun: <id>
artifactDigest: sha256:<digest>
createdBy: release-bot
approvedBy:
  - <person-or-group>
releaseNotesRange:
  fromTag: v1.7.0
  fromCommit: <sha>
  toTag: v1.8.0
  toCommit: <sha>

11. Verifying a Release Tag

Use a layered verification approach.

11.1 Does the Tag Exist?

git ls-remote --tags origin v1.8.0

11.2 Is It Annotated?

git cat-file -t v1.8.0

Expected:

tag

11.3 Is It Signed?

git tag -v v1.8.0

11.4 What Commit Does It Resolve To?

git rev-parse --verify v1.8.0^{commit}

11.5 Does It Match the Artifact?

Check artifact embedded metadata:

./my-service --version

Expected output should include:

version=v1.8.0
commit=<same commit>
build=<ci-run-id>

11.6 Does the Artifact Digest Match the Release Record?

sha256sum my-service.tar.gz

Tag verification alone is not enough. Artifact verification completes the chain.


12. Incident Playbook: Release Tag Moved

If a release tag moved after publication, treat it as a supply-chain incident until proven harmless.

Step 1: Freeze

Stop:

  • release jobs
  • package publication
  • deployment promotion
  • dependency update bots

Step 2: Snapshot Evidence

git ls-remote --tags origin > tags-remote-after.txt
git show-ref --tags > tags-local-after.txt

If someone has an older clone:

git show-ref --tags v1.8.0

Collect:

  • old tag target
  • new tag target
  • actor if audit logs exist
  • time of mutation
  • release artifacts produced before/after
  • consumers that fetched the tag

Step 3: Compare Old and New Trees

git diff --stat OLD_COMMIT NEW_COMMIT
git diff OLD_COMMIT NEW_COMMIT -- . ':!dist/'

Classify difference:

DifferenceRisk
identical commitlow, but still audit actor
same tree, different metadatamedium for signatures/audit
source differencehigh
build scripts/dependencies changedcritical
security-sensitive files changedcritical
unknown actor changed tagcritical

Step 4: Decide Remediation

ExposureRecommended action
local onlydelete/recreate locally
pushed, no consumptioncontrolled correction, documented
public artifact publishedpublish new version; do not silently move
package ecosystem consumedsecurity advisory / consumer notification
suspected compromiserevoke tokens, rotate secrets, investigate access logs

Step 5: Prevent Recurrence

  • protect release tags
  • require signed tags
  • restrict release bot token
  • fail if tag exists
  • publish artifact digest
  • pin dependencies by commit/digest
  • monitor tag mutation
  • write immutable release policy

13. Tag Mutation Detection

A simple local monitor can store tag state:

mkdir -p .git-tag-audit

git ls-remote --tags origin \
  | sort \
  > .git-tag-audit/tags-$(date +%Y%m%d-%H%M%S).txt

Compare snapshots:

diff -u old-tags.txt new-tags.txt

Better: store tag state in a release database:

release_tag_name
remote_ref_sha
peeled_commit_sha
tag_object_sha
first_seen_at
last_verified_at
created_by
release_artifact_digest

A tag mutation should trigger an alert.

Do not rely on people remembering that tags should not move.


14. Tag Integrity in Monorepos

Monorepos complicate tag semantics.

Possible tag schemes:

SchemeExampleUse caseRisk
global repo tagv2026.07.07all services released togetherovercouples unrelated services
service prefixpayments/v1.8.0independent service releasesmany tags, requires convention
package prefixlibs/auth/v2.4.1library release in monoreporelease notes range harder
build train tagtrain/2026-07-07.1coordinated release trainneeds manifest

For monorepo service releases, tag alone may not identify the changed scope.

Add release manifest:

release: payments/v1.8.0
repoCommit: abc123
service: payments
path: services/payments
previousRelease: payments/v1.7.4
changedPaths:
  - services/payments
  - libs/auth
  - libs/risk
artifactDigest: sha256:...

The tag identifies the repository commit. The manifest identifies the product boundary.


15. Tags, Containers, and Artifact Registries

Git tags and container tags are both names that may be mutable depending on registry policy.

Do not confuse them.

IdentityMutable by default?Stronger anchor
Git branchyescommit SHA
Git tagtechnically yes unless protectedprotected signed tag + commit SHA
Container tagoften yesimage digest
Package versionusually intended immutable, ecosystem-dependentpackage checksum/digest/provenance
Release page assetplatform-dependentasset digest

A secure deployment should pin artifact digest, not only image tag:

image: registry.example.com/payments@sha256:abc...
source:
  gitTag: payments/v1.8.0
  gitCommit: def...

Git release identity and runtime artifact identity must both be recorded.


16. Minimal Release Tag Policy Template

# Release Tag Policy

## Scope
All tags matching `v*`, `*/v*`, or `release/*` are release tags.

## Rules
1. Release tags must be annotated.
2. Release tags must be signed by the release bot or approved release key.
3. Release tags must point to commits that passed required release CI.
4. Release tags must be created by the release workflow, not manually from developer machines.
5. Release tags must not be updated or deleted after publication.
6. A bad release is remediated by a new version, not by moving the old tag.
7. Artifact digest and commit SHA must be recorded in the release record.
8. Tag mutation, deletion, or signature failure is treated as a release-integrity incident.

## Emergency Exception
Any exception requires:
- incident ID
- security/release owner approval
- old tag object and commit
- new tag object and commit
- consumer impact assessment
- public or internal notification as applicable

17. Build a Release Tag Verifier

Create verify-release-tag.sh:

#!/usr/bin/env bash
set -euo pipefail

TAG=${1:?usage: verify-release-tag.sh <tag> [expected_commit]}
EXPECTED=${2:-}

# Make sure tag resolves.
git rev-parse --verify --quiet "$TAG" >/dev/null

TYPE=$(git cat-file -t "$TAG")
if [ "$TYPE" != "tag" ]; then
  echo "ERROR: $TAG is not an annotated tag. Actual type: $TYPE" >&2
  exit 1
fi

# Verify signature if present/required.
if ! git tag -v "$TAG" >/tmp/tag-verify.$$ 2>&1; then
  cat /tmp/tag-verify.$$ >&2
  rm -f /tmp/tag-verify.$$
  echo "ERROR: tag signature verification failed: $TAG" >&2
  exit 1
fi
rm -f /tmp/tag-verify.$$

COMMIT=$(git rev-parse --verify "$TAG^{commit}")
TAG_OBJECT=$(git rev-parse --verify "$TAG")

if [ -n "$EXPECTED" ] && [ "$COMMIT" != "$EXPECTED" ]; then
  echo "ERROR: tag commit mismatch" >&2
  echo "expected: $EXPECTED" >&2
  echo "actual:   $COMMIT" >&2
  exit 1
fi

echo "tag=$TAG"
echo "tagObject=$TAG_OBJECT"
echo "commit=$COMMIT"
echo "status=verified"

Use in CI:

./verify-release-tag.sh v1.8.0 abc123...

This script intentionally rejects lightweight release tags.


18. Decision Framework

SituationMove tag?Better action
Local tag wrong, not pushedyesdelete/recreate locally
Pushed tag, no artifact, no consumersmaybe, with approvaldelete/recreate with incident note
Published artifact existsnorelease new patch version
Public package consumednonew version + notification/advisory
Secret in release commitdo not rely on retagrotate secret, publish clean release, consider history rewrite separately
Attacker moved tagno silent fixincident response, revoke access, publish advisory
Need stable channeldo not use release tagbranch/channel/dist-tag with explicit mutability

The rule is simple:

If someone outside the release process may have observed the tag, changing it is no longer a normal Git correction. It is a release integrity event.


19. Practical Checklist

Before creating a release tag:

  • target commit is correct
  • working tree is clean
  • release CI passed on target commit
  • dependency lockfiles are reviewed
  • migrations/config changes are approved
  • release notes boundary is correct
  • tag will be annotated
  • tag will be signed
  • tag name is not already used
  • tag protection is active
  • release bot token scope is minimal

After publishing:

  • verify remote tag target
  • verify tag signature
  • record peeled commit SHA
  • build artifact from resolved commit
  • record artifact digest
  • publish release record
  • monitor tag mutation

During incident:

  • freeze publication
  • snapshot refs
  • identify old/new target
  • classify exposure
  • do not silently retag public release
  • publish corrective version if consumed
  • notify consumers if needed
  • add missing protection

20. Summary

Tag mutability is a supply-chain problem disguised as a Git convenience.

The advanced mental model is:

  • branch names are expected to move
  • release tag names should not move after publication
  • Git technically allows tag mutation unless policy prevents it
  • annotated tags are better release records than lightweight tags
  • signed tags prove signer and tag object integrity, not ref immutability
  • protected tags prevent replacement/deletion
  • artifact digests complete the release identity chain
  • bad public releases should be corrected with new versions, not moved tags

The strongest release identity is not a tag alone.

It is a verified binding:

release version
  -> protected signed Git tag
  -> peeled commit SHA
  -> source tree
  -> CI/provenance record
  -> artifact digest
  -> deployment record

That is the difference between “we tagged it” and “we can prove what we shipped.”


References

Lesson Recap

You just completed lesson 91 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.