How-to › Document and support developers

How to turn a breaking release changelog into a migration guide#

Diff the old and new API descriptions with oasdiff, group the breaks by the work they cause, add before and after code, and ship it with the release candidate.

Audience
API producer
Level
intermediate
Topic
Write guides and quickstarts
Languages
TypeScript and JavaScript
Verified

Your API’s major version ships next week and the changelog is a spec diff: 45 generated lines, three of which report the same field changing type, once per endpoint. The code a caller has to change appears in none of them. Callers read the list, see no line that is about their code, and wait for someone to explain.

What you get

You will end up with a migration guide built from the oasdiff changelog and grouped by the work a caller does rather than by endpoint. Every break carries code before and after. This is for you if you ship a version that breaks callers.

Short answer

Run oasdiff changelog old.yaml new.yaml --format json to get every change as data. Group the changes by what a caller has to do: read a renamed or retyped field, send a required input, and replace a removed call. Give each group a hand-written before and after, and order the groups by whether the work can be done before the switch or only at it. Publish the guide with the release candidate, when callers can still act.

You will need

Node 22 or later, an OpenAPI 3 document for the old version and one for the new, and oasdiff on your machine. Verified 2026-10-21 against Node 22.22.0, oasdiff v1.34.0, jscodeshift 17.4.0, @changesets/cli 3.0.3, and yaml 2.9.1. The sample is an orders API going from 1.8.0 to 2.0.0-rc.1 with six kinds of break; the oasdiff report is committed as a fixture.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A hand-written upgrade noteA small API, or a release with two or three breaks the writer can describe from memoryThe breaks the writer forgot stay out of the note, and the note goes stale the next time the spec movesMore than a handful of breaks, or a spec that changes under the writer
A jscodeshift codemodA break that is mechanical in the caller’s code, such as a renamed field or method, in a language your callers shareA transform to write and test per language, and judgement calls a parser cannot make, such as which status belongs to an orderCallers spread across many languages, or a break that changes meaning rather than names
ChangesetsA package whose release notes are written in the pull request that makes the changeNotes grouped by semver level, one entry per changeset, with no order of work and no before and after unless the author types themThe thing that changed is an API rather than a package, and the audience is callers rather than installers
oasdiffTwo OpenAPI documents and the need for a complete list of what changed and how badlyA list in the spec’s terms, repeated once per operation, that rates the removal of an optional response field informationalThe breaking changes are in behavior the spec does not describe

oasdiff gives you completeness: 45 lines for this release, three of them the same change, each in the spec’s terms rather than a caller’s. Changesets gives you a note written when the change was made, as one line per changeset ordered by semver level rather than by work. A hand-written note speaks the caller’s language, which the other two cannot, at the cost of covering only the breaks the writer remembered. The generator below takes the first and borrows the voice of the third.

Derive the list of breaks from the two documents

The list comes from the two API descriptions, never from memory. oasdiff’s changelog command reports every change a consumer can notice, breaking or not, and --format json makes it data a script can regroup. The breaking command is the CI gate only with --fail-on ERR. Without the flag it exited 0 on this release’s sixteen errors, although the same page says it exits 1. The changelog is the material for the guide, because the additions a caller can use after the switch belong in it too.

head -12 fixtures/oasdiff-changelog.txt
45 changes: 16 error, 1 warning, 28 info
error	[api-path-removed-without-deprecation] at specs/v1.yaml
	in API GET /customers/{customerId}/orders
		api path removed without deprecation

error	[response-property-type-changed] at specs/v2.yaml
	in API GET /orders
		the `items/lines/items/unit_price` response's property `type` changed from `integer` to `object` for status `200`

error	[response-property-type-changed] at specs/v2.yaml
	in API GET /orders
		the `items/total` response's property `type` changed from `integer` to `object` for status `200`

Sixteen errors, which is oasdiff’s word for breaking.1 Each entry in the JSON form carries an id, the operation, and a sentence, which is everything the grouping needs.

node -e "const [first] = JSON.parse(require('fs').readFileSync('fixtures/oasdiff-changelog.json', 'utf8')); console.log(JSON.stringify(first, null, 2))"
{
  "id": "api-path-removed-without-deprecation",
  "text": "api path removed without deprecation",
  "level": 3,
  "operation": "GET",
  "operationId": "listCustomerOrders",
  "path": "/customers/{customerId}/orders",
  "section": "paths",
  "baseSource": {
    "file": "specs/v1.yaml",
    "line": 83,
    "column": 5,
    "endLine": 100,
    "endColumn": 53
  },
  "fingerprint": "bad73e3a7118"
}

Group the breaks by the work they cause and write the code for each

