contract-compatibility-gate · git:20260718.ab29907 · 2026-07-18 · sha256 6e8a87ff9fe65daa

contract-compatibility-gate git:20260718.ab29907A

Immutable. This exact content never changes and is served at /api/v1/blob/6e8a87ff9fe65daa.

---
name: contract-compatibility-gate
description: "Builds a unified deployment-readiness gate that aggregates verdicts from any combination of Pact `can-i-deploy`, oasdiff (OpenAPI), graphql-inspector (GraphQL), and `buf breaking` (Protobuf), applies severity-aware pass/fail thresholds, and emits a single go / no-go decision with per-finding rationale. Use when authoring a CI step that gates a deployment on cross-protocol contract compatibility."
---

# contract-compatibility-gate

## Overview

Modern services rarely speak just one protocol. A single deployment
might depend on Pact verifications (consumer/provider HTTP),
OpenAPI compatibility for an external REST surface, GraphQL schema
regressions for a public graph, and Protobuf breaking-change checks
for internal gRPC. Each tool has its own exit code, its own severity
levels, and its own concept of "breaking":

| Tool                          | Verdict surface                               |
|-------------------------------|-----------------------------------------------|
| Pact `can-i-deploy`           | Exit 0 = yes / 1 = no ([can-i-deploy][cid])   |
| oasdiff `breaking`            | Exit driven by `--fail-on ERR/WARN/INFO` ([oasdiff-breaking][oasdiff]) |
| graphql-inspector `diff`      | Non-zero on BREAKING; zero on DANGEROUS / NON_BREAKING ([gqi-diff][gqi]) |
| `buf breaking`                | Non-zero on any rule violation in active categories ([buf-breaking][buf]) |

[cid]: https://docs.pact.io/pact_broker/can_i_deploy
[oasdiff]: https://github.com/oasdiff/oasdiff/blob/main/docs/BREAKING-CHANGES.md
[gqi]: https://the-guild.dev/graphql/inspector/docs/essentials/diff
[buf]: https://buf.build/docs/breaking

This skill defines a **unified gate**: collect each tool's structured
output, normalize to one record shape, apply a single severity rule,
emit one verdict.

## When to use

- A deployment depends on more than one protocol (typical for a
  service exposing both gRPC + REST, or a frontend consuming both
  GraphQL and REST).
- The team wants the verdict in `$GITHUB_STEP_SUMMARY` (or equivalent)
  rather than four separate "❌ pact" / "❌ oasdiff" CI badges.
- Some checks should be advisory rather than blocking - e.g. block on
  oasdiff ERR but let WARN pass with a comment.
- The team wants per-tool ratchet behavior (existing failures
  grandfathered, new failures block).

If the project uses one protocol only, defer this gate - use the
matching per-tool SKILL.md's "CI integration" section directly.

## Step 1 - Identify your sources

For each protocol the deployment consumes, pick the tool from the
matching plugin skill and ensure its output is captured as a CI
artifact (always with `if: always()` on the upload):

| Tool         | Skill                                                                                        | Artifact                            |
|--------------|----------------------------------------------------------------------------------------------|-------------------------------------|
| Pact         | [`pact-contract-testing`](../pact-contract-testing/SKILL.md)                                | `can-i-deploy` exit code + matrix   |
| oasdiff      | [`openapi-contract-diff`](../openapi-contract-diff/SKILL.md)                                | `--format json` array               |
| GraphQL      | [`graphql-schema-regression`](../graphql-schema-regression/SKILL.md)                        | grep-parsed text or JSON action     |
| Protobuf     | [`protobuf-compat-checking`](../protobuf-compat-checking/SKILL.md)                          | `--error-format json` array         |

## Step 2 - Define the unified record

Flatten every tool's per-finding output into one shape:

```json
{
  "tool":     "oasdiff",
  "protocol": "openapi",
  "finding":  "response-success-status-removed",
  "severity": "blocker",
  "subject":  "GET /pets",
  "message":  "the success response status '200' was removed",
  "ratchet":  false,
  "owner":    "@api-platform"
}
```

