# Breaking Change Detection

> **Scope of this page**
>
> This page documents the Zuplo **gateway** runtime (API, AI, and MCP gateways), not the Developer Portal.
>
> It applies to every Zuplo hosting option: Managed Edge, Managed Dedicated, and Self-Hosted. See [Hosting options](https://zuplo.com/docs/articles/hosting-options).

Your OpenAPI files are the contract your API consumers build against. Remove an
endpoint, make a query parameter required, or change a field from a string to an
integer, and every client that relied on the old shape breaks — usually in
production, and usually after the change has shipped.

Breaking change detection catches these changes before you merge them.
`zuplo lint` compares each OpenAPI file in your project with the same file on a
Git branch, such as `main`, and reports every change that can break an existing
consumer:

```txt
  Breaking Changes

  config/routes.oas.json
    0:0        error     Removed "/todos/{id}" (was "/todos/{id}")  breaking-change
    15:25      error     Modified "required" from "false" to "true"  breaking-change
    37:21      error     Modified "type" from "string" to "integer"  breaking-change

  ✖ 3 breaking changes remaining
```

## Turn on detection

Breaking change detection is off by default. Turn it on in `zuplo.lint.jsonc` in
the root of your project:

```jsonc title="zuplo.lint.jsonc"
{
  "version": 2,
  "breakingChanges": {
    "enabled": true,
    "baseline": "origin/main",
  },
}
```

Then run the linter as usual:

```bash
git fetch origin main
npx zuplo lint
```

Detection runs alongside the rest of the lint checks. It uses no extra flags, so
the same command works on your machine and in CI.

## Settings

| Field      | Description                                                                                                                           |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled`  | Turns detection on. Defaults to `false`.                                                                                              |
| `baseline` | The branch, tag, or commit SHA to compare against, such as `main`, `origin/main`, or `v1.2.0`. Defaults to `"main"`.                  |
| `severity` | `"error"` or `"warn"`. Defaults to `"error"`. Controls whether a breaking change fails the run — see [Block or warn](#block-or-warn). |

## Choose a baseline

The baseline is the version of your API that consumers use today. Compare
against whatever represents that version:

- **The branch you deploy production from**, usually `main`. Each pull request
  then reports what it would break once merged. This is the most common setup.
- **A release tag**, such as `v1.2.0`. Every change since the last release is
  checked, which is useful when several merges ship together.

Use `origin/main` rather than `main` when you lint in CI. CI systems usually
check out the pull request's commit without creating a local `main` branch, so
only the remote-tracking branch `origin/main` exists. `origin/main` also works
on your machine after `git fetch`, so one configuration serves both.

The `baseline` value must be a branch, tag, or commit SHA. Expressions such as
`HEAD~1` or `main^` aren't accepted.

## What counts as a breaking change

A change is breaking when a client written against the baseline can fail against
the new version. The linter reports these kinds of change, among others:

| Change                                                | Example message                              |
| ----------------------------------------------------- | -------------------------------------------- |
| Removing a path or an operation                       | `Removed "/todos/{id}" (was "/todos/{id}")`  |
| Adding a required parameter                           | `Added "parameters"`                         |
| Making an optional parameter required                 | `Modified "required" from "false" to "true"` |
| Making a request body property required               | `Added property "required"`                  |
| Changing the type of a parameter, property, or schema | `Modified "type" from "string" to "integer"` |
| Removing a value from an enum                         | `Removed property "enum" (was "done")`       |
| Removing a response status code                       | `Removed "codes" (was "201")`                |

These changes are safe, so the linter doesn't report them:

- Adding a new path, operation, or response
- Adding an optional parameter or request body property
- Adding a property to a response
- Adding a value to an enum
- Changing descriptions, summaries, and examples

Detection compares the API contract — paths, operations, parameters, request
bodies, and responses. It doesn't compare the gateway configuration inside
`x-zuplo-route`, so removing a policy or changing a handler isn't reported as a
breaking change.

## Read the results

Breaking changes appear in their own **Breaking Changes** section, after the
other lint results, and are counted separately in the summary. Every result uses
the ID `breaking-change`.

Each result points at the change in your current file. A removal has nothing
left to point at, so its location is `0:0`; the message names what was removed.

In `--format json` output, breaking changes are in the `breakingChanges` array
and counted in `summary.breakingChanges`. In `--format sarif` output, they're
included with the other results.

New files have nothing to compare against, so a file that doesn't exist on the
baseline never reports breaking changes. Detection checks the same files as the
rest of the linter: every `.oas.json` file in `config/`, or the files listed in
[`include`](./api-lint-configuration.mdx#include).
[`ignorePaths`](./api-lint-configuration.mdx#ignorepaths) doesn't apply to
breaking changes.

## Block or warn

`severity` decides whether a breaking change fails the run:

| `severity`          | `failOn: "error"` (default) | `failOn: "warn"` |
| ------------------- | --------------------------- | ---------------- |
| `"error"` (default) | Fails                       | Fails            |
| `"warn"`            | Passes, and reports         | Fails            |

Use `"error"` to block pull requests that break the contract. Use `"warn"` when
breaking changes are sometimes intentional — for example, while an API is still
in beta — and you want reviewers to see them without blocking the merge.

When you do mean to ship a breaking change, the usual approach is to version the
API instead: keep the old operation, add the new one under a new path such as
`/v2/todos`, and remove the old one later. See
[Versioning on Zuplo](./versioning-on-zuplo.mdx).

## Run in CI

Detection reads the baseline from your Git history, so the CI checkout has to
include it. With GitHub Actions, set `fetch-depth: 0` on `actions/checkout`:

```yaml title=".github/workflows/lint.yaml"
steps:
  - uses: actions/checkout@v4
    with:
      fetch-depth: 0

  - uses: actions/setup-node@v4
    with:
      node-version: 24

  - name: Install dependencies
    run: npm ci

  - name: Lint
    run: npx zuplo lint
```

On other CI systems, run `git fetch origin main` (or the branch you compare
against) before the linter. See [Lint in CI/CD](./api-lint-ci-cd.mdx) for
complete workflows, including GitHub code scanning.

## Requirements and errors

Detection uses Git, so it needs:

- Git installed where the linter runs.
- A project inside a Git repository. The project can be the repository root or
  any subdirectory of it.
- The baseline ref in the local repository.

If any of these is missing, the linter can't compare, and it says so instead of
reporting no changes:

```txt
  Breaking Changes

    0:0        error     Breaking change detection failed: baseline ref "main" not found in this repository; fetch it first (for example, git fetch origin main) or set breakingChanges.baseline to a ref that exists, such as origin/main  breaking-change
```

This result uses the `severity` you set, so with the default `"error"` it fails
the run. A comparison you asked for that can't happen fails the same gate a
breaking change would.

| Message                                         | Fix                                                                                 |
| ----------------------------------------------- | ----------------------------------------------------------------------------------- |
| `baseline ref "main" not found`                 | Fetch the branch (`git fetch origin main`), or set `baseline` to `origin/main`.     |
| `cannot read baseline ... not a git repository` | Run the linter inside a Git repository, or turn off detection for that environment. |
| `invalid baseline ref`                          | Use a branch, tag, or commit SHA rather than an expression such as `HEAD~1`.        |

## Next steps

- [Lint in CI/CD](./api-lint-ci-cd.mdx) — run detection on every pull request.
- [Lint configuration](./api-lint-configuration.mdx) — the rest of the
  `zuplo.lint.jsonc` options.
- [Versioning on Zuplo](./versioning-on-zuplo.mdx) — ship a breaking change
  without breaking existing consumers.
