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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A hand-written upgrade note | 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 | 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 | 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 | 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 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.
Related how-tos
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
-
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
-
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
privatePackagesas version false and tag false. Under that default the sample’s private package was versioned by nothing, andchangeset versionreported that all files had been updated after updating none, so the fixture setsversionto true. ↩︎ Back to text -
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-Versionheader, 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