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.

Semver mapping for catalogue changes Adding a string is additive and warrants a minor bump. Changing wording under an existing key is a patch or minor depending on whether meaning changed. Renaming or removing a key breaks any consumer still referencing it and is a major change. Adding a locale changes nothing existing and is a minor bump. What a catalogue change means in semver terms Bump Why A string is added minor purely additive Wording changed patch or minor same key, new content A key is renamed major consumers referencing it break A key is removed major a lookup starts missing A locale is added minor nothing existing changes
Only two rows are breaking — and they are the two that lose translations if handled carelessly.

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.

How a translation reaches a deployed application Approved translations sync into the catalogue package, a release records a version bump and a changelog entry, the consuming application resolves the new version through its dependency range, and its own gates run against the updated strings before anything deploys. A catalogue release reaching an application Translator Catalogue repo Release App approved units synced version bump + changelog dependency range resolves CI gates run
The version is the handoff point — it is what lets an application choose when to take the change.

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.

Five rules for releasing a shared catalogue Every catalogue change produces a release rather than mutating a shared directory. The changelog is generated from the key diff. Breaking is defined narrowly as removal or rename. Consumers take a caret range so additive changes arrive without action. And removals are announced by a deprecation window before the major release that performs them. A release policy that holds 1 Every catalogue change is a release no floating shared directory 2 Generate the changelog from the diff keys added, changed, removed 3 Breaking means removed or renamed and only that 4 Consumers use a caret range patches and minors arrive automatically 5 Deprecate before removing a major release announces, it does not surprise
Rule three is the one to write down: without a definition, every change feels breaking to somebody.

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.

Part of Monorepo i18n Package Architecture.