[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

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))