---
title: 'Versioning and Release'
source: 'https://academia.sh/en/courses/design-systems/versioning-and-release'
course: 'Design Systems'
language: en
updated: '2026-08-19T05:19:54+00:00'
license: 'CC BY-SA 4.0'
---

# Versioning and Release

What counts as breaking in a component interface, deriving the version number from comparing two snapshots, generating the migration guide, and measuring migration cost by call site.

Comparing the doc's prop table against the interface left a question open: adding a
`width` prop to `button` and removing the `icon` prop from `badge` do not carry the
same weight. The first breaks no usage; the second breaks every place that passes that
prop.

Versioning turns this weight difference into a number. Semantic versioning, introduced
in the Modules, Tooling and the Ecosystem course, ties a package's version number to
the class of a change. Applying it to the component catalog needs exactly one thing: an
exact list of what counts as breaking in a component interface.

## What Is Breaking in a Component Interface

A component's contract is not limited to its props, but its props are the measurable
part of it. The following changes are breaking:

- **Removing a component.** The most visible one; any import breaks immediately.
- **Removing a prop.** Calls that pass that prop go silently inert.
- **Renaming a prop.** It looks like a removal combined with an addition, but the
  migration step is different.
- **An optional prop becoming required.** Every call that does not pass the prop
  breaks.
- **A default value changing.** Every call that never touched the source text sees
  different behavior.
- **A value set narrowing.** Calls passing the value that was dropped fall into an
  undefined state.

Two other classes fall outside this list and are compatible: **additive** — a new
component, a new optional prop, a widened value set — and **patch**, a behavior fix
that does not change the interface.

One important item is missing from the list: **the DOM tree structure and class
names**. If a component's output tree can be styled from outside with CSS, that tree is
also part of the contract, and changing it is breaking. The catalog therefore either
brings the tree into the contract explicitly or states explicitly that it does not; a
system that does neither produces unnamed breaking changes with every release.

## Computing the Diff

The program below takes two snapshots of the catalog, applies the rules above,
classifies the changes, derives the version number, and generates the migration guide.
Migration cost is measured by the number of call sites each change affects.

