Skip to content

Commit e2f9ec3

Browse files
authored
Merge pull request #12 from BitGo/generate-releases
Added workflow to generate releases
2 parents 98bdc9f + 6a07b0b commit e2f9ec3

File tree

18 files changed

+1061
-0
lines changed

18 files changed

+1061
-0
lines changed
File renamed without changes.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
name: Generate Release
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
paths:
8+
- 'api.yaml'
9+
10+
jobs:
11+
generate-release:
12+
runs-on: ubuntu-latest
13+
permissions:
14+
contents: write
15+
pull-requests: read
16+
17+
steps:
18+
- uses: actions/checkout@v4
19+
with:
20+
fetch-depth: 0
21+
22+
- name: Configure Node.js
23+
uses: actions/setup-node@v4
24+
with:
25+
node-version-file: .nvmrc
26+
27+
- name: Get API specs and generate JSON files
28+
run: |
29+
PREVIOUS_MERGE=$(git rev-list --merges main | head -n 2 | tail -n 1)
30+
git show $PREVIOUS_MERGE:api.yaml > previous.yaml || echo "v0.0.0" > previous.yaml
31+
yq -o=json previous.yaml > previous.json
32+
33+
yq -o=json api.yaml > current.json
34+
rm previous.yaml
35+
36+
- name: Run API diff
37+
run: node scripts/api-diff.js
38+
39+
- name: Determine version
40+
id: version
41+
run: |
42+
# Get current year, month, and day
43+
YEAR=$(date +%Y)
44+
MONTH=$(date +%m)
45+
DAY=$(date +%d)
46+
47+
# Get the latest tag for current year.month.day
48+
CURRENT_VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "v$YEAR.$MONTH.$DAY.0")
49+
echo "Current version: $CURRENT_VERSION"
50+
51+
# Extract version number
52+
if [[ $CURRENT_VERSION == v$YEAR.$MONTH.$DAY.* ]]; then
53+
# If we already have a tag for today, increment its number
54+
VERSION_NUM=$(echo $CURRENT_VERSION | cut -d. -f4)
55+
NEW_VERSION="v$YEAR.$MONTH.$DAY.$((VERSION_NUM+1))"
56+
else
57+
# If this is the first tag for today, start at .1
58+
NEW_VERSION="v$YEAR.$MONTH.$DAY.1"
59+
fi
60+
61+
echo "New version: $NEW_VERSION"
62+
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
63+
64+
- name: Add version to release description
65+
run: |
66+
{
67+
echo "# BitGo API Release ${{ steps.version.outputs.new_version }}"
68+
cat release-description.md
69+
} > final-release-description.md
70+
71+
- name: Create GitHub Release
72+
env:
73+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
74+
run: |
75+
gh release create ${{ steps.version.outputs.new_version }} \
76+
--title "${{ steps.version.outputs.new_version }}" \
77+
--notes-file final-release-description.md

