Tagging packages independently in a monorepo Jump to heading

A single-package repository tags releases as v2.4.0, and every tool understands that: git describe, changelog generators, release workflows, the forge’s release page. A monorepo with twenty packages released on their own schedules cannot use one version for everything. Each package needs its own version history, and the tags must say which package they belong to. The common convention — package@version, such as @acme/[email protected] or [email protected] — handles that, but every tool that assumed v* tags now needs to be told how to filter. This page sets up per-package tags and the commands and configuration that keep version detection, changelogs and release automation working with them, within monorepo branch topology.

When to use this approach Jump to heading

  • A monorepo publishes several packages or deploys several services with their own version numbers.
  • One repository-wide version has become meaningless because packages change at different rates.
  • Release tooling assumes v* tags and gets confused in the monorepo.
  • You are adopting a version tool such as changesets, as in versioning a monorepo with changesets.

Step 1 — Choose a tag format and stick to it Jump to heading

name@version is the most widely supported convention, used by npm workspaces tooling, changesets and Lerna. Scoped package names include a slash, which Git allows in tag names.

git tag -a "@acme/[email protected]" -m "@acme/money 1.8.3"
git tag -a "[email protected]" -m "billing-api 2.4.1"
git push origin "@acme/[email protected]" "[email protected]"
Repository-wide tags against per-package tagsA single v-prefixed tag stream gives one version to the whole repository, so a change to one package bumps everyone. Per-package name@version tags give each package its own history, at the cost of telling every tool which prefix to look at.v2.4.0 (repo-wide)name@version (per package)versions meanthe repositoryeach packageunchanged packagesbumped anywayuntouchedtool supportdefaultfilter by prefixdescribe / changelogworks as-is--match name@*the extra configuration is a one-time cost; meaningless versions are a permanent one

Keep the format identical for every package. Mixed conventions — some [email protected], some name/v1.2.3, some name-1.2.3 — defeat every filter.

Step 2 — Find a package’s latest version with describe Jump to heading

git describe --match restricts which tags count, so it can find the latest version of one package reachable from a commit.

git describe --tags --abbrev=0 --match 'billing-api@*' HEAD
# [email protected]
git describe --tags --match 'billing-api@*' HEAD
# [email protected]      (7 commits since that tag)

Version derivation in general is covered in deriving versions from git describe.

Step 3 — Work out what changed in one package since its last release Jump to heading

A package’s changes since its last tag are the commits between that tag and HEAD that touch the package’s directory — not every commit in the repository.

pkg=billing-api; dir=services/billing
last=$(git describe --tags --abbrev=0 --match "$pkg@*" HEAD)
git log --oneline "$last..HEAD" -- "$dir" libs/money libs/auth-client

Including the package’s internal dependencies in the path list matters: a fix in libs/money changes billing-api’s behaviour and belongs in its next version.

Releasing one package from a monorepoFind the package's last tag with describe and a match pattern. List commits since then that touch the package or its internal dependencies. Decide the version bump from those commits. Tag name@version on the release commit and publish only that package.Last tagdescribe --match pkg@*Changeslog tag..HEAD -- dirsBumpmajor / minor / patchTag[email protected]Publishthat package onlyif no commits touch the package's paths, there is nothing to release

Step 4 — Configure release tooling for prefixed tags Jump to heading

Tools that generate changelogs or versions from tags need to know the prefix. Most accept a tag pattern or a per-package configuration.

// semantic-release in a package directory: tag format per package
{ "tagFormat": "billing-api@${version}" }
# Release workflow triggered by any package tag; the package is derived from the tag name
on:
  push:
    tags: ["*@*"]
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - run: |
          pkg=${GITHUB_REF_NAME%@*}; version=${GITHUB_REF_NAME##*@}
          echo "publishing $pkg $version"
          ./scripts/publish.sh "$pkg" "$version"

${GITHUB_REF_NAME%@*} strips from the last @, which handles scoped names such as @acme/[email protected] correctly.

Step 5 — Keep the tag list navigable Jump to heading

With dozens of packages, git tag becomes long. Sort and filter by package and version, and list each package’s latest tag in a summary.

# Latest tag of every package
git for-each-ref --sort=-version:refname --format='%(refname:short)' refs/tags/ |
  awk -F'@' '{pkg=$0; sub(/@[^@]*$/, "", pkg); if (!(pkg in seen)) {seen[pkg]=1; print}}'

The listing technique is described in iterating refs with for-each-ref.

Step 6 — Sign and protect the tags Jump to heading

Per-package tags are release tags and deserve the same protection: sign them, restrict who can create them, and forbid moving them.

git tag -s "[email protected]" -m "billing-api 2.4.2"
git verify-tag "[email protected]"
# Ruleset on refs/tags/*@*: creation limited to the release workflow, no updates or deletions
Does this commit need a new tag for a package?If no commits since the package's last tag touch its directory or its internal dependencies, no release is needed. If only internal dependencies changed, release a patch to pick them up. If the package's own code changed, bump according to the change type.What changed since pkg's last tag?nothing in its pathsNo releasetag staysonly its dependenciesPatch releasepick them upits own codeBump by change typemajor / minor / patchper-package tags make 'nothing changed' a valid and common answer

Step 7 — Make the forge’s release pages useful Jump to heading

With per-package tags, the forge’s release list mixes every package together. Name releases after the tag, include the package in the title, and generate notes only from the package’s own paths, so each release page describes one package’s change.

pkg=billing-api; tag="$pkg@2.4.2"; prev=$(git describe --tags --abbrev=0 --match "$pkg@*" "$tag^")
git log --format='- %s' "$prev..$tag" -- services/billing libs/money > notes.md
gh release create "$tag" --title "$pkg 2.4.2" --notes-file notes.md --verify-tag

Readers browsing releases can then filter by package name in the title, and each page’s notes match exactly what that package’s users received.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Do forges handle tags with slashes and @ signs? Jump to heading

Yes. Release pages, tag lists and APIs handle them, though URLs encode the special characters. Test your release workflow once with a scoped package name.

Should we also keep a repository-wide tag? Jump to heading

Only if something consumes it — for example, a deploy that ships all services together. Otherwise it adds a second version stream with no meaning.

What about private, never-published packages? Jump to heading

If nothing outside the repository consumes them, they may not need versions at all. Tag only what is released or deployed independently.