Release Tagging & Versioning: Automated, Signed, and Governed Jump to heading
Release tagging sits at the intersection of Git Workflow Architecture & Branching Strategies and continuous delivery: a tag is the immutable checkpoint that converts a code commit into a deployable artifact, triggers downstream pipelines, and creates a compliance-grade audit record. Without a disciplined tagging system, version drift accumulates, rollbacks become guesswork, and supply-chain audits fail.
Prerequisites Jump to heading
Before working through the steps below, confirm your environment meets these requirements:
Release Tag Lifecycle — from Commit to Deployment Jump to heading
Step 1 — Enforce Conventional Commits for Automated Bump Logic Jump to heading
The version-bump calculation is only reliable when every commit heading obeys the Conventional Commits spec. feat: maps to a minor increment, fix: maps to a patch increment, and any commit with a BREAKING CHANGE: footer maps to a major increment. If you have not yet enforced this, configure commitlint with Husky before continuing.
# Verify commitlint is active and the last commit passes validation
npx commitlint --from HEAD~1 --to HEAD --verbose Verification: the command exits with code 0 and prints ✔ found 0 problems, 0 warnings.
Step 2 — Install and Configure semantic-release Jump to heading
semantic-release reads the commit log between the last stable tag and HEAD, derives the next version, creates the Git tag, generates release notes, and publishes them — all in a single CI step.
- Install the package and the plugins you need:
npm install --save-dev semantic-release \
@semantic-release/commit-analyzer \
@semantic-release/release-notes-generator \
@semantic-release/changelog \
@semantic-release/git - Add a
.releaserc.jsonin the repository root:
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
[
"@semantic-release/changelog",
{ "changelogFile": "CHANGELOG.md" }
],
[
"@semantic-release/git",
{
"assets": ["CHANGELOG.md"],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
]
]
} - Verification — run a dry-run locally to confirm the next version without making any changes:
npx semantic-release --dry-run Safety Warning: The
--dry-runflag must be used for local testing. Runningsemantic-releasewithout it outside of CI will push a real tag and trigger your deployment pipeline immediately.
Step 3 — Sign Release Tags with SSH Keys (Git v2.30+) Jump to heading
Unsigned tags cannot be cryptographically verified in downstream pipelines and offer no protection against supply-chain substitution. Git v2.34+ supports SSH signing natively, which removes the GPG dependency without sacrificing verification strength.
- Configure SSH signing globally or per-repository:
# Use SSH for signing instead of GPG
git config gpg.format ssh
# Point Git at your signing key (public key path is correct here)
git config user.signingkey ~/.ssh/id_ed25519.pub
# Register the allowed signers file (maps email → public key)
git config gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers - Populate
~/.ssh/allowed_signers(one entry per trusted signer):
# format: email namespaces="git" key-type key-data
[email protected] namespaces="git" ssh-ed25519 AAAA...rest-of-key
- Create a signed annotated tag:
# -a creates an annotated tag; --sign applies the SSH signature
git tag -a v1.2.3 -m "Release v1.2.3" --sign
# Push the signed tag to origin
git push origin v1.2.3 - Verification — confirm the signature is valid before promoting the tag:
git verify-tag v1.2.3
# Expected: "Good 'git' signature for... with ... key" Safety Warning: Never store signing keys in plaintext CI secrets files or commit them to the repository. Use your CI platform’s secrets manager or OIDC-issued ephemeral keys. Rotate signing credentials on a defined schedule and revoke any compromised key immediately from the allowed signers file.
To make semantic-release emit signed tags automatically, add "tagSign": true to the @semantic-release/git plugin config and ensure GIT_COMMITTER_NAME, GIT_COMMITTER_EMAIL, and the SSH agent are available in the CI environment.
Step 4 — Automate Changelog Generation Jump to heading
Manual release notes create documentation debt and diverge from the actual commit history. CI-driven changelog generation derives the notes directly from the commit graph, so they are always accurate and consistently formatted.
@semantic-release/changelog (configured in Step 2) writes CHANGELOG.md automatically. If you prefer release-please — Google’s alternative that works via pull requests rather than direct pushes — add it as a GitHub Actions workflow:
# .github/workflows/release-please.yml
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: node # adjust for your ecosystem
token: $ Verification: after merging to main, a “Release Please” pull request should appear containing the updated CHANGELOG.md and a bumped version file. Merging that PR creates the tag automatically.
Safety Warning: Do not edit generated changelogs manually after they have been tagged and published. Manual edits break the deterministic link between commit history and the release record. If a correction is needed, cut a patch release that adds the correction via a new
fix:commit.
Step 5 — Trigger CI/CD Pipelines from Tags Jump to heading
Tags should fire pipelines, not branches. Configure your CI system to listen for semver tag events, keeping deployment concerns separate from feature integration concerns. This also aligns cleanly with a trunk-based development setup where main receives continuous commits but deployments are gated on explicit version events.
GitHub Actions example:
# .github/workflows/deploy.yml
name: Deploy on Tag
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+" # match strict semver tags only
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # needed so git describe resolves correctly
- name: Verify tag signature
run: git verify-tag $
- name: Build and push container image
run: |
docker build -t myapp:$ .
docker push myapp:$
- name: Deploy to production
run: ./scripts/deploy.sh $ Verification: push a test tag (v0.0.1-rc.0) and confirm the workflow appears in the Actions tab and only runs the deploy job — not the standard CI job.
Step 6 — Enforce Tag Governance and Protection Jump to heading
Without platform-level controls, developers can push tags directly, bypassing automated signing and validation. Pair these restrictions with PR template standards so that every merged PR contributing to a release carries documented approval and scan results.
GitHub — Tag Protection Rules:
Settings → Rules → Rulesets → New ruleset (Tag)
→ Target: refs/tags/v*.*.*
→ Restrictions: Block force push, Require signed commits
→ Bypass list: your CI service account only
GitLab — Protected Tags:
Settings → Repository → Protected Tags
→ Tag: v*
→ Allowed to create: Maintainers (or a dedicated deploy token)
Verification: attempt to push a tag from a non-CI account and confirm the push is rejected with a permission error.
Integration with Adjacent Workflows Jump to heading
Feature branch isolation and merge strategy directly shape what lands in a release. When teams enforce feature branch isolation with squash merges, each merged PR becomes a single logical commit that semantic-release can cleanly classify. Without that discipline, multi-commit feature branches produce ambiguous bump calculations where a single BREAKING CHANGE: buried in a merge commit may be missed.
Merge vs rebase decisions affect the linearity of the commit graph that version tools traverse. The merge vs rebase decision matrix explains when a linear history (rebase) helps changelog generators produce cleaner output versus when merge commits provide the traceability you need for compliance audits.
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
semantic-release exits with “no release” on every run | Commits do not match any configured prefix (e.g. chore: is excluded by default) | Check .releaserc.json releaseRules and confirm the commit log contains feat:, fix:, or BREAKING CHANGE: since the last tag |
git verify-tag prints “error: no signature found” | Tag was created without --sign, or gpg.format was not set before tagging | Delete the unsigned tag (git tag -d vX.Y.Z) and recreate it with SSH signing configured |
| CI fails with “tag already exists” | A previous dry-run or manual push created the tag before the automated pipeline ran | Delete the conflicting tag from origin (git push origin :refs/tags/vX.Y.Z) and re-trigger the pipeline |
| Tag push rejected: “protected tag” | A developer tried to push directly instead of letting CI create the tag | Let semantic-release or release-please handle tag creation from the designated CI pipeline |
| Changelog is empty despite commits since last tag | The changelogFile path does not match what @semantic-release/changelog expects, or the plugin order is wrong | Ensure @semantic-release/changelog appears before @semantic-release/git in the plugins array |
release-please PR never appears after merge to main | The GITHUB_TOKEN lacks pull-requests: write permission | Add permissions: pull-requests: write to the workflow job |
Frequently Asked Questions Jump to heading
Can I use semantic-release with a monorepo? Jump to heading
Yes. For a monorepo, use semantic-release with the @semantic-release/exec plugin and per-package configuration files, or switch to release-please in manifest mode which tracks independent version files per package in a single repository and generates separate changelogs per package path.
What is the difference between a lightweight tag and an annotated tag? Jump to heading
A lightweight tag is a plain pointer to a commit SHA with no additional metadata. An annotated tag is a full Git object containing a tagger identity, timestamp, message, and optional cryptographic signature. Changelog tools and git describe work best with annotated tags. Always use git tag -a for release tags.
How do I roll back a bad release without deleting the published tag? Jump to heading
Create a new patch release that reverts the offending commits: git revert <bad-commit-sha>, commit with fix: revert <description>, and let semantic-release cut the next patch tag. Never delete or force-move a published tag — downstream registries and deployment systems cache tag references, and deletions cause hard-to-debug inconsistencies in package lock files and deployment logs.
Should release tags live on main or on a dedicated release branch? Jump to heading
In trunk-based workflows, cut tags directly from main after CI passes. In GitFlow-style workflows, cut tags from the release/* branch, then merge back to main and develop. Choose based on your branching model; semantic-release supports both via the branches configuration key.
How do I stop developers from manually pushing tags? Jump to heading
Use GitHub’s tag protection rules (Settings → Rules → Rulesets) or GitLab’s protected tags feature to restrict refs/tags/* pushes to the CI service account identity. Use OIDC-issued tokens or fine-grained personal access tokens scoped exclusively to the release workflow, and reject pushes from personal developer accounts.
Related Jump to heading
- Setting up PR Templates for Code Review Efficiency — standardize the review artifacts that gate each release PR, ensuring every version increment carries documented approval before tagging.
- How to Enforce Conventional Commits with Commitlint — the commit validation layer that makes automated SemVer bump logic deterministic.
- Trunk-Based Development Setup — the branching model most compatible with push-on-tag deployment pipelines and continuous delivery.
- Feature Branch Isolation — controls what lands on
mainbefore a release tag is cut, directly affecting changelog quality. - Merge vs Rebase Decision Matrix — choosing the right integration strategy keeps the commit graph that version tools traverse clean and auditable.