diff --git a/.github/workflows/release-api-changelogs.yaml b/.github/workflows/release-api-changelogs.yaml new file mode 100644 index 000000000..7ddab0c24 --- /dev/null +++ b/.github/workflows/release-api-changelogs.yaml @@ -0,0 +1,180 @@ +name: Release API Changelogs + +on: + workflow_run: + workflows: ['Release NPM Types'] + types: + - completed + +concurrency: + group: release-api-changelogs-${{ github.workflow }} + cancel-in-progress: false + +jobs: + generate_changelog: + name: Generate API Changelog + runs-on: ubuntu-latest + # Only run on successful completion of the Release NPM Types workflow + if: ${{ github.event.workflow_run.conclusion == 'success' }} + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 # Fetch all history for comparing versions + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: 'lts/*' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' + + - name: Get current version + id: current_version + run: | + VERSION=$(node -p "require('./package.json').version") + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Current version: $VERSION" + + - name: Get previous version + id: previous_version + run: | + # Get all version tags sorted + CURRENT_VERSION="${{ steps.current_version.outputs.version }}" + TAGS=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$') + + # Find the previous version tag (skip current if it exists) + PREVIOUS_TAG="" + for tag in $TAGS; do + TAG_VERSION=${tag#v} + if [ "$TAG_VERSION" != "$CURRENT_VERSION" ]; then + PREVIOUS_TAG=$tag + break + fi + done + + if [ -z "$PREVIOUS_TAG" ]; then + echo "No previous version found, this may be the first release" + echo "version=" >> $GITHUB_OUTPUT + echo "tag=" >> $GITHUB_OUTPUT + else + echo "version=${PREVIOUS_TAG#v}" >> $GITHUB_OUTPUT + echo "tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT + echo "Previous version: ${PREVIOUS_TAG#v}" + fi + + - name: Build current types + run: pnpm build:types + + - name: Snapshot current API + id: current_snapshot + run: | + # Create snapshots directory + mkdir -p .api-snapshots + + # Generate snapshot of current types + node scripts/snapshot-api.js dist/index.d.ts > .api-snapshots/current.json + + echo "Current API snapshot created" + + - name: Checkout previous version + if: steps.previous_version.outputs.tag != '' + run: | + # Stash current changes + git stash + + # Checkout previous version + git checkout ${{ steps.previous_version.outputs.tag }} + + - name: Build previous types + if: steps.previous_version.outputs.tag != '' + run: | + pnpm install --frozen-lockfile + pnpm build:types + + - name: Snapshot previous API + if: steps.previous_version.outputs.tag != '' + run: | + # Generate snapshot of previous types + node scripts/snapshot-api.js dist/index.d.ts > .api-snapshots/previous.json + + echo "Previous API snapshot created" + + - name: Return to current version + if: steps.previous_version.outputs.tag != '' + run: | + git checkout - + git stash pop || true + + - name: Compare API snapshots and generate changelog + id: generate_changelog + run: | + # Create docs directory if it doesn't exist + mkdir -p docs + + # Run the comparison script + if [ -f .api-snapshots/previous.json ]; then + node scripts/compare-api-snapshots.js \ + .api-snapshots/previous.json \ + .api-snapshots/current.json \ + ${{ steps.previous_version.outputs.version }} \ + ${{ steps.current_version.outputs.version }} \ + >> docs/API-CHANGELOG.md + else + # First release - just document the initial API surface + echo "## v${{ steps.current_version.outputs.version }} ($(date +%Y-%m-%d))" >> docs/API-CHANGELOG.md + echo "" >> docs/API-CHANGELOG.md + echo "Initial API release." >> docs/API-CHANGELOG.md + echo "" >> docs/API-CHANGELOG.md + fi + + # Check if there are any changes + if git diff --quiet docs/API-CHANGELOG.md; then + echo "has_changes=false" >> $GITHUB_OUTPUT + echo "No API changes detected" + else + echo "has_changes=true" >> $GITHUB_OUTPUT + echo "API changes detected" + fi + + - name: Create Pull Request + if: steps.generate_changelog.outputs.has_changes == 'true' + uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e + with: + token: ${{ secrets.PR_GH_TOKEN }} + commit-message: '[docs] Update API changelog for v${{ steps.current_version.outputs.version }}' + title: '[docs] API Changelog for v${{ steps.current_version.outputs.version }}' + body: | + ## API Changelog Update + + This PR documents public API changes between v${{ steps.previous_version.outputs.version }} and v${{ steps.current_version.outputs.version }}. + + The changelog has been automatically generated by comparing TypeScript type definitions between versions. + + ### Review Instructions + - Review the changes in `docs/API-CHANGELOG.md` + - Verify accuracy of breaking changes + - Add any additional context or migration notes if needed + - Merge when ready to publish changelog + + --- + 🤖 Generated with [Claude Code](https://claude.com/claude-code) + branch: api-changelog-v${{ steps.current_version.outputs.version }} + base: main + labels: documentation + delete-branch: true + draft: true + add-paths: | + docs/API-CHANGELOG.md diff --git a/docs/API-CHANGELOG.md b/docs/API-CHANGELOG.md new file mode 100644 index 000000000..51cd457a1 --- /dev/null +++ b/docs/API-CHANGELOG.md @@ -0,0 +1,27 @@ +# Public API Changelog + +This changelog documents changes to the ComfyUI Frontend public API surface across versions. The public API surface includes types, interfaces, and objects used by third-party extensions and custom nodes. + +**Important**: This is an automatically generated changelog based on TypeScript type definitions. Breaking changes are marked with ⚠️. + +## What is tracked + +This changelog tracks changes to the following public API components exported from `@comfyorg/comfyui-frontend-types`: + +- **Type Aliases**: Type definitions used by extensions +- **Interfaces**: Object shapes and contracts +- **Enums**: Enumerated values +- **Functions**: Public utility functions +- **Classes**: Exported classes and their public members +- **Constants**: Public constant values + +## Migration Guide + +When breaking changes occur, refer to the specific version section below for: +- What changed +- Why it changed (if applicable) +- How to migrate your code + +--- + + diff --git a/scripts/compare-api-snapshots.js b/scripts/compare-api-snapshots.js new file mode 100644 index 000000000..400413a95 --- /dev/null +++ b/scripts/compare-api-snapshots.js @@ -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 ' + ) + 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) diff --git a/scripts/snapshot-api.js b/scripts/snapshot-api.js new file mode 100644 index 000000000..74df3d0d0 --- /dev/null +++ b/scripts/snapshot-api.js @@ -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 ') + 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))