Files
KArtSell.Aegis/.gitea/workflows/openapi-gate.yml
T
kjh2064 3f293d8aa8
deploy / deploy (push) Successful in 1m52s
deploy / notify (push) Successful in 1s
V13-FE-006: consolidate approved UI and contract hardening
2026-08-13 02:41:00 +09:00

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