DevOps · Release Engineering

Describing Breaking Changes So Consumers Can Act on Them

Why a one-line note that an API changed forces every consumer to read the diff, and the shape of an entry that names the affected surface, the failure it produces, and its replacement.

A breaking change is the only kind of release note that changes what a consumer must do. Everything else can be read passively: a new feature is optional, a bug fix is implicitly desirable. A removal or a semantic change demands work, and the cost of that work is determined almost entirely by how well the note identifies what breaks and when it will be noticed.

The failure mode is easy to describe. A release note says “removed deprecated options from the client constructor”. A consumer’s build passes, so nothing is noticed at compile time. At runtime, an option that was silently ignored now raises. The consumer discovers this in production, then searches the release notes, then reads the diff to find which options were removed. The note was accurate and useless.

Three facts an entry has to carry

An actionable breaking-change entry answers three questions, and omitting any one of them shifts work onto the reader.

Which surface. Not the module, not the file: the callable or the message. Client(timeout_ms=) is a surface; client.py lines 140-190 is not. Named surfaces are greppable, which is the only property that matters for a consumer scanning release notes against their own codebase.

What happens on the old usage. An exception at construction, a silent behaviour change, a type error at compile time, a 400 response. This determines whether the break is caught by the test suite or by users, which is the single most useful piece of information a consumer has.

The replacement, or the absence of one. If a new parameter, a new method, or a new endpoint supersedes the old one, naming it turns a research task into a substitution. If there is no replacement, saying so explicitly stops consumers from searching.

A fourth fact is often worth including when the break is not obvious from the surface: the condition under which the old code broke. A parameter that was accepted but ignored for years is not breaking to every consumer, only to those that passed a value expecting it to take effect. Naming the condition lets most consumers skip the entry.

Compile-time breaks are cheaper than runtime ones

The cost asymmetry between a break detected by a type checker and one detected at runtime is large enough to drive API design. Removing an exported function in a statically typed language produces an error at every call site during the build, with a file and line per site. The same removal in a dynamically typed language produces an error the first time that path executes, which may be in a rarely used branch.

This is why deprecation in typed ecosystems follows a mechanical sequence: add the replacement, mark the old surface deprecated with a documented migration, keep both for a declared period, then remove. Each step is a release note, and only the last one is breaking. The intermediate steps give consumers a compile-time signal that survives across upgrades, because the deprecation warning is attached to the symbol rather than to a version number.

For wire protocols the same asymmetry exists, and the mitigation is a rule about tolerance. A message format that requires consumers to ignore unknown fields can carry additions without breaking anyone, which is the property that lets a field be added in a minor version and removed later without the addition itself being a break. Protobuf’s field numbers exist in part to make that possible, since an unknown number can be carried and a retired number never reused. When a format rejects unknown fields, every addition is breaking by construction, and the release note has to say so.

Why the version number is not enough

The major bump tells a consumer that something broke. It does not say what, and for a widely used artifact the set of things that broke in a given major release can be large. The note is what turns the flag into a work item.

A useful structure for a major release note groups entries by the surface they touch and, within a group, orders them by how likely they are to affect a consumer. Removals that were deprecated over several minors are expected; a semantic change in a function nobody was told about is not. Marking which of the two categories an entry belongs to is the difference between a note that can be skimmed and one that must be read line by line.

The counterpart is the deprecation period itself. A removal that was never announced is a defect in the release process, and no amount of note-writing repairs it. The announcement has to be in an artifact the consumer sees without reading release notes: a compiler warning, a runtime warning, an explicit field in the response. Consumers upgrade minor versions without reading; the deprecation notice has to reach them anyway.

Machine-checkable declarations

The declared breaking flag in a commit grammar is the machine-readable half of the same information. In Conventional Commits, a ! in the header or a BREAKING CHANGE: footer marks the commit, and the footer’s value is the explanation. That value is free text, which means the structure described above has to be imposed by convention if it is to be relied upon.

A footer that names the surface explicitly is what lets a tool compile a list. Without one, the generator can only say that something broke, and the reader has the same problem as before:

refactor!: drop timeout_ms from Client()

BREAKING CHANGE: Client(timeout_ms=) is removed. Configure
timeouts with Client.timeout (seconds). Passing timeout_ms
now raises TypeError at construction.

That footer parses as prose but contains the three facts: the surface, the failure, the replacement. A project that wants the list machine-readable rather than machine-detectable can use structured trailers instead — separate keys for the removed surface and the replacement — at the cost of a stricter authoring burden.

Neither approach solves the harder problem: a breaking change that was never declared. A generator aggregates declarations; it cannot audit a diff for semantic changes. The classification remains an author’s judgement, which is why the release gate for a major version tends to include a human read of the aggregate diff rather than a check on the footers. The grammar that carries the declaration, and the parts of it a tool can safely parse, are covered in deriving a changelog from commit messages.

The audience split

Release notes have at least two audiences with incompatible needs. An operator deploying the artifact wants to know what changed in behaviour, configuration, and dependencies. A developer of a dependent library wants the API surface. A single list serving both tends to serve neither, since the operator skips the API section and the developer skips the deployment notes.

The common resolution is one document with clearly separated sections, ordered by audience rather than by change type, with the breaking section first in both. What does not work is placing breaking changes under a “fixes” heading because the change was made to fix something; the cause of the change is irrelevant to a consumer deciding whether to upgrade.

For a dependency the reader also needs to know which versions of the artifact are affected. A break introduced in one minor and fixed in the next produces three releases where only the middle one is dangerous, and a note attached only to the middle release is missed by anyone jumping from the first to the third. Naming the affected range in the entry, rather than relying on its position in a per-release document, is what makes the information survive a multi-version jump.

What cannot be written down

Some breaks have no representation in a note. A function whose behaviour depends on the system time, a sort that was stable and is now not, a default that changed from one valid value to another — none produce an error, and a consumer reading the note may still fail to connect it to their code. The only mitigation is a compatibility guarantee that is tested rather than described: a suite that asserts the observable contract across versions, failing when the contract moves, so that the change becomes visible before release rather than in the field. The construction of such a suite, and the environment pinning it requires, is the subject of release verification and reproducibility gates.

References

  1. Semantic Versioning 2.0.0 — SemVer project
  2. Keep a Changelog — Keep a Changelog project
  3. RFC 9110: HTTP Semantics — IETF
  4. Conventional Commits 1.0.0 — Conventional Commits project