Skip to content

Commit 2047553

Browse files
budaidevadamsaghy
authored andcommitted
FINERACT-2081: add api verification workflow
1 parent 4a02c26 commit 2047553

File tree

6 files changed

+440
-1
lines changed

6 files changed

+440
-1
lines changed
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
name: Verify API Backward Compatibility
2+
3+
on: [pull_request]
4+
5+
permissions:
6+
contents: read
7+
pull-requests: write
8+
9+
jobs:
10+
api-compatibility-check:
11+
runs-on: ubuntu-24.04
12+
timeout-minutes: 15
13+
14+
env:
15+
TZ: Asia/Kolkata
16+
17+
steps:
18+
- name: Checkout base branch
19+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
20+
with:
21+
repository: ${{ github.event.pull_request.base.repo.full_name }}
22+
ref: ${{ github.event.pull_request.base.ref }}
23+
fetch-depth: 0
24+
path: baseline
25+
26+
- name: Set up JDK 21
27+
uses: actions/setup-java@v5
28+
with:
29+
distribution: 'zulu'
30+
java-version: '21'
31+
32+
- name: Generate baseline spec
33+
working-directory: baseline
34+
run: ./gradlew :fineract-provider:resolve --no-daemon
35+
36+
- name: Checkout PR branch
37+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
38+
with:
39+
repository: ${{ github.event.pull_request.head.repo.full_name }}
40+
ref: ${{ github.event.pull_request.head.sha }}
41+
fetch-depth: 0
42+
path: current
43+
44+
- name: Generate PR spec
45+
working-directory: current
46+
run: ./gradlew :fineract-provider:resolve --no-daemon
47+
48+
- name: Sanitize specs
49+
run: |
50+
python3 -c "
51+
import json, sys
52+
53+
def sanitize(path):
54+
with open(path) as f:
55+
spec = json.load(f)
56+
fixed = 0
57+
for path_item in spec.get('paths', {}).values():
58+
for op in path_item.values():
59+
if not isinstance(op, dict) or 'requestBody' not in op:
60+
continue
61+
for media in op['requestBody'].get('content', {}).values():
62+
if 'schema' not in media:
63+
media['schema'] = {'type': 'object'}
64+
fixed += 1
65+
if fixed:
66+
with open(path, 'w') as f:
67+
json.dump(spec, f)
68+
print(f'{path}: fixed {fixed} entries')
69+
70+
sanitize('${GITHUB_WORKSPACE}/baseline/fineract-provider/build/resources/main/static/fineract.json')
71+
sanitize('${GITHUB_WORKSPACE}/current/fineract-provider/build/resources/main/static/fineract.json')
72+
"
73+
74+
- name: Check breaking changes
75+
id: breaking-check
76+
continue-on-error: true
77+
working-directory: current
78+
run: |
79+
set -o pipefail
80+
./gradlew :fineract-provider:checkBreakingChanges \
81+
-PapiBaseline="${GITHUB_WORKSPACE}/baseline/fineract-provider/build/resources/main/static/fineract.json" \
82+
--no-daemon --quiet 2>&1 | tail -50
83+
84+
- name: Build report
85+
if: steps.breaking-check.outcome == 'failure'
86+
id: report
87+
run: |
88+
REPORT_DIR="current/fineract-provider/build/swagger-brake"
89+
90+
python3 -c "
91+
import json, glob, os
92+
from collections import defaultdict
93+
94+
RULE_DESC = {
95+
'R001': 'Standard API changed to beta',
96+
'R002': 'Path deleted',
97+
'R003': 'Request media type deleted',
98+
'R004': 'Request parameter deleted',
99+
'R005': 'Request parameter enum value deleted',
100+
'R006': 'Request parameter location changed',
101+
'R007': 'Request parameter made required',
102+
'R008': 'Request parameter type changed',
103+
'R009': 'Request attribute removed',
104+
'R010': 'Request type changed',
105+
'R011': 'Request enum value deleted',
106+
'R012': 'Response code deleted',
107+
'R013': 'Response media type deleted',
108+
'R014': 'Response attribute removed',
109+
'R015': 'Response type changed',
110+
'R016': 'Response enum value deleted',
111+
'R017': 'Request parameter constraint changed',
112+
}
113+
114+
report_dir = '${REPORT_DIR}'
115+
files = sorted(glob.glob(os.path.join(report_dir, '*.json')))
116+
if not files:
117+
body = 'Breaking change detected but no report file found.'
118+
else:
119+
with open(files[0]) as f:
120+
data = json.load(f)
121+
122+
all_changes = []
123+
for items in data.get('breakingChanges', {}).values():
124+
all_changes.extend(items)
125+
126+
if not all_changes:
127+
body = 'Breaking change detected but no details available in report.'
128+
else:
129+
def detail(c):
130+
for key in ('attributeName', 'attribute', 'name', 'mediaType', 'enumValue', 'code'):
131+
v = c.get(key)
132+
if v:
133+
val = v.rsplit('.', 1)[-1]
134+
if key in ('attributeName', 'attribute', 'name'):
135+
return val
136+
return f'{key}={val}'
137+
return '-'
138+
139+
groups = defaultdict(list)
140+
for c in all_changes:
141+
groups[(c.get('ruleCode', '?'), detail(c))].append(c)
142+
143+
lines = []
144+
lines.append('| Rule | Description | Detail | Affected endpoints | Count |')
145+
lines.append('|------|-------------|--------|--------------------|-------|')
146+
for (rule, det), items in sorted(groups.items()):
147+
desc = RULE_DESC.get(rule, '')
148+
eps = sorted(set(
149+
f'{c.get(\"method\",\"\")} {c.get(\"path\",\"\")}'
150+
for c in items if c.get('path')
151+
))
152+
ep_str = ', '.join(f'\`{e}\`' for e in eps[:5])
153+
if len(eps) > 5:
154+
ep_str += f' +{len(eps)-5} more'
155+
lines.append(f'| {rule} | {desc} | \`{det}\` | {ep_str} | {len(items)} |')
156+
157+
lines.append('')
158+
lines.append(f'**Total: {len(all_changes)} violations across {len(groups)} unique changes**')
159+
body = '\n'.join(lines)
160+
161+
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
162+
f.write('has_report=true\n')
163+
164+
report_file = '${GITHUB_WORKSPACE}/breaking-changes-report.md'
165+
with open(report_file, 'w') as f:
166+
f.write('## Breaking API Changes Detected\n\n')
167+
f.write(body)
168+
f.write('\n\n> **Note:** This check is informational only and does not block the PR.\n')
169+
170+
# Also write to step summary
171+
with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as f:
172+
f.write('## Breaking API Changes Detected\n\n')
173+
f.write(body)
174+
f.write('\n\n> **Note:** This check is informational only and does not block the PR.\n')
175+
"
176+
177+
- name: Comment on PR
178+
if: always()
179+
continue-on-error: true
180+
env:
181+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
182+
PR_NUMBER: ${{ github.event.pull_request.number }}
183+
run: |
184+
MARKER="<!-- swagger-brake-report -->"
185+
186+
# Find existing comment by marker
187+
COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \
188+
--jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" | head -1)
189+
190+
if [ "${{ steps.breaking-check.outcome }}" == "failure" ] && [ -f "${GITHUB_WORKSPACE}/breaking-changes-report.md" ]; then
191+
# Prepend marker to the report
192+
BODY="${MARKER}
193+
$(cat ${GITHUB_WORKSPACE}/breaking-changes-report.md)"
194+
195+
if [ -n "$COMMENT_ID" ]; then
196+
gh api "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \
197+
-X PATCH -f body="${BODY}"
198+
else
199+
gh pr comment "${PR_NUMBER}" --repo ${{ github.repository }} --body "${BODY}"
200+
fi
201+
elif [ -n "$COMMENT_ID" ]; then
202+
# No breaking changes anymore, delete the old comment
203+
gh api "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" -X DELETE
204+
fi
205+
206+
- name: Report no breaking changes
207+
if: steps.breaking-check.outcome == 'success'
208+
run: |
209+
echo "## No Breaking API Changes Detected" >> $GITHUB_STEP_SUMMARY
210+
echo "" >> $GITHUB_STEP_SUMMARY
211+
echo "The API contract is backward compatible." >> $GITHUB_STEP_SUMMARY
212+
213+
- name: Archive breaking change report
214+
if: always()
215+
uses: actions/upload-artifact@v4
216+
with:
217+
name: api-compatibility-report
218+
path: current/fineract-provider/build/swagger-brake/
219+
retention-days: 30
220+
221+
- name: Fail if breaking changes detected
222+
if: steps.breaking-check.outcome == 'failure'
223+
run: |
224+
echo "::error::Breaking API changes detected. See the report above for details."
225+
exit 1

build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ plugins {
125125
id 'com.gradleup.shadow' version '8.3.5' apply false
126126
id 'me.champeau.jmh' version '0.7.1' apply false
127127
id 'org.cyclonedx.bom' version '3.1.0' apply false
128+
id 'com.docktape.swagger-brake' version '2.7.0' apply false
128129
}
129130

130131
apply from: "${rootDir}/buildSrc/src/main/groovy/org.apache.fineract.release.gradle"

0 commit comments

Comments
 (0)