A caller does not fix GET /orders, POST /orders, and GET /orders/{orderId} in turn. A caller fixes the one line that reads order.status. So guide.mjs turns each spec path into the path a caller writes, items/total into total, and groups the entries by subject. A required property removed beside one added at the same level is a rename. A type change collects the properties added under it. A removed endpoint lists the endpoints the release added as candidates for its replacement.

  // A rename: in one operation, one required property removed and one added at the same parent,
  // once the properties that belong to a type change are set aside.
  const renames = new Map()
  const byOp = new Map()
  for (const c of changes) {
    if (!c.operation) continue
    if (!byOp.has(where(c))) byOp.set(where(c), { removed: [], added: [] })
    if (c.id === 'response-required-property-removed') byOp.get(where(c)).removed.push(callerPath(tokens(c)[0]))
    if (c.id === 'response-required-property-added') byOp.get(where(c)).added.push(callerPath(tokens(c)[0]))
  }
  for (const { removed, added } of byOp.values()) {
    const fresh = added.filter((p) => !under(p, retyped))
    if (removed.length === 1 && fresh.length === 1 && parentOf(removed[0]) === parentOf(fresh[0])) renames.set(removed[0], fresh[0])
  }

  const addedEndpoints = changes.filter((c) => c.id === 'endpoint-added').map(where)

Each group then needs the caller’s code, which no diff can produce. A snippet file per group key holds the symptom a caller sees before migrating, one explaining paragraph, and the code before and after, in tilde fences so the guide can quote them.

---
symptom: "`DELETE /orders/{orderId}` answers 404 for every order."
---
Orders are cancelled, not deleted, and a cancelled order keeps its history. Pass the reason the new endpoint requires.

~~~js before
await client.deleteOrder(order.id)
~~~

~~~js after
await client.cancelOrder(order.id, { reason: 'customer_request' })
~~~

The groups are ordered by when the work can be done. Reading a renamed or retyped field can be made tolerant of both shapes before the switch. A request body that changed type, or a removed endpoint, can only change at the switch. Additions wait until after it.

node guide.mjs fixtures/oasdiff-changelog.json | grep -E '^#'
# Migrating Example Orders API from 1.8.0 to 2.0.0-rc.1
## 1. Before you switch: read both shapes
### `legacy_id` is gone from responses
### `status` is now `state`
### `total` changed from integer to object
## 2. At the switch: send what the new version requires
### The `status` query parameter is gone from GET /orders
### `shipping_method` no longer takes `drone`
### Send the `idempotency-key` header on POST /orders
### `lines[].unit_price` changed from integer to object
## 3. At the switch: replace the removed calls
### DELETE /orders/{orderId} (`deleteOrder`) is gone
### GET /customers/{customerId}/orders (`listCustomerOrders`) is gone
## 4. After the switch
### New endpoint POST /orders/{orderId}/cancel
### New property `notes`
### Schema `OrderStatus` is gone from components

Twelve steps from 45 lines. The first is a field oasdiff rated informational: callers who read legacy_id break regardless, so the generator keeps every response removal in the reading step and prints the rating beside it. One group in full shows what a caller gets.

node guide.mjs fixtures/oasdiff-changelog.json | awk '/^### DELETE/,/^### GET \/customers/'
### DELETE /orders/{orderId} (`deleteOrder`) is gone

Where: DELETE /orders/{orderId}. oasdiff rates it breaking: api-removed-without-deprecation.
Also: added in this release: POST /orders/{orderId}/cancel.
Before you migrate: `DELETE /orders/{orderId}` answers 404 for every order.

Orders are cancelled, not deleted, and a cancelled order keeps its history. Pass the reason the new endpoint requires.

Before:

~~~js
await client.deleteOrder(order.id)
~~~

After:

~~~js
await client.cancelOrder(order.id, { reason: 'customer_request' })
~~~

Apply the mechanical breaks with a codemod

Two of the breaks are mechanical in JavaScript callers: order.status becomes order.state, and deleteOrder becomes cancelOrder with a reason. A jscodeshift transform applies both, and the dry run prints the result instead of writing it.