```js
// version.mjs — comparing two catalog snapshots and deriving the version number

// A snapshot: component -> prop name -> { value set, default, required }
const OLD = {
  version: "3.4.2",
  component: {
    button:   { type: { values: ["primary", "secondary", "silent"], default: "secondary", required: false },
                size: { values: ["small", "medium"], default: "medium", required: false },
                icon: { values: null, default: null, required: false } },
    badge:    { tone: { values: ["neutral", "positive", "warning", "negative"], default: "neutral", required: false },
                size: { values: ["small", "medium"], default: "medium", required: false },
                icon: { values: null, default: false, required: false } },
    dropdown: { size: { values: ["small", "medium"], default: "medium", required: false },
                state: { values: null, default: null, required: false } },
    modal:    { size: { values: ["medium", "large"], default: "medium", required: false },
                title: { values: null, default: null, required: false } },
    "notification-banner": { tone: { values: ["info", "positive", "warning", "negative"], default: "info", required: false },
                dismissible: { values: null, default: true, required: false } },
    "loading-indicator": { size: { values: ["small", "medium"], default: "medium", required: false } },
    skeleton: { rows: { values: null, default: 3, required: false } },
  },
};

const NEW = {
  component: {
    button:   { type: { values: ["primary", "secondary", "silent", "danger"], default: "secondary", required: false },
                size: { values: ["small", "medium"], default: "medium", required: false },
                "icon-name": { values: null, default: null, required: false },
                width: { values: ["content", "full"], default: "content", required: false } },
    badge:    { tone: { values: ["neutral", "positive", "warning", "negative"], default: "neutral", required: false },
                size: { values: ["small", "medium"], default: "small", required: false } },
    dropdown: { size: { values: ["small", "medium"], default: "medium", required: false },
                state: { values: null, default: null, required: false },
                searchable: { values: null, default: false, required: false },
                multiple: { values: null, default: false, required: false } },
    modal:    { size: { values: ["medium", "large"], default: "medium", required: false },
                title: { values: null, default: null, required: true },
                dismissible: { values: null, default: true, required: false } },
    "notification-banner": { tone: { values: ["positive", "warning", "negative"], default: "warning", required: false },
                dismissible: { values: null, default: true, required: false } },
    skeleton: { rows: { values: null, default: 3, required: false } },
    "number-field": { size: { values: ["small", "medium"], default: "medium", required: false } },
  },
};

// Rename is reported by hand; otherwise it looks like a removal + an addition.
const RENAMES = [{ component: "button", old: "icon", new: "icon-name" }];

// Call site counts: these determine the migration cost.
const USAGE = { button: 412, badge: 188, dropdown: 57, modal: 45, "notification-banner": 63, "loading-indicator": 31, skeleton: 38 };
// Call sites per prop (places that pass that prop explicitly).
const PROP_USAGE = { "button.icon": 96, "badge.icon": 24, "badge.size": 61, "notification-banner.tone": 63, "modal.title": 40 };

const sameSet = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const findRename = (component, name, dir) =>
  RENAMES.find((r) => r.component === component && r[dir] === name);

const changes = [];
const add = (cls, component, description, migrationStep, impact) =>
  changes.push({ cls, component, description, migrationStep, impact });

for (const [name, oldProps] of Object.entries(OLD.component)) {
  const newProps = NEW.component[name];
  if (!newProps) {
    add("breaking", name, "component removed", "use the skeleton component instead", USAGE[name] ?? 0);
    continue;
  }
  for (const [propName, oldProp] of Object.entries(oldProps)) {
    const renamed = findRename(name, propName, "old");
    if (renamed) {
      add("breaking", name, `prop renamed: ${propName} → ${renamed.new}`,
        `every call passing ${propName} switches to ${renamed.new}`, PROP_USAGE[`${name}.${propName}`] ?? 0);
      continue;
    }
    const newProp = newProps[propName];
    if (!newProp) {
      add("breaking", name, `prop removed: ${propName}`, `removed from calls passing ${propName}`,
        PROP_USAGE[`${name}.${propName}`] ?? 0);
      continue;
    }
    if (!oldProp.required && newProp.required) {
      add("breaking", name, `prop became required: ${propName}`, `a value is added to calls not passing ${propName}`,
        (USAGE[name] ?? 0) - (PROP_USAGE[`${name}.${propName}`] ?? 0));
    }
    if (oldProp.default !== newProp.default) {
      add("breaking", name, `default changed: ${propName} ${JSON.stringify(oldProp.default)} → ${JSON.stringify(newProp.default)}`,
        `calls not passing ${propName} now write the old value explicitly`,
        (USAGE[name] ?? 0) - (PROP_USAGE[`${name}.${propName}`] ?? 0));
    }
    if (oldProp.values && newProp.values && !sameSet(oldProp.values, newProp.values)) {
      const dropped = oldProp.values.filter((d) => !newProp.values.includes(d));
      const added = newProp.values.filter((d) => !oldProp.values.includes(d));
      if (dropped.length) {
        add("breaking", name, `value set narrowed: ${propName} ${JSON.stringify(dropped)} dropped`,
          `calls passing ${JSON.stringify(dropped)} move to another value`, PROP_USAGE[`${name}.${propName}`] ?? 0);
      }
      if (added.length) add("additive", name, `value set widened: ${propName} ${JSON.stringify(added)} added`, "-", 0);
    }
  }
  for (const propName of Object.keys(newProps)) {
    if (oldProps[propName]) continue;
    if (findRename(name, propName, "new")) continue;
    const cls = newProps[propName].required ? "breaking" : "additive";
    add(cls, name, `${cls === "breaking" ? "required " : ""}prop added: ${propName}`,
      cls === "breaking" ? `${propName} is added to every call` : "-", cls === "breaking" ? (USAGE[name] ?? 0) : 0);
  }
}
for (const name of Object.keys(NEW.component)) {
  if (!OLD.component[name]) add("additive", name, "component added", "-", 0);
}

const ORDER = { breaking: 0, additive: 1, patch: 2 };
changes.sort((a, b) => ORDER[a.cls] - ORDER[b.cls] || b.impact - a.impact);

console.log("class      component            change");
for (const d of changes) console.log(`${d.cls.padEnd(10)} ${d.component.padEnd(20)} ${d.description}`);

const count = { breaking: 0, additive: 0, patch: 0 };
for (const d of changes) count[d.cls]++;
console.log(`\nbreaking: ${count.breaking}   additive: ${count.additive}   patch: ${count.patch}`);

// Version derivation: the heaviest class determines which component of the version increases.
function nextVersion(current, count) {
  const [major, minor, patch] = current.split(".").map(Number);
  if (count.breaking > 0) return `${major + 1}.0.0`;
  if (count.additive > 0) return `${major}.${minor + 1}.0`;
  return `${major}.${minor}.${patch + 1}`;
}
console.log(`current version: ${OLD.version}   derived version: ${nextVersion(OLD.version, count)}`);
console.log(`if breaking changes were held back: ${nextVersion(OLD.version, { breaking: 0, additive: count.additive, patch: count.patch })}`);

// Migration guide: breaking changes only, with the call site count each one affects.
console.log("\n— MIGRATION GUIDE —");
let totalImpact = 0;
for (const d of changes.filter((x) => x.cls === "breaking")) {
  totalImpact += d.impact;
  console.log(`* ${d.component}: ${d.description}\n    to do: ${d.migrationStep}\n    affected call sites: ${d.impact}`);
}
console.log(`\ntotal migration cost: ${totalImpact} call sites`);
console.log(`average per breaking change: ${(totalImpact / count.breaking).toFixed(1)} call sites`);
```

