Skip to content

Commit 1e8dc5c

Browse files
committed
FINERACT-2081: add api verification workflow
1 parent 47710e1 commit 1e8dc5c

File tree

6 files changed

+420
-1
lines changed

6 files changed

+420
-1
lines changed
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
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 --quiet > /dev/null 2>&1
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: steps.breaking-check.outcome == 'failure' && steps.report.outputs.has_report == 'true'
178+
continue-on-error: true
179+
env:
180+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
181+
run: |
182+
gh pr comment ${{ github.event.pull_request.number }} \
183+
--repo ${{ github.repository }} \
184+
--body-file "${GITHUB_WORKSPACE}/breaking-changes-report.md"
185+
186+
- name: Report no breaking changes
187+
if: steps.breaking-check.outcome == 'success'
188+
run: |
189+
echo "## No Breaking API Changes Detected" >> $GITHUB_STEP_SUMMARY
190+
echo "" >> $GITHUB_STEP_SUMMARY
191+
echo "The API contract is backward compatible." >> $GITHUB_STEP_SUMMARY
192+
193+
- name: Archive breaking change report
194+
if: always()
195+
uses: actions/upload-artifact@v4
196+
with:
197+
name: api-compatibility-report
198+
path: current/fineract-provider/build/swagger-brake/
199+
retention-days: 30
200+
201+
- name: Fail if breaking changes detected
202+
if: steps.breaking-check.outcome == 'failure'
203+
run: |
204+
echo "::error::Breaking API changes detected. See the report above for details."
205+
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)