Skip to main content

Catching Breaking Changes with Optic

Breaking changes slip through code review because reviewers eyeball YAML diffs rather than evaluating semantic change. A deleted response field looks like two lines of red in a git diff; its impact on every consumer that reads that field is invisible. This guide covers Breaking Change Detection at the developer layer — using Optic 0.59 to diff OpenAPI versions, enforce its built-in breaking-change ruleset, and emit a human-readable changelog directly into the pull request conversation, before any code reaches production.

Symptom

The failure mode is silent. A PR removes settledAt from a GET /transactions/{id} response, reviewers approve the clean diff, the change merges, and consumer applications begin crashing at runtime when they try to read a field that no longer exists. CI never raised a flag because nothing compared the new spec against the old one.

Alternatively, the team has a diff tool — perhaps openapi-diff or a raw git diff — but the output is a wall of JSON with no severity classification, so reviewers stop reading it. Breaking and additive changes look identical in the raw output and the cognitive overhead of distinguishing them drives reviewers to approve without understanding.

Optic addresses both failure modes: it runs the comparison automatically and formats the result as a classified, endpoint-grouped changelog that a reviewer can assess in thirty seconds.

Root Cause

Manual review of spec diffs does not scale because reviewers must mentally reconstruct which consumers depend on each removed field or tightened constraint. The problem compounds in these specific scenarios:

  • Code-first generation. When specs are generated from annotations or decorators, the committed YAML is a build artifact. Engineers do not write it, so they are not fluent in reading it. A generator change that drops a field looks identical to a safe rename.
  • No baseline anchor in CI. Without an explicit comparison against the last shipped contract, CI can only lint the current document — it has no knowledge of what changed. Linting a valid single document passes even when a required response field was removed.
  • Changelog deficit. Human consumers of the API (internal teams, external partners) need a structured record of what changed between versions. Without automation, that record is absent or inaccurate.

Optic solves the baseline problem by comparing the current spec against the version on a reference branch, applying semantic diff rules rather than textual ones, and reporting each change with a severity classification that drives CI gating.

Step-by-Step Fix

Step 1: Install Optic 0.59

Install the Optic CLI. It requires Node 18 or later.

# Install globally — makes the binary available in CI without local node_modules
npm install -g @useoptic/optic@0.59.0

# Verify
optic --version
# → @useoptic/optic/0.59.0 linux-x64 node-v20.x.x

If your project uses a lock file and you want to pin the version reproducibly, install as a dev dependency instead:

npm install -D @useoptic/optic@0.59.0
# Invoke via: npx optic ...

Why this works. Pinning to 0.59.0 freezes the breaking-change rule definitions. Optic’s ruleset evolves between minor versions; a floating @latest install can start reporting new violations on existing specs after an unrelated Optic release, breaking CI unpredictably.

Step 2: Create optic.yml

Add an optic.yml file at the repository root. This tells Optic which ruleset to apply and where the spec lives.

# optic.yml — Optic 0.59 configuration
ruleset:
  - breaking-changes       # built-in: flags all backward-incompatible changes
  - require-ids            # every operation must have a stable operationId

# Optional: explicit spec path (Optic defaults to openapi.yaml / openapi.json)
# Useful in a monorepo where the file is not at the root.
# api:
#   path: ./services/payments/openapi.yaml

The breaking-changes ruleset encodes Optic’s understanding of OpenAPI backward-compatibility: removing a response property, deleting an endpoint, making a request parameter required, tightening a constraint, or changing a documented status code all trigger errors. Adding optional fields, new endpoints, or loosening response constraints are classified as additive and do not fail the check.

The require-ids ruleset prevents operationId churn, which causes cascading breakage in generated SDKs and client libraries even when the wire contract is unchanged.

Why this works. A declarative ruleset file means the rules travel with the repo. Any engineer who clones the project gets the same gate locally and in CI without additional configuration.

Step 3: Run optic diff Locally

Before pushing, reproduce the CI gate on the local branch. The --base flag names the git ref to diff against.

# Diff the working-tree spec against the version on main.
optic diff openapi.yaml --base origin/main --check

A clean, additive-only diff produces:

Comparing openapi.yaml to origin/main:openapi.yaml

  GET /transactions/{id}
    + response 200: added property body.tags (string, optional)

  POST /payments
    + added operation