```
class      component            change
breaking   badge                default changed: size "medium" → "small"
breaking   button               prop renamed: icon → icon-name
breaking   notification-banner  value set narrowed: tone ["info"] dropped
breaking   loading-indicator    component removed
breaking   badge                prop removed: icon
breaking   modal                prop became required: title
breaking   notification-banner  default changed: tone "info" → "warning"
additive   button               value set widened: type ["danger"] added
additive   button               prop added: width
additive   dropdown             prop added: searchable
additive   dropdown             prop added: multiple
additive   modal                prop added: dismissible
additive   number-field         component added

breaking: 7   additive: 6   patch: 0
current version: 3.4.2   derived version: 4.0.0
if breaking changes were held back: 3.5.0

— MIGRATION GUIDE —
* badge: default changed: size "medium" → "small"
    to do: calls not passing size now write the old value explicitly
    affected call sites: 127
* button: prop renamed: icon → icon-name
    to do: every call passing icon switches to icon-name
    affected call sites: 96
* notification-banner: value set narrowed: tone ["info"] dropped
    to do: calls passing ["info"] move to another value
    affected call sites: 63
* loading-indicator: component removed
    to do: use the skeleton component instead
    affected call sites: 31
* badge: prop removed: icon
    to do: removed from calls passing icon
    affected call sites: 24
* modal: prop became required: title
    to do: a value is added to calls not passing title
    affected call sites: 5
* notification-banner: default changed: tone "info" → "warning"
    to do: calls not passing tone now write the old value explicitly
    affected call sites: 0

total migration cost: 346 call sites
average per breaking change: 49.4 call sites
```

## Deriving the Version Number

The version number is not a decision but a **result**: the program finds seven
breaking changes, so version 3.4.2 goes to 4.0.0. Had the breaking changes been held
back and only the additions shipped, the version would be 3.5.0 — this second line
turns the version number from a debatable topic into the result of release scope.

This distinction produces a practical option: if none of the seven breaking changes are
mandatory, the additive release can ship right away and the breaking changes
accumulate for the next major version. **Accumulating** them is cheaper than shipping
each in its own major version — teams using the catalog migrate once, not seven times.

