208 lines
7.8 KiB
YAML
208 lines
7.8 KiB
YAML
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
|