Versioning a shared message package
Three applications consume one catalogue package. A cleanup removes forty unused keys, the package is republished, and the oldest application — which still references two of them — starts rendering raw keys in production. Nothing failed in CI, because the application’s tests ran against the version it had, and the version it got was chosen by a dependency range.
A shared catalogue is a shared API. Treating it as one is what converts that failure into a build error before deployment.
Root cause: content changes and contract changes look identical
A catalogue is data, so every change to it looks like a content change. But a subset of those changes alters the contract between the package and its consumers: a key that existed and no longer does is a removed API member, and code referencing it now returns nothing.
The distinction is not visible in the file. Adding forty strings and removing forty strings produce diffs of similar size and identical shape, and only one of them can break a consumer. Without a version boundary the difference is invisible until runtime, in whichever application still referenced the removed key.
This is the same problem any shared library has, and the solution is the same: express the difference in a version number, so consumers can choose when to absorb it. What makes catalogues special is only that people do not think of them as libraries.
What counts as breaking
The definition needs to be narrow enough to be usable and explicit enough to be applied consistently.
Removing a key is breaking. Any consumer still calling it now gets a miss.
Renaming a key is breaking, because it is a removal plus an addition. The safe procedure — including the alias window — is in safely renaming a translation key.
Changing an ICU argument set is breaking. A message that gained a {count} placeholder now needs a value nobody is passing, and the render either throws or emits the raw placeholder.
Changing wording is not breaking, even when the meaning changes. It affects what readers see, which is worth a changelog entry and possibly review, but no consumer’s code stops working.
Adding anything is not breaking. New keys, new locales, new namespaces — a consumer that ignores them is unaffected.
That list is short enough to put in a contributing guide, and it settles the argument that otherwise recurs at every release.
Generating the changelog from the diff
Because the meaningful unit is the key set, the changelog can be generated rather than written, which is what keeps it accurate.
const before = new Set(Object.keys(previousCatalogue));
const after = new Set(Object.keys(nextCatalogue));
const added = [...after].filter((k) => !before.has(k));
const removed = [...before].filter((k) => !after.has(k));
const changed = [...after].filter((k) =>
before.has(k) && previousCatalogue[k] !== nextCatalogue[k]);
const bump = removed.length ? 'major' : added.length ? 'minor' : 'patch';
Four lines decide the bump and three lists write the changelog. Reviewers then see “12 added, 0 removed, 3 changed” on a release rather than a diff of several hundred lines, which is the difference between a release note that is read and one that is not.
Choosing ranges on the consumer side
The counterpart to a disciplined publisher is a sensible consumer range, and the default of a caret range is right for most applications: patches and minors arrive automatically, majors require a deliberate upgrade.
Two variations are worth knowing. An application in a regulated or high-stakes surface may pin an exact version so that no string changes without a review — that is a legitimate choice, at the cost of falling behind and needing a scheduled upgrade. An application in active development against new strings may depend on a workspace protocol rather than a published version, taking the catalogue as it is, which is fine inside a workspace and unwise across repositories.
What does not work is mixing them without noticing. If one consumer pins an exact version and another takes a caret range, the package manager may install both, which reintroduces the duplication described in two i18n instances in a workspace. Consistency across consumers matters more than which convention is chosen.
Release cadence, and why it is a product decision
How often the catalogue is released decides how quickly translations reach readers, and the two obvious cadences both have a failure mode.
Releasing on every merge gets translations out fastest and produces a steady stream of versions, most of which contain a handful of strings. That is fine mechanically — versions are cheap — but it makes the changelog useless as a narrative, because nobody reads two hundred entries a quarter. It also means a consumer on a caret range absorbs string changes continuously, which is exactly right for a product surface and uncomfortable for anything with a compliance review.
Releasing on a schedule — weekly, or per sprint — makes each release reviewable and each changelog meaningful. The cost is latency: a translation approved the day after a release waits a full cycle, and translators experience that as their work not shipping. On a product where translation lead time is already a complaint, adding a week is not neutral.
The arrangement that satisfies both is to release continuously and let consumers choose their cadence through their ranges. A product application takes a caret range and receives strings as they land; a regulated surface pins and upgrades deliberately on its own schedule, reviewing a batch of changes at once. The publisher stops being the place where that trade-off is made, which is right, because the publisher does not know each consumer’s constraints.
What to avoid is the middle position where releases are irregular and undocumented. A catalogue that is published when someone remembers gives consumers no basis for choosing a range at all, and the usual response is to pin — which means nobody gets translations until someone does a manual upgrade round.
Verification
# The bump matches the diff
node scripts/catalogue-diff.mjs --from v3.3.0 --to HEAD
# added: 12 changed: 3 removed: 0 → minor
# No consumer references a key the new version removed
for app in apps/*; do
npx tsx scripts/check-referenced-keys.ts "$app" --against packages/i18n-messages
done
# apps/web: 0 missing
# apps/admin: 0 missing
# apps/docs: 2 missing → checkout.legacy-banner.title, onboarding.tour.step4
The second check is the one that would have caught the failure in the opening paragraph. Running it across every consumer before a release turns a production incident into a release-blocking report naming two keys.
When to escalate
If consumers keep breaking despite correct versioning, the deprecation window is probably too short. A major release announces a removal; it does not give anyone time to act on it. The removal should already have been marked deprecated for a release cycle, as described in string catalog governance, so the major is a formality rather than a surprise.
If a consumer cannot upgrade because of an unrelated dependency conflict, the catalogue is being blocked by something that has nothing to do with strings. That is an argument for keeping the messages package free of runtime dependencies entirely — a package that depends on nothing can always be upgraded.
If release overhead is discouraging small string fixes, automate the release rather than batching the fixes. Batched string changes are how a “minor” release ends up containing a removal nobody mentioned.
FAQ
Should each locale be its own package?
Almost never. The locales change together, are gated together, and are consumed together; splitting them multiplies the release surface without decoupling anything real. Per-locale exports from one package give the loading benefit without the versioning cost.
What version does a catalogue start at?
Whatever makes the breaking-change signal available immediately — which in practice means starting at 1.0.0 rather than 0.x, since pre-1.0 ranges treat minor bumps as breaking and remove the distinction the policy depends on.
Should the version bump be automated?
The bump itself, yes: it is derived from the key diff. The decision to release is worth keeping deliberate on a shared catalogue, because a release is what propagates content to consumers and someone should be able to say why it happened.
How does this interact with translation sync?
The sync writes translations into the catalogue repository; the release publishes them. Keeping those separate means a partially translated batch can land without being published, which is what lets you hold a release until a locale reaches its coverage threshold — the gate described in GitHub Actions i18n CI gates.
Related
- Monorepo i18n Package Architecture — the package this policy versions.
- String Catalog Governance — the deprecation window a major release depends on.
- Safely renaming a translation key — why a rename is two breaking changes.
- Two i18n instances in a workspace — what inconsistent ranges produce.
Part of Monorepo i18n Package Architecture.