name: OpenAPI Gate - Breaking Change Detection on: pull_request: branches: - main paths: - 'src/KArtSell.Host/Features/**/*.cs' - 'src/KArtSell.Modules.*/**/*.cs' - '.gitea/workflows/openapi-gate.yml' jobs: openapi-diff: name: Detect Breaking Changes in OpenAPI Spec runs-on: ubuntu-latest steps: - name: Checkout PR branch uses: actions/checkout@v3 with: fetch-depth: 0 - name: Setup .NET uses: actions/setup-dotnet@v3 with: dotnet-version: '10.x' - name: Restore dependencies run: dotnet restore - name: Build solution run: dotnet build -c Release --no-restore - name: Generate current OpenAPI spec run: | mkdir -p /tmp/openapi dotnet run --project src/KArtSell.Host -c Release -- \ --generate-openapi-spec-only \ --output /tmp/openapi/current.json test -s /tmp/openapi/current.json - name: Checkout main branch run: | git fetch origin main:main git checkout main - name: Load approved baseline OpenAPI spec run: | test -s docs/api/openapi.json || { echo "Approved baseline missing: docs/api/openapi.json" echo "Create and approve the baseline before enabling OpenAPI diff comparisons." exit 1 } cp docs/api/openapi.json /tmp/openapi/baseline.json test -s /tmp/openapi/baseline.json - name: Checkout PR branch again run: git checkout - - name: Analyze OpenAPI diff run: | # Compare specs and detect breaking changes python3 << 'EOF' import json import sys def load_spec(path): try: with open(path) as f: return json.load(f) except: return {} baseline = load_spec('/tmp/openapi/baseline.json') current = load_spec('/tmp/openapi/current.json') breaking_changes = [] # Check 1: Required parameter removed for path, baseline_ops in baseline.get('paths', {}).items(): for method, baseline_op in baseline_ops.items(): if isinstance(baseline_op, dict): baseline_params = {p['name']: p.get('required', False) for p in baseline_op.get('parameters', [])} current_ops = current.get('paths', {}).get(path, {}) current_op = current_ops.get(method, {}) current_params = {p['name']: p.get('required', False) for p in current_op.get('parameters', [])} for param_name, was_required in baseline_params.items(): if was_required and param_name not in current_params: breaking_changes.append( f"BREAKING: Required parameter '{param_name}' removed from {method.upper()} {path}" ) # Check 2: Response status code removed for path, baseline_ops in baseline.get('paths', {}).items(): for method, baseline_op in baseline_ops.items(): if isinstance(baseline_op, dict): baseline_statuses = set(baseline_op.get('responses', {}).keys()) current_ops = current.get('paths', {}).get(path, {}) current_op = current_ops.get(method, {}) current_statuses = set(current_op.get('responses', {}).keys()) for status in ['200', '201', '202', '204']: if status in baseline_statuses and status not in current_statuses: breaking_changes.append( f"BREAKING: Response status {status} removed from {method.upper()} {path}" ) # Check 3: Required field removed from response for path, baseline_ops in baseline.get('paths', {}).items(): for method, baseline_op in baseline_ops.items(): if isinstance(baseline_op, dict): baseline_schema = baseline_op.get('responses', {}).get('200', {}).get('schema', {}) required_fields = set(baseline_schema.get('required', [])) current_ops = current.get('paths', {}).get(path, {}) current_op = current_ops.get(method, {}) current_schema = current_op.get('responses', {}).get('200', {}).get('schema', {}) current_fields = set(current_schema.get('properties', {}).keys()) for field in required_fields: if field not in current_fields: breaking_changes.append( f"BREAKING: Required field '{field}' removed from response of {method.upper()} {path}" ) if breaking_changes: print("❌ BREAKING CHANGES DETECTED:\n") for change in breaking_changes: print(f" - {change}") print("\n⛔ WORKFLOW HALTED: Cannot merge without approval\n") sys.exit(1) else: print("✅ No breaking changes detected in OpenAPI spec") sys.exit(0) EOF - name: Comment on PR (Breaking Changes) if: failure() uses: actions/github-script@v6 with: script: | github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: '⛔ **OpenAPI Gate Failed: Breaking Changes Detected**\n\nThis PR introduces breaking changes to the API contract. Required parameters, response fields, or status codes were removed. Modify the changes for backward compatibility or request API Architect approval with rationale, migration plan, and version bump.' }) - name: Comment on PR (All Clear) if: success() uses: actions/github-script@v6 with: script: | github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: '✅ **OpenAPI Gate Passed: No Breaking Changes**\n\nYour API changes are backward-compatible. Safe to merge.' }) openapi-approval: name: Manual Approval Gate (if breaking changes) if: failure() needs: openapi-diff runs-on: ubuntu-latest steps: - name: Require manual approval run: | echo "❌ Breaking changes detected. Waiting for @api-architects approval..." echo "GitHub PR Review required from 'api-architects' team before merging." exit 1 openapi-specs-update: name: Publish OpenAPI Candidate Artifact (manual approval required) if: success() needs: openapi-diff runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v3 - name: Setup .NET uses: actions/setup-dotnet@v3 with: dotnet-version: '10.x' - name: Generate candidate OpenAPI spec run: | mkdir -p /tmp/openapi dotnet run --project src/KArtSell.Host -c Release -- \ --generate-openapi-spec-only \ --output /tmp/openapi/candidate.json test -s /tmp/openapi/candidate.json - name: Upload candidate for API Architect review uses: actions/upload-artifact@v4 with: name: openapi-candidate path: /tmp/openapi/candidate.json if-no-files-found: error