Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added workflow to generate releases #12

Merged
merged 2 commits into from
Dec 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
File renamed without changes.
77 changes: 77 additions & 0 deletions .github/workflows/generate-release.yml
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
23 changes: 23 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Pull Request

on:
pull_request:
paths:
- scripts/api-diff.js
- tests/**

jobs:
test-api-diff:
name: Run API Diff Tests
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'

- name: Run tests
run: node --test
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
v20.10.0
247 changes: 247 additions & 0 deletions scripts/api-diff.js
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);
Loading
Loading