-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add script and workflow to generate releases
- Loading branch information
1 parent
0b78810
commit 8438610
Showing
4 changed files
with
325 additions
and
0 deletions.
There are no files selected for viewing
File renamed without changes.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
name: Generate Release | ||
|
||
on: | ||
push: | ||
branches: | ||
- main | ||
paths: | ||
- 'api.yaml' | ||
|
||
jobs: | ||
generate-release: | ||
runs-on: ubuntu-latest | ||
permissions: | ||
contents: write | ||
pull-requests: read | ||
|
||
steps: | ||
- uses: actions/checkout@v4 | ||
with: | ||
fetch-depth: 0 | ||
|
||
- name: Configure Node.js | ||
uses: actions/setup-node@v4 | ||
with: | ||
node-version-file: .nvmrc | ||
|
||
- name: Get API specs and generate JSON files | ||
run: | | ||
PREVIOUS_MERGE=$(git rev-list --merges main | head -n 2 | tail -n 1) | ||
git show $PREVIOUS_MERGE:api.yaml > previous.yaml || echo "v0.0.0" > previous.yaml | ||
yq -o=json previous.yaml > previous.json | ||
yq -o=json api.yaml > current.json | ||
rm previous.yaml | ||
- name: Run API diff | ||
run: node scripts/api-diff.js | ||
|
||
- name: Determine version | ||
id: version | ||
run: | | ||
# Get current year, month, and day | ||
YEAR=$(date +%Y) | ||
MONTH=$(date +%m) | ||
DAY=$(date +%d) | ||
# Get the latest tag for current year.month.day | ||
CURRENT_VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "v$YEAR.$MONTH.$DAY.0") | ||
echo "Current version: $CURRENT_VERSION" | ||
# Extract version number | ||
if [[ $CURRENT_VERSION == v$YEAR.$MONTH.$DAY.* ]]; then | ||
# If we already have a tag for today, increment its number | ||
VERSION_NUM=$(echo $CURRENT_VERSION | cut -d. -f4) | ||
NEW_VERSION="v$YEAR.$MONTH.$DAY.$((VERSION_NUM+1))" | ||
else | ||
# If this is the first tag for today, start at .1 | ||
NEW_VERSION="v$YEAR.$MONTH.$DAY.1" | ||
fi | ||
echo "New version: $NEW_VERSION" | ||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT | ||
- name: Add version to release description | ||
run: | | ||
{ | ||
echo "# BitGo API Release ${{ steps.version.outputs.new_version }}" | ||
cat release-description.md | ||
} > final-release-description.md | ||
- name: Create GitHub Release | ||
env: | ||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
run: | | ||
gh release create ${{ steps.version.outputs.new_version }} \ | ||
--title "${{ steps.version.outputs.new_version }}" \ | ||
--notes-file final-release-description.md |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
v18.19.0 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,247 @@ | ||
const fs = require('fs'); | ||
|
||
// Read the JSON files | ||
const previousSpec = JSON.parse(fs.readFileSync('previous.json', 'utf8')); | ||
const currentSpec = JSON.parse(fs.readFileSync('current.json', 'utf8')); | ||
|
||
// Initialize change tracking | ||
const changes = { | ||
added: {}, // Group by path | ||
removed: {}, // Group by path | ||
modified: {}, // Group by path | ||
components: new Set(), // Track changed components | ||
affectedByComponents: {} // Track paths affected by component changes | ||
}; | ||
|
||
// Helper function to track component references | ||
function findComponentRefs(obj, components) { | ||
if (!obj) return; | ||
if (typeof obj === 'object') { | ||
if (obj['$ref'] && obj['$ref'].startsWith('#/components/')) { | ||
components.add(obj['$ref'].split('/').pop()); | ||
} | ||
Object.values(obj).forEach(value => findComponentRefs(value, components)); | ||
} | ||
} | ||
|
||
// Compare components first | ||
function compareComponents() { | ||
const prevComps = previousSpec.components || {}; | ||
const currComps = currentSpec.components || {}; | ||
|
||
for (const [category, components] of Object.entries(currComps)) { | ||
for (const [name, def] of Object.entries(components)) { | ||
if (!prevComps[category]?.[name] || | ||
JSON.stringify(prevComps[category][name]) !== JSON.stringify(def)) { | ||
changes.components.add(name); | ||
} | ||
} | ||
} | ||
} | ||
|
||
// Find paths affected by component changes | ||
function findAffectedPaths() { | ||
if (changes.components.size === 0) return; | ||
|
||
Object.entries(currentSpec.paths || {}).forEach(([path, methods]) => { | ||
const affectedMethods = []; | ||
Object.entries(methods).forEach(([method, details]) => { | ||
const usedComponents = new Set(); | ||
findComponentRefs(details, usedComponents); | ||
|
||
for (const comp of usedComponents) { | ||
if (changes.components.has(comp)) { | ||
affectedMethods.push(method.toUpperCase()); | ||
if (!changes.affectedByComponents[path]) { | ||
changes.affectedByComponents[path] = { | ||
methods: new Set(), | ||
components: new Set() | ||
}; | ||
} | ||
changes.affectedByComponents[path].methods.add(method.toUpperCase()); | ||
changes.affectedByComponents[path].components.add(comp); | ||
} | ||
} | ||
}); | ||
}); | ||
} | ||
|
||
// Compare paths and methods | ||
function comparePaths() { | ||
// Check for added and modified endpoints | ||
Object.entries(currentSpec.paths || {}).forEach(([path, methods]) => { | ||
const previousMethods = previousSpec.paths?.[path] || {}; | ||
|
||
Object.entries(methods).forEach(([method, details]) => { | ||
if (!previousMethods[method]) { | ||
if (!changes.added[path]) changes.added[path] = new Set(); | ||
changes.added[path].add(method.toUpperCase()); | ||
} else if (JSON.stringify(previousMethods[method]) !== JSON.stringify(details)) { | ||
if (!changes.modified[path]) changes.modified[path] = []; | ||
changes.modified[path].push({ | ||
method: method.toUpperCase(), | ||
changes: getChanges(previousMethods[method], details) | ||
}); | ||
} | ||
}); | ||
}); | ||
|
||
// Check for removed endpoints | ||
Object.entries(previousSpec.paths || {}).forEach(([path, methods]) => { | ||
Object.keys(methods).forEach(method => { | ||
if (!currentSpec.paths?.[path]?.[method]) { | ||
if (!changes.removed[path]) changes.removed[path] = new Set(); | ||
changes.removed[path].add(method.toUpperCase()); | ||
} | ||
}); | ||
}); | ||
} | ||
|
||
function getChanges(previous, current) { | ||
const changes = []; | ||
const fields = ['summary', 'description', 'operationId', 'parameters', 'requestBody', 'responses']; | ||
|
||
fields.forEach(field => { | ||
if (JSON.stringify(previous[field]) !== JSON.stringify(current[field])) { | ||
changes.push(field); | ||
} | ||
}); | ||
|
||
return changes; | ||
} | ||
|
||
// Helper function to detect where a component is used in an endpoint | ||
function findComponentUsage(details, componentName) { | ||
const usage = []; | ||
|
||
// Check parameters | ||
if (details.parameters) { | ||
const hasComponent = details.parameters.some(p => | ||
(p.$ref && p.$ref.includes(componentName)) || | ||
(p.schema && p.schema.$ref && p.schema.$ref.includes(componentName)) | ||
); | ||
if (hasComponent) usage.push('parameters'); | ||
} | ||
|
||
// Check requestBody | ||
if (details.requestBody && | ||
details.requestBody.content && | ||
Object.values(details.requestBody.content).some(c => | ||
c.schema && c.schema.$ref && c.schema.$ref.includes(componentName))) { | ||
usage.push('requestBody'); | ||
} | ||
|
||
// Check responses | ||
if (details.responses && | ||
Object.values(details.responses).some(r => | ||
r.content && Object.values(r.content).some(c => | ||
c.schema && c.schema.$ref && c.schema.$ref.includes(componentName)))) { | ||
usage.push('responses'); | ||
} | ||
|
||
return usage; | ||
} | ||
|
||
// Generate markdown release notes | ||
function generateReleaseNotes() { | ||
let releaseDescription = ''; | ||
|
||
|
||
const sections = []; | ||
|
||
// Added endpoints | ||
if (Object.keys(changes.added).length > 0) { | ||
let section = '## Added\n'; | ||
Object.entries(changes.added) | ||
.sort(([a], [b]) => a.localeCompare(b)) | ||
.forEach(([path, methods]) => { | ||
section += `- [${Array.from(methods).sort().join('] [')}] \`${path}\`\n`; | ||
}); | ||
sections.push(section); | ||
} | ||
|
||
// Modified endpoints | ||
if (Object.keys(changes.modified).length > 0 || Object.keys(changes.affectedByComponents).length > 0) { | ||
let section = '## Modified\n'; | ||
|
||
// Combine and sort all modified paths | ||
const allModifiedPaths = new Set([ | ||
...Object.keys(changes.modified), | ||
...Object.keys(changes.affectedByComponents) | ||
]); | ||
|
||
Array.from(allModifiedPaths) | ||
.sort() | ||
.forEach(path => { | ||
// Handle both direct modifications and component changes for each path | ||
const methodsToProcess = new Set(); | ||
|
||
// Collect all affected methods | ||
if (changes.modified[path]) { | ||
changes.modified[path].forEach(({method}) => methodsToProcess.add(method)); | ||
} | ||
if (changes.affectedByComponents[path]) { | ||
changes.affectedByComponents[path].methods.forEach(method => methodsToProcess.add(method)); | ||
} | ||
|
||
// Process each method | ||
Array.from(methodsToProcess) | ||
.sort() | ||
.forEach(method => { | ||
section += `- [${method}] \`${path}\`\n`; | ||
|
||
// Add direct changes | ||
const directChanges = changes.modified[path]?.find(m => m.method === method); | ||
if (directChanges) { | ||
directChanges.changes.sort().forEach(change => { | ||
section += ` - ${change}\n`; | ||
}); | ||
} | ||
|
||
// Add component changes | ||
if (changes.affectedByComponents[path]?.methods.has(method)) { | ||
const methodDetails = currentSpec.paths[path][method.toLowerCase()]; | ||
Array.from(changes.affectedByComponents[path].components) | ||
.sort() | ||
.forEach(component => { | ||
const usageLocations = findComponentUsage(methodDetails, component).sort(); | ||
section += ` - \`${component}\` modified in ${usageLocations.join(', ')}\n`; | ||
}); | ||
} | ||
}); | ||
}); | ||
sections.push(section); | ||
} | ||
|
||
// Removed endpoints | ||
if (Object.keys(changes.removed).length > 0) { | ||
let section = '## Removed\n'; | ||
Object.entries(changes.removed) | ||
.sort(([a], [b]) => a.localeCompare(b)) | ||
.forEach(([path, methods]) => { | ||
section += `- [${Array.from(methods).sort().join('] [')}] \`${path}\`\n`; | ||
}); | ||
sections.push(section); | ||
} | ||
|
||
|
||
// Sort sections alphabetically and combine | ||
sections.sort((a, b) => { | ||
const titleA = a.split('\n')[0]; | ||
const titleB = b.split('\n')[0]; | ||
return titleA.localeCompare(titleB); | ||
}); | ||
|
||
releaseDescription += sections.join('\n'); | ||
|
||
return releaseDescription; | ||
} | ||
|
||
// Main execution | ||
compareComponents(); | ||
findAffectedPaths(); | ||
comparePaths(); | ||
const releaseDescription = generateReleaseNotes(); | ||
|
||
// Write release notes to markdown file | ||
fs.writeFileSync('release-description.md', releaseDescription); |