[feat] Add automated API changelog generation workflow

Implements a GitHub Actions workflow that automatically generates API
changelogs by comparing TypeScript type definitions between versions.

Changes:
- Add release-api-changelogs.yaml workflow triggered after npm types release
- Create snapshot-api.js script to extract API surface from TypeScript defs
- Create compare-api-snapshots.js to generate human-readable changelogs
- Initialize docs/API-CHANGELOG.md to track public API changes

The workflow:
1. Triggers after Release NPM Types workflow completes
2. Builds and snapshots current and previous API surfaces
3. Compares snapshots to detect additions, removals, and modifications
4. Generates formatted changelog with breaking changes highlighted
5. Creates draft PR for review before merging

This automates documentation of breaking changes for extension developers
without manual effort, supporting the large extension ecosystem.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
snomiao
2025-10-23 03:03:11 +00:00
parent f63d0f3289
commit ff60bdf1bc
4 changed files with 863 additions and 0 deletions

View File

@@ -0,0 +1,418 @@
#!/usr/bin/env node
/**
* Compares two API snapshots and generates a human-readable changelog
* documenting additions, removals, and modifications to the public API.
*/
import * as fs from 'fs'
const args = process.argv.slice(2)
if (args.length < 4) {
console.error(
'Usage: compare-api-snapshots.js <previous.json> <current.json> <previous-version> <current-version>'
)
process.exit(1)
}
const [previousPath, currentPath, previousVersion, currentVersion] = args
if (!fs.existsSync(previousPath)) {
console.error(`Previous snapshot not found: ${previousPath}`)
process.exit(1)
}
if (!fs.existsSync(currentPath)) {
console.error(`Current snapshot not found: ${currentPath}`)
process.exit(1)
}
const previousApi = JSON.parse(fs.readFileSync(previousPath, 'utf-8'))
const currentApi = JSON.parse(fs.readFileSync(currentPath, 'utf-8'))
/**
* Compare two API snapshots and generate changelog
*/
function compareApis(previous, current) {
const changes = {
breaking: [],
additions: [],
modifications: [],
deprecations: []
}
const categories = [
'types',
'interfaces',
'enums',
'functions',
'classes',
'constants'
]
for (const category of categories) {
const prevItems = previous[category] || {}
const currItems = current[category] || {}
// Find additions
for (const name in currItems) {
if (!prevItems[name]) {
changes.additions.push({
category,
name,
item: currItems[name]
})
}
}
// Find removals and modifications
for (const name in prevItems) {
if (!currItems[name]) {
changes.breaking.push({
category,
name,
type: 'removed',
item: prevItems[name]
})
} else {
// Check for modifications
const diff = compareItems(prevItems[name], currItems[name], category)
if (diff.length > 0) {
changes.modifications.push({
category,
name,
changes: diff
})
}
}
}
}
return changes
}
/**
* Compare two items and return differences
*/
function compareItems(prev, curr, category) {
const differences = []
if (category === 'interfaces' || category === 'classes') {
// Compare members
const prevMembers = new Map(prev.members?.map((m) => [m.name, m]) || [])
const currMembers = new Map(curr.members?.map((m) => [m.name, m]) || [])
// Find added members
for (const [name, member] of currMembers) {
if (!prevMembers.has(name)) {
differences.push({
type: 'member_added',
name,
member
})
}
}
// Find removed members
for (const [name, member] of prevMembers) {
if (!currMembers.has(name)) {
differences.push({
type: 'member_removed',
name,
member
})
} else {
// Check if member type changed
const prevMember = prevMembers.get(name)
const currMember = currMembers.get(name)
if (prevMember.type !== currMember.type) {
differences.push({
type: 'member_type_changed',
name,
from: prevMember.type,
to: currMember.type
})
}
if (prevMember.optional !== currMember.optional) {
differences.push({
type: 'member_optionality_changed',
name,
from: prevMember.optional ? 'optional' : 'required',
to: currMember.optional ? 'optional' : 'required'
})
}
}
}
// Compare methods (for classes and interfaces)
if (category === 'classes') {
const prevMethods = new Map(prev.methods?.map((m) => [m.name, m]) || [])
const currMethods = new Map(curr.methods?.map((m) => [m.name, m]) || [])
for (const [name, method] of currMethods) {
if (!prevMethods.has(name)) {
differences.push({
type: 'method_added',
name,
method
})
}
}
for (const [name, method] of prevMethods) {
if (!currMethods.has(name)) {
differences.push({
type: 'method_removed',
name,
method
})
} else {
const prevMethod = prevMethods.get(name)
const currMethod = currMethods.get(name)
if (prevMethod.returnType !== currMethod.returnType) {
differences.push({
type: 'method_return_type_changed',
name,
from: prevMethod.returnType,
to: currMethod.returnType
})
}
// Compare parameters
if (
JSON.stringify(prevMethod.parameters) !==
JSON.stringify(currMethod.parameters)
) {
differences.push({
type: 'method_signature_changed',
name,
from: prevMethod.parameters,
to: currMethod.parameters
})
}
}
}
}
} else if (category === 'functions') {
// Compare function signatures
if (prev.returnType !== curr.returnType) {
differences.push({
type: 'return_type_changed',
from: prev.returnType,
to: curr.returnType
})
}
if (
JSON.stringify(prev.parameters) !== JSON.stringify(curr.parameters)
) {
differences.push({
type: 'parameters_changed',
from: prev.parameters,
to: curr.parameters
})
}
} else if (category === 'enums') {
// Compare enum members
const prevMembers = new Set(prev.members?.map((m) => m.name) || [])
const currMembers = new Set(curr.members?.map((m) => m.name) || [])
for (const member of currMembers) {
if (!prevMembers.has(member)) {
differences.push({
type: 'enum_member_added',
name: member
})
}
}
for (const member of prevMembers) {
if (!currMembers.has(member)) {
differences.push({
type: 'enum_member_removed',
name: member
})
}
}
}
return differences
}
/**
* Format changelog as markdown
*/
function formatChangelog(changes, prevVersion, currVersion) {
const lines = []
lines.push(`## v${currVersion} (${new Date().toISOString().split('T')[0]})`)
lines.push('')
lines.push(
`Comparing v${prevVersion} → v${currVersion}. This changelog documents changes to the public API surface that third-party extensions and custom nodes depend on.`
)
lines.push('')
// Breaking changes
if (changes.breaking.length > 0) {
lines.push('### ⚠️ Breaking Changes')
lines.push('')
const grouped = groupByCategory(changes.breaking)
for (const [category, items] of Object.entries(grouped)) {
lines.push(`**${categoryToTitle(category)}**`)
lines.push('')
for (const item of items) {
lines.push(`- **Removed**: \`${item.name}\``)
}
lines.push('')
}
}
// Additions
if (changes.additions.length > 0) {
lines.push('### ✨ Additions')
lines.push('')
const grouped = groupByCategory(changes.additions)
for (const [category, items] of Object.entries(grouped)) {
lines.push(`**${categoryToTitle(category)}**`)
lines.push('')
for (const item of items) {
lines.push(`- \`${item.name}\``)
if (item.item.members && item.item.members.length > 0) {
const publicMembers = item.item.members.filter(
(m) => !m.visibility || m.visibility === 'public'
)
if (publicMembers.length > 0 && publicMembers.length <= 5) {
lines.push(
` - Members: ${publicMembers.map((m) => `\`${m.name}\``).join(', ')}`
)
}
}
}
lines.push('')
}
}
// Modifications
if (changes.modifications.length > 0) {
lines.push('### 🔄 Modifications')
lines.push('')
const hasBreakingMods = changes.modifications.some((mod) =>
mod.changes.some((c) => isBreakingChange(c))
)
if (hasBreakingMods) {
lines.push('> **Note**: Some modifications may be breaking changes.')
lines.push('')
}
const grouped = groupByCategory(changes.modifications)
for (const [category, items] of Object.entries(grouped)) {
lines.push(`**${categoryToTitle(category)}**`)
lines.push('')
for (const item of items) {
lines.push(`- \`${item.name}\``)
for (const change of item.changes) {
const formatted = formatChange(change)
if (formatted) {
lines.push(` ${formatted}`)
}
}
}
lines.push('')
}
}
if (
changes.breaking.length === 0 &&
changes.additions.length === 0 &&
changes.modifications.length === 0
) {
lines.push('_No API changes detected._')
lines.push('')
}
lines.push('---')
lines.push('')
return lines.join('\n')
}
function groupByCategory(items) {
const grouped = {}
for (const item of items) {
if (!grouped[item.category]) {
grouped[item.category] = []
}
grouped[item.category].push(item)
}
return grouped
}
function categoryToTitle(category) {
const titles = {
types: 'Type Aliases',
interfaces: 'Interfaces',
enums: 'Enums',
functions: 'Functions',
classes: 'Classes',
constants: 'Constants'
}
return titles[category] || category
}
function isBreakingChange(change) {
const breakingTypes = [
'member_removed',
'method_removed',
'member_type_changed',
'method_return_type_changed',
'method_signature_changed',
'return_type_changed',
'parameters_changed',
'enum_member_removed'
]
return breakingTypes.includes(change.type)
}
function formatChange(change) {
switch (change.type) {
case 'member_added':
return `- ✨ Added member: \`${change.name}\``
case 'member_removed':
return `- ⚠️ **Breaking**: Removed member: \`${change.name}\``
case 'member_type_changed':
return `- ⚠️ **Breaking**: Member \`${change.name}\` type changed: \`${change.from}\`\`${change.to}\``
case 'member_optionality_changed':
return `- ${change.to === 'required' ? '⚠️ **Breaking**' : '✨'}: Member \`${change.name}\` is now ${change.to}`
case 'method_added':
return `- ✨ Added method: \`${change.name}()\``
case 'method_removed':
return `- ⚠️ **Breaking**: Removed method: \`${change.name}()\``
case 'method_return_type_changed':
return `- ⚠️ **Breaking**: Method \`${change.name}()\` return type changed: \`${change.from}\`\`${change.to}\``
case 'method_signature_changed':
return `- ⚠️ **Breaking**: Method \`${change.name}()\` signature changed`
case 'return_type_changed':
return `- ⚠️ **Breaking**: Return type changed: \`${change.from}\`\`${change.to}\``
case 'parameters_changed':
return `- ⚠️ **Breaking**: Function parameters changed`
case 'enum_member_added':
return `- ✨ Added enum value: \`${change.name}\``
case 'enum_member_removed':
return `- ⚠️ **Breaking**: Removed enum value: \`${change.name}\``
default:
return null
}
}
// Main execution
const changes = compareApis(previousApi, currentApi)
const changelog = formatChangelog(changes, previousVersion, currentVersion)
console.log(changelog)

