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

Release Tag LifecycleA linear flow diagram showing five stages: Conventional Commit, SemVer Calculation, Signed Tag, Changelog Generation, and CI Deploy Trigger, connected by arrows.ConventionalCommitSemVerCalculationSigned TagCreatedChangelogGeneratedCI DeployTriggerfeat: / fix: / BREAKINGsemantic-release / release-pleaseSSH or GPG signedCHANGELOG.md committedtag push event → pipeline

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.

  1. 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
  1. Add a .releaserc.json in 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}"
      }
    ]
  ]
}
  1. Verification — run a dry-run locally to confirm the next version without making any changes:
npx semantic-release --dry-run

Safety Warning: The --dry-run flag must be used for local testing. Running semantic-release without 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.

  1. 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
  1. 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
  1. 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
  1. 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

SymptomLikely causeFix
semantic-release exits with “no release” on every runCommits 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 taggingDelete 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 ranDelete 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 tagLet semantic-release or release-please handle tag creation from the designated CI pipeline
Changelog is empty despite commits since last tagThe changelogFile path does not match what @semantic-release/changelog expects, or the plugin order is wrongEnsure @semantic-release/changelog appears before @semantic-release/git in the plugins array
release-please PR never appears after merge to mainThe GITHUB_TOKEN lacks pull-requests: write permissionAdd 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.