.github/workflows/pr.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: Pull Request
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- scripts/api-diff.js
7+
- tests/**
8+
9+
jobs:
10+
test-api-diff:
11+
name: Run API Diff Tests
12+
runs-on: ubuntu-latest
13+
14+
steps:
15+
- uses: actions/checkout@v4
16+
17+
- name: Use Node.js
18+
uses: actions/setup-node@v4
19+
with:
20+
node-version-file: '.nvmrc'
21+
22+
- name: Run tests
23+
run: node --test

.nvmrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
v20.10.0

scripts/api-diff.js

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
const fs = require('fs');
2+
3+
// Read the JSON files
4+
const previousSpec = JSON.parse(fs.readFileSync('previous.json', 'utf8'));
5+
const currentSpec = JSON.parse(fs.readFileSync('current.json', 'utf8'));
6+
7+
// Initialize change tracking
8+
const changes = {
9+
added: {}, // Group by path
10+
removed: {}, // Group by path
11+
modified: {}, // Group by path
12+
components: new Set(), // Track changed components
13+
affectedByComponents: {} // Track paths affected by component changes
14+
};
15+
16+
// Helper function to track component references
17+
function findComponentRefs(obj, components) {
18+
if (!obj) return;
19+
if (typeof obj === 'object') {
20+
if (obj['$ref'] && obj['$ref'].startsWith('#/components/')) {
21+
components.add(obj['$ref'].split('/').pop());
22+
}
23+
Object.values(obj).forEach(value => findComponentRefs(value, components));
24+
}
25+
}
26+
27+
// Compare components first
28+
function compareComponents() {
29+
const prevComps = previousSpec.components || {};
30+
const currComps = currentSpec.components || {};
31+
32+
for (const [category, components] of Object.entries(currComps)) {
33+
for (const [name, def] of Object.entries(components)) {
34+
if (!prevComps[category]?.[name] ||
35+
JSON.stringify(prevComps[category][name]) !== JSON.stringify(def)) {
36+
changes.components.add(name);
37+
}
38+
}
39+
}
40+
}
41+
42+
// Find paths affected by component changes
43+
function findAffectedPaths() {
44+
if (changes.components.size === 0) return;
45+
46+
Object.entries(currentSpec.paths || {}).forEach(([path, methods]) => {
47+
const affectedMethods = [];
48+
Object.entries(methods).forEach(([method, details]) => {
49+
const usedComponents = new Set();
50+
findComponentRefs(details, usedComponents);
51+
52+
for (const comp of usedComponents) {
53+
if (changes.components.has(comp)) {
54+
affectedMethods.push(method.toUpperCase());
55+
if (!changes.affectedByComponents[path]) {
56+
changes.affectedByComponents[path] = {
57+
methods: new Set(),
58+
components: new Set()
59+
};
60+
}
61+
changes.affectedByComponents[path].methods.add(method.toUpperCase());
62+
changes.affectedByComponents[path].components.add(comp);
63+
}
64+
}
65+
});
66+
});
67+
}
68+
69+
// Compare paths and methods
70+
function comparePaths() {
71+
// Check for added and modified endpoints
72+
Object.entries(currentSpec.paths || {}).forEach(([path, methods]) => {
73+
const previousMethods = previousSpec.paths?.[path] || {};
74+
75+
Object.entries(methods).forEach(([method, details]) => {
76+
if (!previousMethods[method]) {
77+
if (!changes.added[path]) changes.added[path] = new Set();
78+
changes.added[path].add(method.toUpperCase());
79+
} else if (JSON.stringify(previousMethods[method]) !== JSON.stringify(details)) {
80+
if (!changes.modified[path]) changes.modified[path] = [];
81+
changes.modified[path].push({
82+
method: method.toUpperCase(),
83+
changes: getChanges(previousMethods[method], details)
84+
});
85+
}
86+
});
87+
});
88+
89+
// Check for removed endpoints
90+
Object.entries(previousSpec.paths || {}).forEach(([path, methods]) => {
91+
Object.keys(methods).forEach(method => {
92+
if (!currentSpec.paths?.[path]?.[method]) {
93+
if (!changes.removed[path]) changes.removed[path] = new Set();
94+
changes.removed[path].add(method.toUpperCase());
95+
}
96+
});
97+
});
98+
}
99+
100+
function getChanges(previous, current) {
101+
const changes = [];
102+
const fields = ['summary', 'description', 'operationId', 'parameters', 'requestBody', 'responses'];
103+
104+
fields.forEach(field => {
105+
if (JSON.stringify(previous[field]) !== JSON.stringify(current[field])) {
106+
changes.push(field);
107+
}
108+
});
109+
110+
return changes;
111+
}
112+
113+
// Helper function to detect where a component is used in an endpoint
114+
function findComponentUsage(details, componentName) {
115+
const usage = [];
116+
117+
// Check parameters
118+
if (details.parameters) {
119+
const hasComponent = details.parameters.some(p =>
120+
(p.$ref && p.$ref.includes(componentName)) ||
121+
(p.schema && p.schema.$ref && p.schema.$ref.includes(componentName))
122+
);
123+
if (hasComponent) usage.push('parameters');
124+
}
125+
126+
// Check requestBody
127+
if (details.requestBody &&
128+
details.requestBody.content &&
129+
Object.values(details.requestBody.content).some(c =>
130+
c.schema && c.schema.$ref && c.schema.$ref.includes(componentName))) {
131+
usage.push('requestBody');
132+
}
133+
134+
// Check responses
135+
if (details.responses &&
136+
Object.values(details.responses).some(r =>
137+
r.content && Object.values(r.content).some(c =>
138+
c.schema && c.schema.$ref && c.schema.$ref.includes(componentName)))) {
139+
usage.push('responses');
140+
}
141+
142+
return usage;
143+
}
144+
145+
// Generate markdown release notes
146+
function generateReleaseNotes() {
147+
let releaseDescription = '';
148+
149+
150+
const sections = [];
151+
152+
// Added endpoints
153+
if (Object.keys(changes.added).length > 0) {
154+
let section = '## Added\n';
155+
Object.entries(changes.added)
156+
.sort(([a], [b]) => a.localeCompare(b))
157+
.forEach(([path, methods]) => {
158+
section += `- [${Array.from(methods).sort().join('] [')}] \`${path}\`\n`;
159+
});
160+
sections.push(section);
161+
}
162+
163+
// Modified endpoints
164+
if (Object.keys(changes.modified).length > 0 || Object.keys(changes.affectedByComponents).length > 0) {
165+
let section = '## Modified\n';
166+
167+
// Combine and sort all modified paths
168+
const allModifiedPaths = new Set([
169+
...Object.keys(changes.modified),
170+
...Object.keys(changes.affectedByComponents)
171+
]);
172+
173+
Array.from(allModifiedPaths)
174+
.sort()
175+
.forEach(path => {
176+
// Handle both direct modifications and component changes for each path
177+
const methodsToProcess = new Set();
178+
179+
// Collect all affected methods
180+
if (changes.modified[path]) {
181+
changes.modified[path].forEach(({method}) => methodsToProcess.add(method));
182+
}
183+
if (changes.affectedByComponents[path]) {
184+
changes.affectedByComponents[path].methods.forEach(method => methodsToProcess.add(method));
185+
}
186+
187+
// Process each method
188+
Array.from(methodsToProcess)
189+
.sort()
190+
.forEach(method => {
191+
section += `- [${method}] \`${path}\`\n`;
192+
193+
// Add direct changes
194+
const directChanges = changes.modified[path]?.find(m => m.method === method);
195+
if (directChanges) {
196+
directChanges.changes.sort().forEach(change => {
197+
section += ` - ${change}\n`;
198+
});
199+
}
200+
201+
// Add component changes
202+
if (changes.affectedByComponents[path]?.methods.has(method)) {
203+
const methodDetails = currentSpec.paths[path][method.toLowerCase()];
204+
Array.from(changes.affectedByComponents[path].components)
205+
.sort()
206+
.forEach(component => {
207+
const usageLocations = findComponentUsage(methodDetails, component).sort();
208+
section += ` - \`${component}\` modified in ${usageLocations.join(', ')}\n`;
209+
});
210+
}
211+
});
212+
});
213+
sections.push(section);
214+
}
215+
216+
// Removed endpoints
217+
if (Object.keys(changes.removed).length > 0) {
218+
let section = '## Removed\n';
219+
Object.entries(changes.removed)
220+
.sort(([a], [b]) => a.localeCompare(b))
221+
.forEach(([path, methods]) => {
222+
section += `- [${Array.from(methods).sort().join('] [')}] \`${path}\`\n`;
223+
});
224+
sections.push(section);
225+
}
226+
227+
228+
// Sort sections alphabetically and combine
229+
sections.sort((a, b) => {
230+
const titleA = a.split('\n')[0];
231+
const titleB = b.split('\n')[0];
232+
return titleA.localeCompare(titleB);
233+
});
234+
235+
releaseDescription += sections.join('\n');
236+
237+
return releaseDescription;
238+
}
239+
240+
// Main execution
241+
compareComponents();
242+
findAffectedPaths();
243+
comparePaths();
244+
const releaseDescription = generateReleaseNotes();
245+
246+
// Write release notes to markdown file
247+
fs.writeFileSync('release-description.md', releaseDescription);

0 commit comments

Comments
 (0)