module.exports = function transform(file, api) {
  const j = api.jscodeshift
  const root = j(file.source)
npx jscodeshift -t codemods/orders-v2.cjs --dry --print samples/caller.js
// A caller written against 1.x. The codemod rewrites the two mechanical breaks and leaves
// res.status alone: that is an HTTP status, not an order field.
export async function shipPaidOrders(client) {
  const res = await client.get('/orders')
  if (res.status !== 200) throw new Error(`listing orders failed: ${res.status}`)
  for (const order of res.data) {
    if (order.state === 'paid') await client.post(`/orders/${order.id}/ship`)
    if (order.state === 'cancelled') await client.cancelOrder(order.id, {
      reason: 'customer_request'
    })
  }

res.status survived, because the transform renames the property only on an object called order, orders, or o. That restriction is the judgement a diff cannot make. It is also why the codemod is a row in the table rather than the whole answer. It covers one language, and only the breaks whose fix is the same in every caller.

See what a release note tool writes for the same release

Changesets captures intent at the right time, in the pull request that makes the change, and changeset version turns the accumulated files into a changelog. The sample runs it on a copy of a fixture package, so no committed file moves.

node release-notes.mjs
package.json version after changeset version: 2.0.0

# @example/orders-sdk

## 2.0.0

### Major Changes

- Orders API 2.0: `Order.status` is `Order.state`, `total` and `unit_price` are Money objects, `POST /orders` requires an `Idempotency-Key` header, `DELETE /orders/{orderId}` is replaced by `POST /orders/{orderId}/cancel`, and the per-customer listing is a `customer_id` filter on `GET /orders`.

One major change, one line. That is a release note, and it is a different document from the guide. The version bump is right, the audience is whoever installs the package, and the before and after live in the author’s head.2

Check it worked

Thirteen tests pin the sample. They cover the path translation, the grouping of 45 entries into twelve steps, the rename and the type-change fold, the snippet parser, and the order of the steps. Others pin the Changesets entry, the codemod’s reach, and the command line. Two are refusals. One fails a break with no snippet, and one fails a snippet with no code before and after.

test('every breaking step needs caller code, or the guide is not written', () => {
  assert.throws(
    () => buildGuide({ ...guideArgs, snippetsDir: 'fixtures/partial-snippets' }),
    /no caller code for: retype-total, enum-shipping_method-drone, header-idempotency-key, retype-lines-unit_price, replace-deleteOrder, replace-listCustomerOrders/,
  )
})
node --test guide.test.mjs
1..13
# tests 13
# suites 0
# pass 13
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 776.11656

pass 13. Delete snippets/retype-total.md, or empty it, and every test that builds the whole guide fails, standing in for the reviewer who asks where that break’s code went.

When it goes wrong

The generator refuses to write the guide. A breaking group has no snippet, or one with no code before and after, and the error names every one. The sample’s fixtures/partial-snippets directory holds one of the nine files, the state of a guide started from the changelog and abandoned.

node guide.mjs fixtures/oasdiff-changelog.json --snippets fixtures/partial-snippets
no caller code for: retype-total, enum-shipping_method-drone, header-idempotency-key, retype-lines-unit_price, replace-deleteOrder, replace-listCustomerOrders. A break with no before and after is a changelog line, not a migration step.

Write the six snippets. Lowering the check to a warning turns the guide back into the list this page opened with.

The guide is published after the release. Nobody can act on it, because the callers who read it are already broken and the ones who are not have stopped reading. Generate it from the release candidate’s description, which is why the sample’s new document is 2.0.0-rc.1 and the guide’s first line says so, and publish it with the candidate. GitHub’s REST API versioning page promises advance notice before a breaking version and keeps the previous version alive for two years afterwards, which is the shape of the window callers need.3

The rename detection pairs the wrong fields. The heuristic takes one required property removed and one added at the same level in one operation as a rename. A release that also adds an unrelated required field there defeats it. Write the group’s snippet for what the change is, and change the title in the generator.

When not to do this

Do not publish the oasdiff output as the migration guide. It is complete and it is correct, and it describes the specification, so a caller has to translate every line into their own code before they can start. The translation is the guide.

Do not write the guide from memory for a release with more than a handful of breaks. The breaks you remember are the ones you argued about in review. The ones that break callers are the ones nobody argued about, such as the optional response field this release dropped.

Do not do any of this for an additive release. A release with no entry at the error level needs a changelog, and one kept in the shape Keep a Changelog describes is enough. The guide is for the release that costs callers work, and a guide for every release teaches callers to skip them.

Do not run the codemod against callers you do not own without the dry run and their consent. It is a suggestion you ship beside the guide, not a step you take for them.

Last verified

Verified 2026-10-21 against Node 22.22.0, jscodeshift 17.4.0, @changesets/cli 3.0.3, and yaml 2.9.1. Every output block is what the command preceding it printed in the page’s code directory after npm ci. oasdiff v1.34.0 was built with go install and run by hand, for the two reports in fixtures/ that the checked commands read and for the exit codes of oasdiff breaking; the gate does not run it.

Footnotes

  1. The rule catalog counts 773 kinds of change: 350 breaking, 19 warnings, and 404 informational. It states the law behind the rating in two sentences: narrowing a request is breaking, and widening a response is breaking. A change that cannot be proven safe is reported as breaking. The reasoning is given: a safe change reported as breaking costs a reviewer one look, while a breaking change reported as safe ships. Removing an optional response property narrows the response, so by that law it is informational, whatever the caller reading the property thinks. ↩︎ Back to text

  2. Changesets’ getting started guide asks for a changeset whenever you make a change, via a commit or a pull request. That is the one moment the author still knows what the change does to a caller. Its configuration reference lists the default for privatePackages as version false and tag false. Under that default the sample’s private package was versioned by nothing, and changeset version reported that all files had been updated after updating none, so the fixture sets version to true. ↩︎ Back to text

  3. GitHub’s API versions page says that breaking changes are released in a new API version with advance notice, and that additive changes go into every supported version. A version is supported for 24 months after the next one is released. The version is a date, sent in the X-GitHub-Api-Version header, and a request without the header gets the version from 2022. Two years is the window in which a migration guide can be read before it is needed. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.