No breaking changes found.
✓ Passed (0 errors, 0 warnings)

A diff containing a breaking change exits non-zero and identifies the exact violation:

Comparing openapi.yaml to origin/main:openapi.yaml

  GET /transactions/{id}
    ✕ breaking  response 200: removed property body.settledAt
    + response 200: added property body.processedAt (string, optional)

  PATCH /payments/{id}
    ✕ breaking  request body: property amount changed from optional to required

2 breaking changes found.
✗ Failed (2 errors, 0 warnings)

The exit code is 1 when any rule fails, 0 otherwise — the CI gate reads this directly.

Why this works. Running the same command locally means engineers see the gate result before opening a PR, closing the feedback loop to seconds rather than minutes. The output names the exact endpoint and property, which makes the remediation obvious.

Step 4: Add a JSON Config for Fine-Grained Rule Overrides

For teams that need to suppress a specific rule or adjust severity, Optic 0.59 accepts a JSON override file referenced from optic.yml.

{
  "$schema": "https://app.useoptic.com/ruleset-config-schema.json",
  "ruleset": [
    {
      "name": "breaking-changes",
      "config": {
        "exclude_operations_with_extension": "x-beta",
        "severity": {
          "response-body-property-removed": "warn",
          "request-property-became-required": "error"
        }
      }
    }
  ]
}

Reference it from optic.yml:

# optic.yml — with JSON config override
ruleset:
  - ./optic-rules.json
  - require-ids

The exclude_operations_with_extension key tells Optic to skip operations marked x-beta: true, which is correct for pre-stable endpoints where breaking changes are expected and communicated separately. Downgrading response-body-property-removed to warn allows the check to pass while still surfacing the change in the PR comment — useful during a planned deprecation period.

Why this works. Severity overrides encode policy decisions explicitly in the repository rather than requiring engineers to remember which changes are allowed. The JSON schema reference means IDEs validate the config file on save.

Step 5: Wire Into GitHub Actions

Add a workflow that runs optic diff --check on every pull request that touches the spec, and posts the changelog as a PR comment.

# .github/workflows/optic-breaking-change.yml
name: Optic Breaking Change Check
on:
  pull_request:
    paths:
      - "openapi.yaml"
      - "optic.yml"
      - "optic-rules.json"

jobs:
  optic-check:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write   # required to post the PR comment
      contents: read

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0     # full history so origin/main is reachable

      - uses: actions/setup-node@v4
        with:
          node-version: "20"

      - name: Install Optic
        run: npm install -g @useoptic/optic@0.59.0

      # Capture diff output regardless of exit code so we can post it.
      - name: Run Optic diff
        id: optic
        run: |
          set +e
          OUTPUT=$(optic diff openapi.yaml --base origin/main --check 2>&1)
          EXIT_CODE=$?
          echo "output<<EOF" >> "$GITHUB_OUTPUT"
          echo "$OUTPUT"     >> "$GITHUB_OUTPUT"
          echo "EOF"         >> "$GITHUB_OUTPUT"
          echo "exit_code=$EXIT_CODE" >> "$GITHUB_OUTPUT"
          exit $EXIT_CODE

      # Post the changelog as a PR comment even on failure.
      - name: Post changelog comment
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            const output = `${{ steps.optic.outputs.output }}`;
            const icon = '${{ steps.optic.outputs.exit_code }}' === '0' ? '✅' : '❌';
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body: `### ${icon} Optic API Changelog\n\`\`\`\n${output}\n\`\`\``
            });

The if: always() on the comment step ensures the changelog appears whether the diff passed or failed, giving reviewers the full picture in both cases. The set +e pattern captures the output before exit $EXIT_CODE propagates the failure to the Actions runner.

Why this works. The PR comment eliminates the need for reviewers to pull the branch locally to understand what changed. The exit-code propagation means the Actions check turns red on a breaking change, satisfying a branch protection rule that requires optic-check to pass before merge.

Before / After

Before — a PR removes settledAt from the response and adds processedAt. The raw git diff gives no semantic signal:

-          settledAt:
-            type: string
-            format: date-time
           processedAt:
             type: string
             format: date-time