The rename row shows the boundary of the diff: `icon` becoming `icon-name` is
recognized as a rename only because it was reported by hand. Unreported, it would have
produced two rows — "prop removed: icon" and "prop added: icon-name." The version
number would still read 4.0.0, but the migration guide would be wrong, with no way to
know the removal and the addition are two faces of the same prop. Taking the diff is
automatic; **declaring intent is manual**.

## Measuring Migration Cost

The migration guide is the breaking changes sorted by call site count, and this
ranking feeds two decisions.

The first is which breaking change is actually worth it: `badge`'s `size` default
change affects 127 call sites, removing the `icon` prop affects only 24 — two changes
in the same release differing in cost by more than fivefold. If the default change's
reason does not justify those 127 sites, it is pulled back and dropped from the
breaking list.

The second is how the migration is carried out. Some steps are **mechanical** — a
rename is a string substitution, automatable with a codemod. Others require a
**decision**: which of the 63 calls passing `notification-banner`'s `info` tone should
become `positive` and which `warning` cannot be decided without looking. Separating
these two classes is what makes the migration-time estimate realistic.

The last row shows an interesting case: `notification-banner`'s default-change impact
is zero, because all 63 calls using it already pass `tone` explicitly. The change is
breaking by contract — it bumps the version number — but carries no migration cost.
Contract and cost are separate: the version number looks at the contract, the release
plan at the cost.

## Release Shape

Whether the catalog ships as a single package or as a separate package per component
changes the distribution of the numbers above.

When shipped as a **single package**, a breaking change in one component bumps the
whole package's major version. In the example above, the team using `skeleton` is
forced onto 4.0.0 because of `badge`, even though nothing changed for them, and they
still must make a migration decision. In exchange, dependency management stays single
and no cross-version compatibility problem arises.

When shipped as **one package per component**, each component moves at its own pace and
migration decisions separate out. The cost is that different versions of the same
system can sit side by side in the dependency tree; if two components bind to different
versions of the same token layer, consistency — the system's reason for existing — is
lost.

The choice is made by comparing two numbers: the call sites forced into an unnecessary
migration versus the inconsistency produced by side-by-side versions. A single package
is cheap when the catalog is small and its components are tightly coupled; separate
packages become cheap as the catalog grows and the coupling between components loosens.

## The Deprecation Window

A breaking change is not made directly: a deprecation mark is placed first, then
removal follows. The gap between them is a **window**, measured in releases, not on a
calendar — "for one major version" is more useful than "for three months," since it
keeps its meaning even if the release cadence changes.

The window's job is to keep migration from piling up at release time. Had `icon` been
deprecated one major version earlier, part of its 96 call sites would already have
moved, and the 4.0.0 release's migration cost would have come out lower. This is also
why the deprecation warning must include the **migration step**; a warning that only
says "this prop will be removed" speeds up nothing.

## Summary

- What counts as breaking in a component interface is a countable list: removing the
  component, removing or renaming a prop, a prop becoming required, a default changing,
  and a value set narrowing.
- The version number is not a decision but the result of the heaviest change class in
  the release; accumulating breaking changes into a single major version is cheaper
  than shipping each one separately.
- Taking the diff can be automated, but intent declarations like a rename must be made
  by hand; if they are not reported, the guide comes out wrong.
- Migration cost is measured by the number of affected call sites, and mechanical steps
  must be separated from steps that require a decision.
- Contract and cost are separate things: a change with zero migration cost can still
  bump the version number.
- The choice between a single package and one package per component is made by
  comparing the call sites forced into unnecessary migration against the
  inconsistency produced by side-by-side versions.

## Next Step

The version and migration guide this lesson produced did not ask where the changes
came from; the interface definition was read twice, and something had changed in
between. Who proposed the change, who reviewed it, and by what criterion it was
accepted stayed open. Whose decision was it to add the `width` prop to `button`, and how
many days did that decision take? The next lesson builds the contribution process as a
state machine, detects invalid transitions, and measures the time from proposal to
release by breaking it into stages.
