Skip to content

Enforcing No Breaking Changes

Contractual can enforce a strict no-breaking-changes policy on API contracts by failing the PR check whenever a breaking change is detected. This recipe explains how to set that up, how to handle cases where a breaking change is required, and how to structure a deprecation-first workflow that avoids breaking consumers entirely.

By default, the Contractual GitHub Action runs with fail-on-breaking: true. Any pull request that introduces a breaking change to a contract fails the required status check and cannot merge until one of these things happens:

  1. The breaking change is reverted
  2. A changeset override promotes the bump level intentionally (signaling the team has reviewed and accepted the break)

This is the strictest mode and works well for public APIs where consumers cannot update immediately.

.github/workflows/contractual-pr.yml
name: Contractual PR Check
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'specs/**'
- 'schemas/**'
- 'contractual.yaml'
permissions:
contents: write
pull-requests: write
jobs:
contractual-check:
name: Check contracts
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
- name: Run Contractual PR check
uses: contractual-dev/action@v1
with:
mode: pr-check
github-token: ${{ secrets.GITHUB_TOKEN }}
fail-on-breaking: true # This is already the default. Shown here for clarity.

Make this check a required status check in the repository settings under Branch protection rules for the main branch. Marking it required means GitHub will not allow the merge button to activate until the check passes.

What the PR author sees when a breaking change is detected

Section titled “What the PR author sees when a breaking change is detected”

When a PR introduces a breaking change, the Action posts a comment like this:

## Contractual: Breaking changes detected
| Contract | Change | Classification |
|--------------|-------------------------------------|----------------|
| orders-api | DELETE /orders/{id} removed | BREAKING |
| orders-api | GET /orders: status field removed | BREAKING |
A changeset has been committed to this branch: .contractual/changesets/brave-wolf-leaps.md
To proceed, either:
- Revert the breaking change, or
- Review and acknowledge the changeset (see below)
Workflow failed: fail-on-breaking is true.

The auto-generated changeset file carries a major bump. The PR author needs to take deliberate action before the merge can go through.

Handling exceptions: the changeset override mechanism

Section titled “Handling exceptions: the changeset override mechanism”

Sometimes a breaking change is intentional and the team has decided to accept it. In that case, the PR author edits the changeset file that Contractual committed to the branch.

Open .contractual/changesets/brave-wolf-leaps.md:

---
"orders-api": major
---
Removed DELETE /orders/{id} endpoint and the status field from GET /orders.

The changeset already has major, which is correct. The override mechanism here is not about changing the bump level. It is about acknowledging the change by pushing the changeset file with a meaningful description. Once the author pushes a commit with a description that explains the rationale, the team can review and approve the PR explicitly.

If the auto-detected bump was minor but the team decides the change should be treated as breaking:

---
"orders-api": major ← override: change minor → major
---
Renamed field customerId to customer_id. Structurally non-breaking
(both names exist during transition), but all consumers must update
their code before the old field is removed. Treat as major to signal
consumer action required.

Temporarily disabling fail-on-breaking for a single PR

Section titled “Temporarily disabling fail-on-breaking for a single PR”

If a team has an emergency fix that must merge despite a breaking change and a full review cannot wait, a bypass can be used, but it requires repository admin access. A better approach is to set fail-on-breaking: false for a specific workflow run using a workflow dispatch trigger:

on:
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
inputs:
fail-on-breaking:
description: "Fail if breaking changes are detected"
type: boolean
default: true
jobs:
contractual-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
- uses: contractual-dev/action@v1
with:
mode: pr-check
github-token: ${{ secrets.GITHUB_TOKEN }}
fail-on-breaking: ${{ inputs.fail-on-breaking || true }}

The cleanest way to enforce no breaking changes is to never make them. The deprecation-first workflow means:

  1. Mark the old field or endpoint as deprecated in the spec
  2. Add the replacement field or endpoint alongside it
  3. Release the new version. Consumers migrate at their own pace
  4. After a documented migration window, remove the deprecated field in a separate PR

This approach means the fail-on-breaking: true check never fires for intentional removals. They happen in a separate, clearly communicated release after consumers have already migrated.

paths:
/orders/{id}:
get:
operationId: getOrder
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/Order"
components:
schemas:
Order:
type: object
properties:
# Deprecated: use customer_id instead. Will be removed in 3.0.0.
customerId:
type: string
deprecated: true
description: "Deprecated. Use customer_id."
customer_id:
type: string
description: "Customer identifier. Replaces customerId."

The deprecated: true marker surfaces in generated documentation and clients. Contractual does not classify deprecation as a breaking change, so this PR passes the check cleanly.

JSON Schema does not have a first-class deprecated keyword in Draft 07, but a custom extension can be used:

{
"properties": {
"customerId": {
"type": "string",
"description": "Deprecated. Use customer_id. Will be removed in v3.",
"x-deprecated": true
},
"customer_id": {
"type": "string",
"description": "Customer identifier."
}
}
}

Reporting breaking changes without blocking

Section titled “Reporting breaking changes without blocking”

For internal APIs where all consumers are controlled, it can be useful to report breaking changes in PR comments without blocking the merge. Set fail-on-breaking: false:

- uses: contractual-dev/action@v1
with:
mode: pr-check
github-token: ${{ secrets.GITHUB_TOKEN }}
fail-on-breaking: false # Report but do not block

With this setting, the PR comment still shows the diff table and Contractual still commits the major changeset to the branch. The difference is that the workflow exits with code 0 regardless of what was detected. The Version Contracts PR will still bump the version as major.

PolicyConfigUse when
Block all breaking changesfail-on-breaking: true (default)Public APIs with external consumers
Report but do not blockfail-on-breaking: falseInternal APIs where all consumers are controlled
Deprecation-firstfail-on-breaking: true + deprecate before removingLong-lived public APIs with many consumers