Skip to content

Commit 4887c15

Browse files
committed
FINERACT-2081: add api verification workflow
1 parent 93f5292 commit 4887c15

File tree

6 files changed

+439
-1
lines changed

6 files changed

+439
-1
lines changed
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
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+
./gradlew :fineract-provider:checkBreakingChanges \
80+
-PapiBaseline="${GITHUB_WORKSPACE}/baseline/fineract-provider/build/resources/main/static/fineract.json" \
81+
--no-daemon 2>&1 | tail -50
82+
83+
- name: Build report
84+
if: steps.breaking-check.outcome == 'failure'
85+
id: report
86+
run: |
87+
REPORT_DIR="current/fineract-provider/build/swagger-brake"
88+
89+
python3 -c "
90+
import json, glob, os
91+
from collections import defaultdict
92+
93+
RULE_DESC = {
94+
'R001': 'Standard API changed to beta',
95+
'R002': 'Path deleted',
96+
'R003': 'Request media type deleted',
97+
'R004': 'Request parameter deleted',
98+
'R005': 'Request parameter enum value deleted',
99+
'R006': 'Request parameter location changed',
100+
'R007': 'Request parameter made required',
101+
'R008': 'Request parameter type changed',
102+
'R009': 'Request attribute removed',
103+
'R010': 'Request type changed',
104+
'R011': 'Request enum value deleted',
105+
'R012': 'Response code deleted',
106+
'R013': 'Response media type deleted',
107+
'R014': 'Response attribute removed',
108+
'R015': 'Response type changed',
109+
'R016': 'Response enum value deleted',
110+
'R017': 'Request parameter constraint changed',
111+
}
112+
113+
report_dir = '${REPORT_DIR}'
114+
files = sorted(glob.glob(os.path.join(report_dir, '*.json')))
115+
if not files:
116+
body = 'Breaking change detected but no report file found.'
117+
else:
118+
with open(files[0]) as f:
119+
data = json.load(f)
120+
121+
all_changes = []
122+
for items in data.get('breakingChanges', {}).values():
123+
all_changes.extend(items)
124+
125+
if not all_changes:
126+
body = 'Breaking change detected but no details available in report.'
127+
else:
128+
def detail(c):
129+
for key in ('attributeName', 'attribute', 'name', 'mediaType', 'enumValue', 'code'):
130+
v = c.get(key)
131+
if v:
132+
val = v.rsplit('.', 1)[-1]
133+
if key in ('attributeName', 'attribute', 'name'):
134+
return val
135+
return f'{key}={val}'
136+
return '-'
137+
138+
groups = defaultdict(list)
139+
for c in all_changes:
140+
groups[(c.get('ruleCode', '?'), detail(c))].append(c)
141+
142+
lines = []
143+
lines.append('| Rule | Description | Detail | Affected endpoints | Count |')
144+
lines.append('|------|-------------|--------|--------------------|-------|')
145+
for (rule, det), items in sorted(groups.items()):
146+
desc = RULE_DESC.get(rule, '')
147+
eps = sorted(set(
148+
f'{c.get(\"method\",\"\")} {c.get(\"path\",\"\")}'
149+
for c in items if c.get('path')
150+
))
151+
ep_str = ', '.join(f'\`{e}\`' for e in eps[:5])
152+
if len(eps) > 5:
153+
ep_str += f' +{len(eps)-5} more'
154+
lines.append(f'| {rule} | {desc} | \`{det}\` | {ep_str} | {len(items)} |')
155+
156+
lines.append('')
157+
lines.append(f'**Total: {len(all_changes)} violations across {len(groups)} unique changes**')
158+
body = '\n'.join(lines)
159+
160+
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
161+
f.write('has_report=true\n')
162+
163+
report_file = '${GITHUB_WORKSPACE}/breaking-changes-report.md'
164+
with open(report_file, 'w') as f:
165+
f.write('## Breaking API Changes Detected\n\n')
166+
f.write(body)
167+
f.write('\n\n> **Note:** This check is informational only and does not block the PR.\n')
168+
169+
# Also write to step summary
170+
with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as f:
171+
f.write('## Breaking API Changes Detected\n\n')
172+
f.write(body)
173+
f.write('\n\n> **Note:** This check is informational only and does not block the PR.\n')
174+
"
175+
176+
- name: Comment on PR
177+
if: always()
178+
continue-on-error: true
179+
env:
180+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
181+
PR_NUMBER: ${{ github.event.pull_request.number }}
182+
run: |
183+
MARKER="<!-- swagger-brake-report -->"
184+
185+
# Find existing comment by marker
186+
COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \
187+
--jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" | head -1)
188+
189+
if [ "${{ steps.breaking-check.outcome }}" == "failure" ] && [ -f "${GITHUB_WORKSPACE}/breaking-changes-report.md" ]; then
190+
# Prepend marker to the report
191+
BODY="${MARKER}
192+
$(cat ${GITHUB_WORKSPACE}/breaking-changes-report.md)"
193+
194+
if [ -n "$COMMENT_ID" ]; then
195+
gh api "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \
196+
-X PATCH -f body="${BODY}"
197+
else
198+
gh pr comment "${PR_NUMBER}" --repo ${{ github.repository }} --body "${BODY}"
199+
fi
200+
elif [ -n "$COMMENT_ID" ]; then
201+
# No breaking changes anymore, delete the old comment
202+
gh api "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" -X DELETE
203+
fi
204+
205+
- name: Report no breaking changes
206+
if: steps.breaking-check.outcome == 'success'
207+
run: |
208+
echo "## No Breaking API Changes Detected" >> $GITHUB_STEP_SUMMARY
209+
echo "" >> $GITHUB_STEP_SUMMARY
210+
echo "The API contract is backward compatible." >> $GITHUB_STEP_SUMMARY
211+
212+
- name: Archive breaking change report
213+
if: always()
214+
uses: actions/upload-artifact@v4
215+
with:
216+
name: api-compatibility-report
217+
path: current/fineract-provider/build/swagger-brake/
218+
retention-days: 30
219+
220+
- name: Fail if breaking changes detected
221+
if: steps.breaking-check.outcome == 'failure'
222+
run: |
223+
echo "::error::Breaking API changes detected. See the report above for details."
224+
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)