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 || true - name: Checkout main branch run: | git fetch origin main:main git checkout main - name: Build main branch run: | dotnet restore dotnet build -c Release --no-restore - name: Generate baseline OpenAPI spec run: | dotnet run --project src/KArtSell.Host -c Release -- \ --generate-openapi-spec-only \ --output /tmp/openapi/baseline.json || true - 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** This PR introduces breaking changes to the API contract: - Required parameters removed - Response fields removed - Status codes removed **Action Required:** 1. Modify your changes to be backward-compatible, OR 2. Request approval from @api-architects with justification Breaking change approval requires: - [x] Documented rationale (why breaking is necessary) - [x] Migration plan for existing clients - [x] Version bump (major version for breaking changes)` }) - 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** Your 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: Update Committed OpenAPI Specs (if merged) 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 OpenAPI spec run: | mkdir -p docs/api dotnet run --project src/KArtSell.Host -c Release -- \ --generate-openapi-spec-only \ --output docs/api/openapi.json - name: Commit updated spec run: | git config user.email "ci@example.com" git config user.name "CI Bot" if ! git diff --quiet docs/api/openapi.json; then git add docs/api/openapi.json git commit -m "ci: Update OpenAPI specification (auto-generated)" git push fi