| name: Verify API Backward Compatibility |
| |
| on: |
| workflow_call: |
| secrets: |
| DEVELOCITY_ACCESS_KEY: |
| required: false |
| |
| permissions: |
| contents: read |
| |
| jobs: |
| api-compatibility-check: |
| if: ${{ github.event_name == 'pull_request' }} |
| runs-on: ubuntu-24.04 |
| timeout-minutes: 60 |
| |
| env: |
| TZ: Asia/Kolkata |
| |
| steps: |
| - name: Checkout base branch for baseline |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 |
| with: |
| persist-credentials: false |
| repository: ${{ github.event.pull_request.base.repo.full_name }} |
| ref: ${{ github.event.pull_request.base.ref }} |
| fetch-depth: 0 |
| path: baseline |
| |
| - name: Checkout base branch for merged PR |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 |
| with: |
| persist-credentials: false |
| repository: ${{ github.event.pull_request.base.repo.full_name }} |
| ref: ${{ github.event.pull_request.base.ref }} |
| fetch-depth: 0 |
| path: current |
| |
| - name: Merge PR into current base |
| working-directory: current |
| env: |
| PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} |
| PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} |
| run: | |
| git config user.name "github-actions" |
| git config user.email "github-actions@github.com" |
| |
| case "$PR_HEAD_SHA" in |
| ""|*[!0-9a-fA-F]*) |
| echo "::error::Invalid pull request head SHA: $PR_HEAD_SHA" |
| exit 1 |
| ;; |
| esac |
| |
| git fetch "https://github.com/${PR_HEAD_REPO}.git" "$PR_HEAD_SHA" --no-tags |
| |
| git merge --no-commit --no-ff FETCH_HEAD |
| |
| - name: Set up JDK 21 |
| uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 |
| with: |
| distribution: 'zulu' |
| java-version: '21' |
| |
| - name: Generate baseline spec |
| working-directory: baseline |
| run: ./gradlew :fineract-provider:resolve --no-daemon |
| |
| - name: Generate merged PR spec |
| working-directory: current |
| run: ./gradlew :fineract-provider:resolve --no-daemon |
| |
| - name: Sanitize specs |
| run: | |
| python3 -c " |
| import json |
| |
| def sanitize(path): |
| with open(path) as f: |
| spec = json.load(f) |
| fixed = 0 |
| for path_item in spec.get('paths', {}).values(): |
| for op in path_item.values(): |
| if not isinstance(op, dict) or 'requestBody' not in op: |
| continue |
| for media in op['requestBody'].get('content', {}).values(): |
| if 'schema' not in media: |
| media['schema'] = {'type': 'object'} |
| fixed += 1 |
| if fixed: |
| with open(path, 'w') as f: |
| json.dump(spec, f) |
| print(f'{path}: fixed {fixed} entries') |
| |
| sanitize('${GITHUB_WORKSPACE}/baseline/fineract-provider/build/resources/main/static/fineract.json') |
| sanitize('${GITHUB_WORKSPACE}/current/fineract-provider/build/resources/main/static/fineract.json') |
| " |
| |
| - name: Check breaking changes |
| id: breaking-check |
| continue-on-error: true |
| working-directory: current |
| run: | |
| set -o pipefail |
| ./gradlew --no-daemon :fineract-provider:checkBreakingChanges \ |
| -PapiBaseline="${GITHUB_WORKSPACE}/baseline/fineract-provider/build/resources/main/static/fineract.json" \ |
| -PapiNew="${GITHUB_WORKSPACE}/current/fineract-provider/build/resources/main/static/fineract.json" \ |
| -x buildJavaSdk \ |
| -x :fineract-client:buildJavaSdk \ |
| -x :fineract-client-feign:buildJavaSdk \ |
| -x :fineract-avro-schemas:buildJavaSdk |
| |
| - name: Build report |
| if: steps.breaking-check.outcome == 'failure' |
| id: report |
| run: | |
| REPORT_DIR="current/fineract-provider/build/swagger-brake" |
| |
| python3 -c " |
| import json, glob, os |
| from collections import defaultdict |
| |
| RULE_DESC = { |
| 'R001': 'Standard API changed to beta', |
| 'R002': 'Path deleted', |
| 'R003': 'Request media type deleted', |
| 'R004': 'Request parameter deleted', |
| 'R005': 'Request parameter enum value deleted', |
| 'R006': 'Request parameter location changed', |
| 'R007': 'Request parameter made required', |
| 'R008': 'Request parameter type changed', |
| 'R009': 'Request attribute removed', |
| 'R010': 'Request type changed', |
| 'R011': 'Request enum value deleted', |
| 'R012': 'Response code deleted', |
| 'R013': 'Response media type deleted', |
| 'R014': 'Response attribute removed', |
| 'R015': 'Response type changed', |
| 'R016': 'Response enum value deleted', |
| 'R017': 'Request parameter constraint changed', |
| } |
| |
| report_dir = '${REPORT_DIR}' |
| files = sorted(glob.glob(os.path.join(report_dir, '*.json'))) |
| if not files: |
| body = 'Breaking change detected but no report file found.' |
| else: |
| with open(files[0]) as f: |
| data = json.load(f) |
| |
| all_changes = [] |
| for items in data.get('breakingChanges', {}).values(): |
| all_changes.extend(items) |
| |
| if not all_changes: |
| body = 'Breaking change detected but no details available in report.' |
| else: |
| def detail(c): |
| for key in ('attributeName', 'attribute', 'name', 'mediaType', 'enumValue', 'code'): |
| v = c.get(key) |
| if v: |
| val = v.rsplit('.', 1)[-1] |
| if key in ('attributeName', 'attribute', 'name'): |
| return val |
| return f'{key}={val}' |
| return '-' |
| |
| groups = defaultdict(list) |
| for c in all_changes: |
| groups[(c.get('ruleCode', '?'), detail(c))].append(c) |
| |
| lines = [] |
| lines.append('| Rule | Description | Detail | Affected endpoints | Count |') |
| lines.append('|------|-------------|--------|--------------------|-------|') |
| for (rule, det), items in sorted(groups.items()): |
| desc = RULE_DESC.get(rule, '') |
| eps = sorted(set( |
| f'{c.get(\"method\", \"\")} {c.get(\"path\", \"\")}' |
| for c in items if c.get('path') |
| )) |
| ep_str = ', '.join(f'\`{e}\`' for e in eps[:5]) |
| if len(eps) > 5: |
| ep_str += f' +{len(eps)-5} more' |
| lines.append(f'| {rule} | {desc} | \`{det}\` | {ep_str} | {len(items)} |') |
| |
| lines.append('') |
| lines.append(f'**Total: {len(all_changes)} violations across {len(groups)} unique changes**') |
| body = '\n'.join(lines) |
| |
| with open(os.environ['GITHUB_OUTPUT'], 'a') as f: |
| f.write('has_report=true\n') |
| |
| report_file = '${GITHUB_WORKSPACE}/breaking-changes-report.md' |
| with open(report_file, 'w') as f: |
| f.write('## Breaking API Changes Detected\n\n') |
| f.write(body) |
| f.write('\n\n> **Note:** This check is informational only and does not block the PR.\n') |
| |
| with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as f: |
| f.write('## Breaking API Changes Detected\n\n') |
| f.write(body) |
| f.write('\n\n> **Note:** This check is informational only and does not block the PR.\n') |
| " |
| |
| - name: Report no breaking changes |
| if: steps.breaking-check.outcome == 'success' |
| run: | |
| echo "## No Breaking API Changes Detected" >> $GITHUB_STEP_SUMMARY |
| echo "" >> $GITHUB_STEP_SUMMARY |
| echo "The API contract is backward compatible." >> $GITHUB_STEP_SUMMARY |
| |
| - name: Archive breaking change report |
| if: failure() |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 |
| with: |
| name: api-compatibility-report |
| path: current/fineract-provider/build/swagger-brake/ |
| retention-days: 5 |
| compression-level: 9 |
| |
| - name: Fail if breaking changes detected |
| if: always() |
| env: |
| BREAKING_CHECK_OUTCOME: ${{ steps.breaking-check.outcome }} |
| run: | |
| if [ "$BREAKING_CHECK_OUTCOME" == "failure" ]; then |
| echo "::error::Breaking API changes detected. See the report above for details." |
| exit 1 |
| fi |