A reviewer scanning this diff sees a rename, not a breaking deletion. The change merges.

After — Optic’s CI step fails and posts the following comment:

❌ Optic API Changelog

  GET /transactions/{id}
    ✕ breaking  response 200: removed property body.settledAt
    + response 200: added property body.processedAt (string, optional)

1 breaking change found.
✗ Failed (1 error, 0 warnings)

The reviewer sees the semantic classification immediately. The required remediation is clear: either restore settledAt (with deprecated: true if removal is planned) or apply the breaking-change-approved label after deliberate review. The breaking-change-approved label escape hatch is described in the parent Breaking Change Detection guide.

Verification

After wiring the Actions workflow, verify the full gate end-to-end:

# 1. On a feature branch, remove a response field from openapi.yaml.
# 2. Commit and push.
git push origin feature/test-optic-gate

# 3. Open a PR — the Optic check should fail within 60 seconds.
# 4. Confirm the PR comment appears with the breaking-change summary.
# 5. Confirm the Actions check is red and blocks merge (if branch protection is set).

# 6. Restore the field and push again.
# 7. Confirm the check turns green and the comment shows no breaking changes.

To verify the exit code without a full CI round-trip:

optic diff openapi.yaml --base origin/main --check
echo "Exit: $?"
# Exit: 0  → no breaking changes
# Exit: 1  → breaking changes found

The sibling guide detecting breaking changes with openapi-diff in CI shows how to layer oasdiff as a second opinion in the same workflow for teams that want both the Optic changelog and oasdiff’s richer change-ID inventory. Running both tools in parallel adds roughly 10 seconds to CI and provides redundant coverage.

Edge Cases and Caveats

  • Multi-file specs with $ref. Optic 0.59 resolves $ref inline before diffing, so a modular spec split across multiple files works correctly when you point Optic at the root document. If your spec uses circular $ref chains (a pattern covered in Compile-Time Type Generation from OpenAPI), bundle to a single file with the Redocly CLI before passing it to Optic.

  • Optic and Spectral govern different things. Optic compares two spec versions for semantic compatibility. Spectral lints a single spec against structural rules — field naming conventions, required descriptions, deprecated-before-deletion policies. Use Spectral for governance of the current spec and Optic for comparison; they are complementary, not redundant. The writing custom Spectral rules for API governance guide covers the Spectral half of the pipeline.

  • Shallow clones block the baseline fetch. The actions/checkout@v4 default is a shallow clone (fetch-depth: 1) that does not include origin/main. Set fetch-depth: 0 in the checkout step, or add an explicit git fetch origin main before the diff step, otherwise optic diff --base origin/main cannot resolve the baseline and errors with fatal: couldn't find remote ref origin/main.

Frequently Asked Questions

Does Optic replace oasdiff for CI breaking-change gates?

Not necessarily. Optic and oasdiff solve overlapping but distinct problems. Optic excels at developer-facing review — it renders a readable changelog and captures spec history automatically. oasdiff has a richer set of change IDs and finer severity control, making it the stronger enforcement engine for strict gating. Many teams run both: Optic for the PR comment summary, oasdiff for the hard exit-code gate.

Can Optic track specs that are generated from code rather than hand-written?

Yes. Run optic diff before committing the generated file, comparing it against the previously captured baseline. As long as the generation step runs before Optic, it sees the fully resolved output. The key discipline is committing the generated spec deterministically so the baseline stays stable.

What does optic diff --check exit with when it finds a breaking change?

It exits with code 1 when any enabled rule fails. Additive-only diffs exit 0. The exit code drives the CI gate — map it directly to a GitHub Actions step failure without extra scripting.

How do I add a custom rule to the Optic ruleset?

Optic 0.59 accepts custom rules as JavaScript or TypeScript files referenced from optic.yml under the rules key. Each rule receives a DiffContext object containing the change set, and you call context.error() or context.warn() to report violations. Custom rules compose with the built-in breaking-changes ruleset rather than replacing it.

Is optic diff safe to run on a monorepo with multiple OpenAPI files?

Yes. Invoke optic diff for each spec file separately, or use a matrix strategy in GitHub Actions. Each invocation is stateless and operates on one pair of files. Optic does not assume a single-spec project layout.