From 8438610067045c00d1bcfbdb64f93839a5b89a6f Mon Sep 17 00:00:00 2001 From: Benson Shen Date: Mon, 16 Dec 2024 17:32:04 -0500 Subject: [PATCH] feat: add script and workflow to generate releases --- CODEOWNERS => .github/CODEOWNERS | 0 .github/workflows/generate-release.yml | 77 ++++++++ .nvmrc | 1 + scripts/api-diff.js | 247 +++++++++++++++++++++++++ 4 files changed, 325 insertions(+) rename CODEOWNERS => .github/CODEOWNERS (100%) create mode 100644 .github/workflows/generate-release.yml create mode 100644 .nvmrc create mode 100644 scripts/api-diff.js diff --git a/CODEOWNERS b/.github/CODEOWNERS similarity index 100% rename from CODEOWNERS rename to .github/CODEOWNERS diff --git a/.github/workflows/generate-release.yml b/.github/workflows/generate-release.yml new file mode 100644 index 0000000..6be62a0 --- /dev/null +++ b/.github/workflows/generate-release.yml @@ -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 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..eb800ed --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v18.19.0 diff --git a/scripts/api-diff.js b/scripts/api-diff.js new file mode 100644 index 0000000..d8247df --- /dev/null +++ b/scripts/api-diff.js @@ -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);