# 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.

Source: https://voxgig.com/howto/changelog-to-a-migration-guide

- Audience: api-producer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-19

## 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

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A hand-written upgrade note](https://docs.stripe.com/upgrades) | A small API, or a release with two or three breaks the writer can describe from memory | The breaks the writer forgot stay out of the note, and the note goes stale the next time the spec moves | More than a handful of breaks, or a spec that changes under the writer |
| [A jscodeshift codemod](https://jscodeshift.com/run/cli/) | A break that is mechanical in the caller's code, such as a renamed field or method, in a language your callers share | A transform to write and test per language, and judgement calls a parser cannot make, such as which `status` belongs to an order | Callers spread across many languages, or a break that changes meaning rather than names |
| [Changesets](https://changesets.dev/guide/getting-started) | A package whose release notes are written in the pull request that makes the change | Notes grouped by semver level, one entry per changeset, with no order of work and no before and after unless the author types them | The thing that changed is an API rather than a package, and the audience is callers rather than installers |
| [oasdiff](https://www.oasdiff.com/docs/breaking-changes) | Two OpenAPI documents and the need for a complete list of what changed and how badly | A list in the spec's terms, repeated once per operation, that rates the removal of an optional response field informational | The 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](https://www.oasdiff.com/docs/getting-started) 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.

```bash
head -12 fixtures/oasdiff-changelog.txt
```

```text output
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.

```bash
node -e "const [first] = JSON.parse(require('fs').readFileSync('fixtures/oasdiff-changelog.json', 'utf8')); console.log(JSON.stringify(first, null, 2))"
```

```text output
{
  "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.

```js title="guide.mjs"
  // 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.

```md title="snippets/replace-deleteOrder.md"
---
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.

```bash
node guide.mjs fixtures/oasdiff-changelog.json | grep -E '^#'
```

```text output
# 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.

```bash
node guide.mjs fixtures/oasdiff-changelog.json | awk '/^### DELETE/,/^### GET \/customers/'
```

```text output
### 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](https://jscodeshift.com/run/cli/) transform applies both, and the dry run prints
the result instead of writing it.

```js title="codemods/orders-v2.cjs"
module.exports = function transform(file, api) {
  const j = api.jscodeshift
  const root = j(file.source)
```

```bash
npx jscodeshift -t codemods/orders-v2.cjs --dry --print samples/caller.js
```

```text output
// 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.

```bash
node release-notes.mjs
```

```text output
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.

```js title="guide.test.mjs"
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/,
  )
})
```

```bash
node --test guide.test.mjs
```

```text output
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.

```bash
node guide.mjs fixtures/oasdiff-changelog.json --snippets fixtures/partial-snippets
```

```text output
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](https://docs.github.com/en/rest/about-the-rest-api/api-versions)
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](https://keepachangelog.com/en/1.1.0/)
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.

## Related how-tos

- [Detect breaking changes between two OpenAPI versions in CI](/howto/detect-breaking-changes-between-openapi-versions-in-ci)

- [Run a codemod across every generated file with jscodeshift](/howto/jscodeshift-codemod-across-generated-files)

- [Generate a changelog from conventional commits](/howto/changelog-from-conventional-commits)

- [Run a changelog channel developers read in time](/howto/run-a-changelog-channel-developers-read)

- [Move guides to a new docs toolchain without breaking URLs](/howto/migrate-a-guides-site-without-breaking-urls)

## 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.

[^1]: The [rule catalog](https://www.oasdiff.com/docs/breaking-changes) 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.

[^2]: Changesets' [getting started guide](https://changesets.dev/guide/getting-started) 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](https://changesets.dev/guide/config) 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.

[^3]: GitHub's [API versions](https://docs.github.com/en/rest/about-the-rest-api/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.