238
scripts/snapshot-api.js Normal file
View File

@@ -0,0 +1,238 @@
#!/usr/bin/env node
/**
* Generates a JSON snapshot of the public API surface from TypeScript definitions.
* This snapshot is used to track API changes between versions.
*/
import * as fs from 'fs'
import * as path from 'path'
import * as ts from 'typescript'
const args = process.argv.slice(2)
if (args.length === 0) {
console.error('Usage: snapshot-api.js <path-to-index.d.ts>')
process.exit(1)
}
const filePath = args[0]
if (!fs.existsSync(filePath)) {
console.error(`File not found: ${filePath}`)
process.exit(1)
}
/**
* Extract API surface from TypeScript definitions
*/
function extractApiSurface(sourceFile) {
const api = {
types: {},
interfaces: {},
enums: {},
functions: {},
classes: {},
constants: {}
}
function visit(node) {
// Extract type aliases
if (ts.isTypeAliasDeclaration(node) && node.name) {
const name = node.name.text
api.types[name] = {
kind: 'type',
name,
text: node.getText(sourceFile),
exported: hasExportModifier(node)
}
}
// Extract interfaces
if (ts.isInterfaceDeclaration(node) && node.name) {
const name = node.name.text
const members = []
node.members.forEach((member) => {
if (ts.isPropertySignature(member) && member.name) {
members.push({
name: member.name.getText(sourceFile),
type: member.type ? member.type.getText(sourceFile) : 'any',
optional: !!member.questionToken
})
} else if (ts.isMethodSignature(member) && member.name) {
members.push({
name: member.name.getText(sourceFile),
kind: 'method',
parameters: member.parameters.map((p) => ({
name: p.name.getText(sourceFile),
type: p.type ? p.type.getText(sourceFile) : 'any',
optional: !!p.questionToken
})),
returnType: member.type ? member.type.getText(sourceFile) : 'void'
})
}
})
api.interfaces[name] = {
kind: 'interface',
name,
members,
exported: hasExportModifier(node),
heritage: node.heritageClauses
? node.heritageClauses
.map((clause) =>
clause.types.map((type) => type.getText(sourceFile))
)
.flat()
: []
}
}
// Extract enums
if (ts.isEnumDeclaration(node) && node.name) {
const name = node.name.text
const members = node.members.map((member) => ({
name: member.name.getText(sourceFile),
value: member.initializer
? member.initializer.getText(sourceFile)
: undefined
}))
api.enums[name] = {
kind: 'enum',
name,
members,
exported: hasExportModifier(node)
}
}
// Extract functions
if (ts.isFunctionDeclaration(node) && node.name) {
const name = node.name.text
api.functions[name] = {
kind: 'function',
name,
parameters: node.parameters.map((p) => ({
name: p.name.getText(sourceFile),
type: p.type ? p.type.getText(sourceFile) : 'any',
optional: !!p.questionToken
})),
returnType: node.type ? node.type.getText(sourceFile) : 'any',
exported: hasExportModifier(node)
}
}
// Extract classes
if (ts.isClassDeclaration(node) && node.name) {
const name = node.name.text
const members = []
const methods = []
node.members.forEach((member) => {
if (ts.isPropertyDeclaration(member) && member.name) {
members.push({
name: member.name.getText(sourceFile),
type: member.type ? member.type.getText(sourceFile) : 'any',
static: hasStaticModifier(member),
visibility: getVisibility(member)
})
} else if (ts.isMethodDeclaration(member) && member.name) {
methods.push({
name: member.name.getText(sourceFile),
parameters: member.parameters.map((p) => ({
name: p.name.getText(sourceFile),
type: p.type ? p.type.getText(sourceFile) : 'any',
optional: !!p.questionToken
})),
returnType: member.type ? member.type.getText(sourceFile) : 'any',
static: hasStaticModifier(member),
visibility: getVisibility(member)
})
}
})
api.classes[name] = {
kind: 'class',
name,
members,
methods,
exported: hasExportModifier(node),
heritage: node.heritageClauses
? node.heritageClauses
.map((clause) =>
clause.types.map((type) => type.getText(sourceFile))
)
.flat()
: []
}
}
// Extract variable declarations (constants)
if (ts.isVariableStatement(node)) {
node.declarationList.declarations.forEach((decl) => {
if (decl.name && ts.isIdentifier(decl.name)) {
const name = decl.name.text
api.constants[name] = {
kind: 'constant',
name,
type: decl.type ? decl.type.getText(sourceFile) : 'unknown',
exported: hasExportModifier(node)
}
}
})
}
ts.forEachChild(node, visit)
}
function hasExportModifier(node) {
return (
node.modifiers &&
node.modifiers.some(
(mod) => mod.kind === ts.SyntaxKind.ExportKeyword
)
)
}
function hasStaticModifier(node) {
return (
node.modifiers &&
node.modifiers.some(
(mod) => mod.kind === ts.SyntaxKind.StaticKeyword
)
)
}
function getVisibility(node) {
if (!node.modifiers) return 'public'
if (
node.modifiers.some(
(mod) => mod.kind === ts.SyntaxKind.PrivateKeyword
)
)
return 'private'
if (
node.modifiers.some(
(mod) => mod.kind === ts.SyntaxKind.ProtectedKeyword
)
)
return 'protected'
return 'public'
}
visit(sourceFile)
return api
}
// Read and parse the file
const sourceCode = fs.readFileSync(filePath, 'utf-8')
const sourceFile = ts.createSourceFile(
path.basename(filePath),
sourceCode,
ts.ScriptTarget.Latest,
true
)
const apiSurface = extractApiSurface(sourceFile)
// Output as JSON
console.log(JSON.stringify(apiSurface, null, 2))