Breaking Changes

Catch a change that breaks callers before it ships.

kizuna diff compares two versions of your API and reports what each change costs the people already calling it. It reads the declarations, so route keys, MCP tool names and job keys are all in view, and every Zod schema is compared field by field.

Run it against a ref

kizuna diff --against main
BREAKING users.patchUser renamed to users.updateUser
         client method and generated client names change, HTTP surface unaffected

BREAKING POST /users body.organisationId is now required

BREAKING the job reconcileInvoices is gone
         whatever calls POST /jobs/run with that key stops working

CHANGED  GET /users/:id can now answer 410
ADDED    POST /users/:id/archive added

Gate it in CI

One checkout, one install, no build. fetch-depth: 0 matters, since the default shallow clone has no base commit to compare against.

.github/workflows/breaking-changes.yml
name: Breaking Changes

on:
    pull_request:
        branches: [main]

jobs:
    breaking-changes:
        runs-on: ubuntu-latest
        steps:
            - uses: actions/checkout@v6
              with:
                  fetch-depth: 0

            - uses: pnpm/action-setup@v6
            - uses: actions/setup-node@v6
              with:
                  node-version: 24
                  cache: pnpm

            - run: pnpm install --frozen-lockfile

            - name: Compare against main
              id: diff
              run: pnpm kizuna diff --against ${{ github.event.pull_request.base.sha }}

            - name: Require a label for breaking changes
              if: steps.diff.outcome == 'failure'
              run: |
                  if [[ "${{ contains(github.event.pull_request.labels.*.name, 'breaking changes') }}" == "true" ]]; then
                      echo "Breaking changes acknowledged via label."
                  else
                      echo "::error::Breaking changes detected. Add the 'breaking changes' label to acknowledge."
                      exit 1
                  fi

A breaking change blocks the pull request until someone adds the breaking changes label.

The route surface

ChangeLevel
A route removedbreaking
A route renamedbreaking
A method or path changedbreaking
A declared status no longer answeredbreaking
A job key removedbreaking
An MCP tool removed or renamedbreaking
A new statuschanged
A new deprecation or sunsetchanged
A route addedadded

A rename is spotted by matching a departed key against an arrived one on the same method and path, so it reads as one breaking change rather than a removal and an addition. Renaming users.patchUser to users.updateUser leaves the method, the path and every schema alone, and still changes the client method, the MCP tool name, and the method names in the generated Swift and Kotlin clients.

Schemas, where direction decides everything

A caller sends requests and reads responses, so tightening an input and dropping an output are the two that hurt.

ChangeRequestResponse
A required field addedbreakingchanged
An optional field addedquietchanged
A field removedchangedbreaking
An optional field requiredbreakingchanged
A type changedbreakingbreaking
An enum value removedbreakingbreaking
An enum value addedchangedbreaking

That last row catches people out. Adding a value to a response enum breaks any client that generated a closed enum for it, which is what unknownEnumCase exists for in the Swift and Kotlin clients.

Bodies, query parameters, headers and every response body are compared, reported by path and followed into arrays:

BREAKING POST /users body.address.postcode is now required
BREAKING GET /users 200.items[].id is number instead of string

What it cannot see is a rule inside .refine() or .superRefine(), because those are functions rather than declarations. A tightened refinement passes quietly.

Keeping generated clients current

A generated client that has fallen behind is a compile error waiting to happen in whatever imports it. kizuna generate --check catches it, and checkClients does the same inside a script of your own:

scripts/check-clients.ts
import { checkClients, formatStale } from '@kizunajs/cli';
import kizuna from '../kizuna.config';

const stale = checkClients(kizuna.api, kizuna.clients);

if (stale.length > 0) {
    console.error(formatStale(stale));
    process.exit(1);
}
A generated client is out of date:
  src/lib/api-client.generated.ts is behind the config

Run `kizuna generate` and commit the result.

Reference

On this page