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]" 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.
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 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.
Related Jump to heading
- Monorepo Branch Topology — the parent topic.
- Versioning a Monorepo with Changesets — automating the bump and tag steps.
- Annotated vs Lightweight Tags — why release tags should be annotated.
- Triggering Workflows on Tags and Releases — tag filters for the release workflow.