diff --git a/.github/workflows/api-breaking-changes.yaml b/.github/workflows/api-breaking-changes.yaml
new file mode 100644
index 000000000..dcd07fe4f
--- /dev/null
+++ b/.github/workflows/api-breaking-changes.yaml
@@ -0,0 +1,191 @@
+# Generate an API changelog by diffing the OpenAPI schema between base and HEAD.
+name: API Breaking Changes Check
+
+on:
+ pull_request:
+ branches: ["main"]
+ # Re-run when the approval label is added/removed so merge gating updates immediately.
+ types: [opened, synchronize, reopened, labeled, unlabeled]
+
+permissions:
+ contents: read
+ pull-requests: write
+
+ # One run per PR. A new commit (or label toggle) cancels the in-flight run instead
+concurrency:
+ group: api-changes-pr-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ breaking-changes:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - name: Checkout PR head
+ uses: actions/checkout@v4
+ with:
+ path: head
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Checkout base ref
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.base_ref }}
+ path: base
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v3
+
+ - name: Prepare env files
+ run: |
+ cp head/.env.test.example head/.env.test
+ cp head/.env.test.example base/.env.test
+
+ - name: Export base OpenAPI schema
+ working-directory: base/backend
+ env:
+ ENVIRONMENT: testing
+ run: |
+ uv sync --frozen
+ if [ -f scripts/kaapi_export_openapi.py ]; then
+ uv run python scripts/kaapi_export_openapi.py "$GITHUB_WORKSPACE/base-openapi.yaml"
+ else
+ # Base ref predates this workflow; copy the HEAD script in to bootstrap.
+ cp "$GITHUB_WORKSPACE/head/backend/scripts/kaapi_export_openapi.py" scripts/
+ uv run python scripts/kaapi_export_openapi.py "$GITHUB_WORKSPACE/base-openapi.yaml"
+ fi
+
+ - name: Export HEAD OpenAPI schema
+ working-directory: head/backend
+ env:
+ ENVIRONMENT: testing
+ run: |
+ uv sync --frozen
+ uv run python scripts/kaapi_export_openapi.py "$GITHUB_WORKSPACE/head-openapi.yaml"
+
+ - name: Generate diff (JSON)
+ env:
+ OASDIFF_IMAGE: tufin/oasdiff:latest
+ run: |
+ set -euo pipefail
+
+ run_diff() {
+ local cmd="$1" out="$2"
+ docker run --rm -v "$PWD:/specs" "$OASDIFF_IMAGE" \
+ "$cmd" /specs/base-openapi.yaml /specs/head-openapi.yaml \
+ --format json > "$out" || true
+ if ! jq -e . "$out" >/dev/null 2>&1; then
+ echo "[]" > "$out"
+ fi
+ }
+ run_diff changelog changelog.json
+ run_diff breaking breaking.json
+
+ echo "breaking=$(jq 'length' breaking.json) changelog=$(jq 'length' changelog.json)"
+
+ - name: Debug raw oasdiff output
+ run: |
+ echo "=== breaking.json ==="
+ cat breaking.json || echo "(missing)"
+ echo
+ echo "=== changelog.json ==="
+ cat changelog.json || echo "(missing)"
+
+ - name: Build PR comment body
+ if: always()
+ run: |
+ plural() { if [ "$1" -ne 1 ]; then echo s; fi; }
+
+ # Render an oasdiff JSON array as a markdown table.
+ # Severity dot: red only for breaking (ERR), green for everything else
+ # (WARN / INFO โ non-breaking additions like optional params).
+ to_table() {
+ jq -r '
+ def dot(lvl):
+ if lvl >= 2 then "๐ด"
+ else "๐ข" end;
+ if (. // []) | length == 0 then "_No entries._"
+ else
+ "| | Method | Path | Change |",
+ "|:-:|:-:|:--|:--|",
+ (.[] | "| \(dot(.level)) | `\(.operation // "โ")` | `\(.path // "โ")` | \((.text // "") | gsub("[\r\n]+"; " ")) |")
+ end
+ ' "$1"
+ }
+
+ BREAKING=$(jq 'length' breaking.json)
+ CHANGES=$(jq 'length' changelog.json)
+
+ if [ "$BREAKING" -gt 0 ]; then
+ STATUS="๐ด **${BREAKING} breaking change$(plural $BREAKING)**"
+ ALERT='> [!CAUTION]'
+ HEADLINE="Downstream consumers may need an update before merging."
+ BREAKING_OPEN=" open"
+ elif [ "$CHANGES" -gt 0 ]; then
+ STATUS="๐ข **${CHANGES} non-breaking change$(plural $CHANGES)**"
+ ALERT='> [!TIP]'
+ HEADLINE="Safe to merge from an API-contract perspective."
+ BREAKING_OPEN=""
+ else
+ STATUS="โช **No API surface changes**"
+ ALERT='> [!NOTE]'
+ HEADLINE="This PR does not modify the API contract."
+ BREAKING_OPEN=""
+ fi
+
+ {
+ echo "## OpenAPI changes ${STATUS}"
+ echo
+ echo "${ALERT}"
+ echo "> ${HEADLINE}"
+ echo
+
+ if [ "$BREAKING" -gt 0 ]; then
+ echo ""
+ echo "Breaking changes ยท ${BREAKING}
"
+ echo
+ to_table breaking.json
+ echo
+ echo " "
+ echo
+ fi
+
+ if [ "$CHANGES" -gt 0 ]; then
+ echo ""
+ echo "Full changelog ยท ${CHANGES}
"
+ echo
+ to_table changelog.json
+ echo
+ echo " "
+ echo
+ fi
+
+ echo "${GITHUB_BASE_REF} โ ${GITHUB_SHA::8} ยท generated by oasdiff"
+ } > comment.md
+ cat comment.md >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Post sticky PR comment
+ uses: marocchino/sticky-pull-request-comment@v3
+ with:
+ header: oasdiff
+ path: comment.md
+
+ - name: Enforce breaking-change gate
+ # Skip the hard fail when a reviewer has explicitly acknowledged the breaking change
+ # by applying the `breaking-change-approved` label to the PR.
+ if: ${{ !contains(github.event.pull_request.labels.*.name, 'breaking-change-approved') }}
+ run: |
+ set -euo pipefail
+ count=$(jq 'length' breaking.json)
+ if [ "$count" -gt 0 ]; then
+ echo "::error::${count} breaking change(s) detected. Apply the 'breaking-change-approved' label to override the gate."
+ exit 1
+ fi
+ echo "No breaking changes detected."
diff --git a/backend/scripts/kaapi_export_openapi.py b/backend/scripts/kaapi_export_openapi.py
new file mode 100644
index 000000000..838deae9a
--- /dev/null
+++ b/backend/scripts/kaapi_export_openapi.py
@@ -0,0 +1,23 @@
+"""Export the FastAPI app's OpenAPI schema to a YAML file.
+
+Usage:
+ uv run python -m scripts.kaapi_export_openapi [output_path]
+"""
+
+import sys
+from pathlib import Path
+
+import yaml
+
+from app.main import app
+
+
+def main() -> None:
+ output = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("openapi.yaml")
+ schema = app.openapi()
+ output.write_text(yaml.safe_dump(schema, sort_keys=False))
+ print(f"[export_openapi] Wrote {output} ({output.stat().st_size} bytes)")
+
+
+if __name__ == "__main__":
+ main()