| Field        | Source                                                                                                              |
|--------------|---------------------------------------------------------------------------------------------------------------------|
| `tool`       | `pact` / `oasdiff` / `graphql-inspector` / `buf`.                                                                   |
| `protocol`   | `pact` / `openapi` / `graphql` / `protobuf`.                                                                        |
| `finding`    | tool-specific rule ID (oasdiff `id`, graphql-inspector change type, buf rule like `FIELD_NO_DELETE`).                |
| `severity`   | normalized - `blocker` / `warn` / `info`.                                                                            |
| `subject`    | endpoint / type / message + field that's affected.                                                                  |
| `message`    | human-readable rationale (tool's own message).                                                                       |
| `ratchet`    | optional - `true` if grandfathered; `false` blocks if severity is `blocker`.                                         |
| `owner`      | optional - team/handle responsible for the surface.                                                                 |

### Severity normalization

| Tool                | Tool severity                  | Normalized |
|---------------------|--------------------------------|-----------:|
| Pact                | `can-i-deploy` exit `1`        | `blocker`  |
| oasdiff             | `ERR`                          | `blocker`  |
| oasdiff             | `WARN`                         | `warn`     |
| oasdiff             | `INFO`                         | `info`     |
| graphql-inspector   | `Breaking`                     | `blocker`  |
| graphql-inspector   | `Dangerous`                    | `warn`     |
| graphql-inspector   | `Non-breaking`                 | `info`     |
| buf breaking        | any rule under active category | `blocker`  |

`buf breaking` doesn't classify by severity - every violation is
treated as a blocker. To downgrade, exclude rules in `buf.yaml` rather
than at the gate level (see
[`protobuf-compat-checking`](../protobuf-compat-checking/SKILL.md)).

## Step 3 - Apply the gate decision rule

Pseudocode:

```python
def gate_decision(records, *, allow_warn=True):
    blockers = [
        r for r in records
        if r["severity"] == "blocker" and not r.get("ratchet", False)
    ]
    warnings = [r for r in records if r["severity"] == "warn"]
    return {
        "verdict": "no-go" if blockers else "go",
        "blocker_count": len(blockers),
        "warning_count": len(warnings),
        "blockers": blockers,
        "warnings": warnings,
    }
```

Default behavior is **strict-but-warn-tolerant**: any non-ratcheted
blocker fails the gate; warnings show up in the report but don't
block. For stricter projects, set `allow_warn=False`.

## Step 4 - Emit the artifact

Markdown summary suitable for `$GITHUB_STEP_SUMMARY`:

```markdown
# Contract Compatibility Gate - verdict: NO-GO

**Blockers: 2**

| Tool      | Protocol | Finding                          | Subject                | Message                                       |
|-----------|----------|----------------------------------|------------------------|-----------------------------------------------|
| oasdiff   | openapi  | response-success-status-removed  | GET /pets              | success response status '200' was removed     |
| buf       | protobuf | FIELD_NO_DELETE                  | orders/v1.Order.amount | Field "amount" was deleted (active in v1).    |

**Warnings: 1**

| Tool              | Protocol | Finding              | Subject               | Message                              |
|-------------------|----------|----------------------|-----------------------|--------------------------------------|
| graphql-inspector | graphql  | Dangerous            | Pet.legacyTag (added union member) | adding a union member could break enums |
```

Plus a JSON sibling for downstream consumers:

```json
{
  "verdict": "no-go",
  "blocker_count": 2,
  "warning_count": 1,
  "blockers": [...],
  "warnings": [...]
}
```

A no-go verdict exits non-zero so CI halts.

## Worked example: minimal Python implementation

```python
# scripts/run_contract_gate.py
import json, subprocess, sys
from pathlib import Path

records = []

# 1. Pact: trust can-i-deploy's exit code
pact_proc = subprocess.run(
    ["pact-broker", "can-i-deploy",
     "--pacticipant", "web-app",
     "--version", os.environ["GITHUB_SHA"],
     "--to-environment", "production"],
    capture_output=True, text=True,
)
if pact_proc.returncode != 0:
    records.append({
        "tool": "pact", "protocol": "pact",
        "finding": "can-i-deploy-no",
        "severity": "blocker",
        "subject": "web-app -> production",
        "message": pact_proc.stdout.strip().splitlines()[-1] if pact_proc.stdout else "no",
    })

# 2. oasdiff
if Path("oasdiff.json").exists():
    for f in json.loads(Path("oasdiff.json").read_text()):
        sev_map = {3: "blocker", 2: "warn", 1: "info"}
        records.append({
            "tool": "oasdiff", "protocol": "openapi",
            "finding": f["id"],
            "severity": sev_map.get(f["level"], "info"),
            "subject": f"{f['operation']} {f['path']}",
            "message": f["text"],
        })

# 3. graphql-inspector  (parsed from text or JSON action)
# 4. buf breaking      (--error-format json)
# ... (omitted for brevity; same shape)

# Apply gate
blockers = [r for r in records if r["severity"] == "blocker"]
verdict = "no-go" if blockers else "go"

print(f"# Contract Compatibility Gate - verdict: {verdict.upper()}")
for r in blockers:
    print(f"- {r['tool']} :: {r['subject']} :: {r['finding']} ({r['message']})")

sys.exit(0 if verdict == "go" else 1)
```

Wire into CI **after** every protocol step has produced its artifact:

```yaml
- run: pact-broker can-i-deploy ... || true       # don't fail; let gate decide
- run: oasdiff breaking ... --format json > oasdiff.json || true
- run: graphql-inspector diff ... --json > gqi.json || true
- run: buf breaking ... --error-format json > buf.json || true

- name: Contract compatibility gate
  run: python scripts/run_contract_gate.py
```

The `|| true` lets each tool emit its artifact even on failure; the
final gate is the single source of CI truth.

## References

- [`pact-contract-testing`](../pact-contract-testing/SKILL.md)
- [`openapi-contract-diff`](../openapi-contract-diff/SKILL.md)
- [`graphql-schema-regression`](../graphql-schema-regression/SKILL.md)
- [`protobuf-compat-checking`](../protobuf-compat-checking/SKILL.md)
- [can-i-deploy][cid] - Pact deployment gate exit codes.
- [oasdiff-breaking][oasdiff] - oasdiff severity tiers.
- [gqi-diff][gqi] - GraphQL Inspector breaking classification.
- [buf-breaking][buf] - buf breaking-change category model.
- `data-quality-gate` and `visual-baseline-gate` - sibling gate skills with the same artifact shape.