diff --git a/.github/workflows/README.md b/.github/workflows/README.md index f62e91eda3..e32739b297 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -19,3 +19,214 @@ Workflow files follow a consistent naming pattern: `-. Each workflow file contains comments explaining its purpose, triggers, and behavior. For specific details about what each workflow does, refer to the comments at the top of each `.yaml` file. For GitHub Actions documentation, see [Events that trigger workflows](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows). + +## Manual API Changelog Generation + +The `manual-api-changelog.yaml` workflow allows you to generate API changelogs by comparing any two versions of the ComfyUI Frontend package. + +### Usage + +#### Via GitHub Actions UI + +1. Go to **Actions** tab in the repository +2. Select **Manual API Changelog Generation** from the workflows list +3. Click **Run workflow** button +4. Fill in the inputs: + - **Previous version**: The earlier version (e.g., `1.29.0` or `v1.29.0`) + - **Current version**: The later version (e.g., `1.30.2` or `v1.30.2`) + - **Create PR**: Check this to automatically create a pull request with the changelog + +#### Via GitHub CLI + +```bash +# Basic usage - just generate changelog +gh workflow run manual-api-changelog.yaml \ + -f from_version=1.29.0 \ + -f to_version=1.30.2 \ + -f create_pr=false + +# Generate changelog and create PR +gh workflow run manual-api-changelog.yaml \ + -f from_version=1.29.0 \ + -f to_version=1.30.2 \ + -f create_pr=true +``` + +### What It Does + +1. **Validates Inputs**: Checks that version formats are valid (X.Y.Z) and tags exist +2. **Builds Both Versions**: Checks out each version tag, installs dependencies, and builds TypeScript types +3. **Generates Snapshots**: Creates structured JSON snapshots of the public API surface for each version +4. **Compares APIs**: Analyzes differences and categorizes as: + - âš ī¸ **Breaking changes** (removals, signature changes) + - ✨ **Additions** (new interfaces, methods, properties) + - 🔄 **Modifications** (non-breaking changes) +5. **Uploads Artifact**: Saves the changelog and snapshots as a workflow artifact (90-day retention) +6. **Creates PR** (optional): Generates a draft PR to update `docs/API-CHANGELOG.md` + +### Output + +#### Workflow Artifacts + +Every run produces an artifact containing: +- `CHANGELOG-{from}-to-{to}.md` - Human-readable changelog +- `from.json` - API snapshot of the earlier version +- `to.json` - API snapshot of the later version + +**Retention**: 90 days + +#### Pull Request (Optional) + +If `create_pr` is enabled and changes are detected: +- Creates a draft PR with title: `[docs] API Changelog: v{from} → v{to}` +- Updates `docs/API-CHANGELOG.md` with the new changelog entry +- Includes detailed metadata and review instructions +- Labeled with `documentation` + +### Example Changelog Output + +```markdown +## v1.30.2 (2025-11-04) + +Comparing v1.29.0 → v1.30.2. This changelog documents changes to the public API surface. + +### ✨ Additions + +**Type Aliases** +- `WorkflowId` + +**Interfaces** +- `ExtensionMetadata` + - Members: `id`, `name`, `version`, `description` + +### 🔄 Modifications + +> **Note**: Some modifications may be breaking changes. + +**Interfaces** +- `ComfyApi` + - ✨ Added member: `queuePromptAsync` + - ✨ Added member: `cancelPrompt` + - âš ī¸ **Breaking**: Removed member: `queuePrompt` + +**Enums** +- `NodeStatus` + - ✨ Added enum value: `ERROR` + - ✨ Added enum value: `COMPLETED` +``` + +### Use Cases + +#### 1. Generate Changelog for Missed Releases + +If the automatic workflow failed or was skipped for a release: + +```bash +gh workflow run manual-api-changelog.yaml \ + -f from_version=1.28.0 \ + -f to_version=1.29.0 \ + -f create_pr=true +``` + +#### 2. Compare Non-Adjacent Versions + +To see cumulative changes across multiple releases: + +```bash +gh workflow run manual-api-changelog.yaml \ + -f from_version=1.25.0 \ + -f to_version=1.30.2 \ + -f create_pr=false +``` + +#### 3. Test Upcoming Changes + +Compare current `main` branch against the latest release (requires creating a temporary tag): + +```bash +# Create temporary tag for current main +git tag v1.31.0-preview +git push origin v1.31.0-preview + +# Run comparison +gh workflow run manual-api-changelog.yaml \ + -f from_version=1.30.2 \ + -f to_version=1.31.0-preview \ + -f create_pr=false + +# Clean up temporary tag +git tag -d v1.31.0-preview +git push origin :refs/tags/v1.31.0-preview +``` + +#### 4. Audit Historical Changes + +Generate changelogs for documentation purposes: + +```bash +# Compare multiple version pairs +for from in 1.26.0 1.27.0 1.28.0 1.29.0; do + to=$(echo "$from" | awk -F. '{print $1"."$2+1".0"}') + gh workflow run manual-api-changelog.yaml \ + -f from_version=$from \ + -f to_version=$to \ + -f create_pr=false +done +``` + +### Validation + +The workflow validates: +- ✅ Version format matches semantic versioning (X.Y.Z) +- ✅ Both version tags exist in the repository +- ✅ Tags reference valid commits with buildable code + +If validation fails, the workflow exits early with a clear error message. + +### Limitations + +- **Tag requirement**: Both versions must have corresponding `vX.Y.Z` git tags +- **Build requirement**: Both versions must have functional build processes +- **Type files**: Requires `dist/index.d.ts` to exist after building +- **Scripts**: Requires `scripts/snapshot-api.ts` and `scripts/compare-api-snapshots.ts` to be present + +### Related Workflows + +- **[Release API Changelogs](.github/workflows/release-api-changelogs.yaml)**: Automatic changelog generation triggered by NPM releases +- **[Release NPM Types](.github/workflows/release-npm-types.yaml)**: Publishes type definitions and triggers automatic changelog + +### Troubleshooting + +#### "Tag does not exist" error + +Ensure the version exists as a git tag: + +```bash +git tag -l 'v*' | grep 1.29.0 +``` + +If missing, the version may not have been released yet. + +#### "Build failed" error + +Check that the version can be built successfully: + +```bash +git checkout v1.29.0 +pnpm install +pnpm build:types +``` + +#### No changes detected + +If the workflow reports no changes but you expect some: +1. Check the artifact snapshots to verify they're different +2. Ensure you're comparing the correct versions +3. Review the comparison script logic in `scripts/compare-api-snapshots.ts` + +#### PR not created + +PR creation requires: +- `create_pr` input set to `true` +- Significant changes detected (more than just headers) +- `PR_GH_TOKEN` secret configured with appropriate permissions diff --git a/.github/workflows/manual-api-changelog.yaml b/.github/workflows/manual-api-changelog.yaml new file mode 100644 index 0000000000..681a7af234 --- /dev/null +++ b/.github/workflows/manual-api-changelog.yaml @@ -0,0 +1,255 @@ +name: Manual API Changelog Generation + +on: + workflow_dispatch: + inputs: + from_version: + description: 'Previous version (e.g., 1.29.0 or v1.29.0)' + required: true + type: string + to_version: + description: 'Current version (e.g., 1.30.2 or v1.30.2)' + required: true + type: string + create_pr: + description: 'Create a pull request with the changelog' + required: false + type: boolean + default: false + +concurrency: + group: manual-api-changelog-${{ github.run_id }} + cancel-in-progress: false + +jobs: + generate_changelog: + name: Generate API Changelog + runs-on: ubuntu-latest + 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: Validate version inputs + id: validate_versions + run: | + # Normalize version strings (remove 'v' prefix if present) + FROM_VERSION="${{ github.event.inputs.from_version }}" + TO_VERSION="${{ github.event.inputs.to_version }}" + + FROM_VERSION=${FROM_VERSION#v} + TO_VERSION=${TO_VERSION#v} + + # Validate version format (semantic versioning) + if ! [[ "$FROM_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Invalid from_version format: $FROM_VERSION" + echo "Expected format: X.Y.Z (e.g., 1.29.0)" + exit 1 + fi + + if ! [[ "$TO_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Invalid to_version format: $TO_VERSION" + echo "Expected format: X.Y.Z (e.g., 1.30.2)" + exit 1 + fi + + # Check if tags exist + if ! git rev-parse "v$FROM_VERSION" >/dev/null 2>&1; then + echo "Error: Tag v$FROM_VERSION does not exist" + exit 1 + fi + + if ! git rev-parse "v$TO_VERSION" >/dev/null 2>&1; then + echo "Error: Tag v$TO_VERSION does not exist" + exit 1 + fi + + echo "from_version=$FROM_VERSION" >> $GITHUB_OUTPUT + echo "to_version=$TO_VERSION" >> $GITHUB_OUTPUT + echo "from_tag=v$FROM_VERSION" >> $GITHUB_OUTPUT + echo "to_tag=v$TO_VERSION" >> $GITHUB_OUTPUT + + echo "✅ Validated versions: v$FROM_VERSION → v$TO_VERSION" + + - 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: Create snapshots directory + run: mkdir -p .api-snapshots + + - name: Preserve scripts + run: | + # Copy scripts to temporary location + mkdir -p /tmp/api-changelog-scripts + cp scripts/snapshot-api.ts scripts/compare-api-snapshots.ts /tmp/api-changelog-scripts/ + + - name: Build and snapshot TO version + run: | + echo "Building types for v${{ steps.validate_versions.outputs.to_version }}" + git checkout ${{ steps.validate_versions.outputs.to_tag }} + + # Restore scripts + mkdir -p scripts + cp /tmp/api-changelog-scripts/*.ts scripts/ + + pnpm install --frozen-lockfile + pnpm build:types + + # Generate snapshot + node --loader tsx scripts/snapshot-api.ts dist/index.d.ts > /tmp/api-snapshots-to.json + + echo "✅ Created snapshot for v${{ steps.validate_versions.outputs.to_version }}" + + - name: Build and snapshot FROM version + run: | + echo "Building types for v${{ steps.validate_versions.outputs.from_version }}" + git checkout ${{ steps.validate_versions.outputs.from_tag }} + + # Restore scripts + mkdir -p scripts + cp /tmp/api-changelog-scripts/*.ts scripts/ + + pnpm install --frozen-lockfile + pnpm build:types + + # Generate snapshot + node --loader tsx scripts/snapshot-api.ts dist/index.d.ts > /tmp/api-snapshots-from.json + + echo "✅ Created snapshot for v${{ steps.validate_versions.outputs.from_version }}" + + - name: Return to original branch + run: | + git checkout ${{ github.ref_name }} + + # Restore scripts + mkdir -p scripts + cp /tmp/api-changelog-scripts/*.ts scripts/ + + # Copy snapshots to working directory + cp /tmp/api-snapshots-from.json .api-snapshots/from.json + cp /tmp/api-snapshots-to.json .api-snapshots/to.json + + - name: Compare API snapshots and generate changelog + id: generate_changelog + run: | + # Get git ref for TO version + GIT_REF=$(git rev-parse ${{ steps.validate_versions.outputs.to_tag }}) + + # Run the comparison script + CHANGELOG_OUTPUT=$(node --loader tsx scripts/compare-api-snapshots.ts \ + .api-snapshots/from.json \ + .api-snapshots/to.json \ + ${{ steps.validate_versions.outputs.from_version }} \ + ${{ steps.validate_versions.outputs.to_version }} \ + Comfy-Org \ + ComfyUI_frontend \ + "$GIT_REF") + + # Save changelog to file for artifact + echo "$CHANGELOG_OUTPUT" > .api-snapshots/CHANGELOG-${{ steps.validate_versions.outputs.from_version }}-to-${{ steps.validate_versions.outputs.to_version }}.md + + # Also output to step summary + echo "## 📊 Generated API Changelog" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "$CHANGELOG_OUTPUT" >> $GITHUB_STEP_SUMMARY + + # Check if changelog is empty or just header + if [ $(echo "$CHANGELOG_OUTPUT" | wc -l) -lt 5 ]; then + echo "has_changes=false" >> $GITHUB_OUTPUT + echo "âš ī¸ No significant API changes detected" >> $GITHUB_STEP_SUMMARY + else + echo "has_changes=true" >> $GITHUB_OUTPUT + echo "✅ API changes detected and documented" >> $GITHUB_STEP_SUMMARY + fi + + echo "✅ Changelog generated successfully" + + - name: Upload changelog artifact + uses: actions/upload-artifact@v4 + with: + name: api-changelog-v${{ steps.validate_versions.outputs.from_version }}-to-v${{ steps.validate_versions.outputs.to_version }} + path: | + .api-snapshots/CHANGELOG-*.md + .api-snapshots/from.json + .api-snapshots/to.json + retention-days: 90 + + - name: Create Pull Request + if: github.event.inputs.create_pr == 'true' && 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.validate_versions.outputs.from_version }} → v${{ steps.validate_versions.outputs.to_version }}' + title: '[docs] API Changelog: v${{ steps.validate_versions.outputs.from_version }} → v${{ steps.validate_versions.outputs.to_version }}' + body: | + ## API Changelog Update (Manual) + + This PR documents public API changes between v${{ steps.validate_versions.outputs.from_version }} and v${{ steps.validate_versions.outputs.to_version }}. + + The changelog has been manually generated by comparing TypeScript type definitions between versions. + + ### Version Comparison + - **From:** v${{ steps.validate_versions.outputs.from_version }} + - **To:** v${{ steps.validate_versions.outputs.to_version }} + - **Requested by:** @${{ github.actor }} + - **Workflow run:** ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + + ### 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 + + ### Artifacts + The full changelog and snapshots are available as workflow artifacts for 90 days. + + --- + 🤖 Generated with [Claude Code](https://claude.com/claude-code) + branch: api-changelog-manual-${{ steps.validate_versions.outputs.from_version }}-to-${{ steps.validate_versions.outputs.to_version }} + base: ${{ github.ref_name }} + labels: documentation + delete-branch: true + draft: true + add-paths: | + docs/API-CHANGELOG.md + + - name: Summary + run: | + echo "## 🎉 Workflow Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **From version:** v${{ steps.validate_versions.outputs.from_version }}" >> $GITHUB_STEP_SUMMARY + echo "- **To version:** v${{ steps.validate_versions.outputs.to_version }}" >> $GITHUB_STEP_SUMMARY + echo "- **Changes detected:** ${{ steps.generate_changelog.outputs.has_changes }}" >> $GITHUB_STEP_SUMMARY + echo "- **Create PR:** ${{ github.event.inputs.create_pr }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### đŸ“Ļ Artifact" >> $GITHUB_STEP_SUMMARY + echo "The generated changelog and API snapshots have been uploaded as artifacts." >> $GITHUB_STEP_SUMMARY + echo "Retention: 90 days" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${{ github.event.inputs.create_pr }}" == "true" ] && [ "${{ steps.generate_changelog.outputs.has_changes }}" == "true" ]; then + echo "### 🔀 Pull Request" >> $GITHUB_STEP_SUMMARY + echo "A draft pull request has been created with the changelog updates." >> $GITHUB_STEP_SUMMARY + elif [ "${{ github.event.inputs.create_pr }}" == "true" ]; then + echo "### â„šī¸ No PR Created" >> $GITHUB_STEP_SUMMARY + echo "No significant changes were detected, so no PR was created." >> $GITHUB_STEP_SUMMARY + else + echo "### â„šī¸ PR Creation Skipped" >> $GITHUB_STEP_SUMMARY + echo "Pull request creation was not requested. Enable 'Create PR' option to automatically create a PR." >> $GITHUB_STEP_SUMMARY + fi diff --git a/.github/workflows/release-api-changelogs.yaml b/.github/workflows/release-api-changelogs.yaml new file mode 100644 index 0000000000..028af8ac77 --- /dev/null +++ b/.github/workflows/release-api-changelogs.yaml @@ -0,0 +1,203 @@ +name: Release API Changelogs + +on: + workflow_run: + workflows: ['Release NPM Types'] + types: + - completed + push: + branches: + - sno-api-changelog + +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 or on push to sno-api-changelog + if: ${{ github.event_name == 'push' || 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 the two most recent 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]+$' | head -2) + + # 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 --loader tsx scripts/snapshot-api.ts dist/index.d.ts > .api-snapshots/current.json + + echo "Current API snapshot created" + + - name: Preserve scripts for previous version + if: steps.previous_version.outputs.tag != '' + run: | + # Copy scripts to temporary location to use with previous version + mkdir -p /tmp/api-changelog-scripts + cp scripts/snapshot-api.ts scripts/compare-api-snapshots.ts /tmp/api-changelog-scripts/ + + - 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 }} + + # Restore scripts + mkdir -p scripts + cp /tmp/api-changelog-scripts/*.ts scripts/ + + - 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 --loader tsx scripts/snapshot-api.ts 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: | + # Remove copied scripts to avoid conflicts + rm -f scripts/snapshot-api.ts scripts/compare-api-snapshots.ts + + 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 + + # Get current git ref (commit SHA) + GIT_REF=$(git rev-parse HEAD) + + # Run the comparison script + if [ -f .api-snapshots/previous.json ]; then + node --loader tsx scripts/compare-api-snapshots.ts \ + .api-snapshots/previous.json \ + .api-snapshots/current.json \ + ${{ steps.previous_version.outputs.version }} \ + ${{ steps.current_version.outputs.version }} \ + Comfy-Org \ + ComfyUI_frontend \ + "$GIT_REF" \ + >> 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: ${{ github.event_name == 'push' && github.ref_name || '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 0000000000..51cd457a16 --- /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/knip.config.ts b/knip.config.ts index a77574f97b..72dac825b4 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -41,7 +41,9 @@ const config: KnipConfig = { 'src/workbench/extensions/manager/types/generatedManagerTypes.ts', 'packages/registry-types/src/comfyRegistryTypes.ts', // Used by a custom node (that should move off of this) - 'src/scripts/ui/components/splitButton.ts' + 'src/scripts/ui/components/splitButton.ts', + // Demo snapshots for API changelog system + 'demo-snapshots/**' ], compilers: { // https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199 diff --git a/scripts/compare-api-snapshots.ts b/scripts/compare-api-snapshots.ts new file mode 100644 index 0000000000..89f168b688 --- /dev/null +++ b/scripts/compare-api-snapshots.ts @@ -0,0 +1,445 @@ +#!/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 [repo-owner] [repo-name] [git-ref]' + ) + process.exit(1) +} + +const [ + previousPath, + currentPath, + previousVersion, + currentVersion, + repoOwner = 'Comfy-Org', + repoName = 'ComfyUI_frontend', + gitRef = 'main' +] = 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')) + +/** + * Generate GitHub permalink to source code + * Prefers source file location over dist .d.ts location + */ +function generateGitHubLink(name, item) { + // If we have source file information, use that + if (item?.sourceFile && item?.sourceLine) { + return `[\`${name}\`](https://github.com/${repoOwner}/${repoName}/blob/${gitRef}/${item.sourceFile}#L${item.sourceLine})` + } + + // Fallback to .d.ts location if available + if (item?.line) { + return `[\`${name}\`](https://github.com/${repoOwner}/${repoName}/blob/${gitRef}/dist/index.d.ts#L${item.line})` + } + + // No location info available + return `\`${name}\`` +} + +/** + * 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) { + const displayName = generateGitHubLink(item.name, item.item) + lines.push(`- **Removed**: ${displayName}`) + } + lines.push('') + } + } + + // Additions - commented out as per feedback + // 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) { + // Get the current item to access source location + const currItem = + currentApi[item.category] && currentApi[item.category][item.name] + const displayName = generateGitHubLink(item.name, currItem) + lines.push(`- ${displayName}`) + for (const change of item.changes) { + const formatted = formatChange(change) + if (formatted) { + lines.push(` ${formatted}`) + } + } + } + lines.push('') + } + } + + if (changes.breaking.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) + +// eslint-disable-next-line no-console +console.log(changelog) diff --git a/scripts/snapshot-api.ts b/scripts/snapshot-api.ts new file mode 100644 index 0000000000..654417ca84 --- /dev/null +++ b/scripts/snapshot-api.ts @@ -0,0 +1,314 @@ +#!/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 { execSync } from 'child_process' +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) +} + +/** + * Search for the declaration in source files + * Returns {file, line} or null if not found + */ +function findInSourceFiles(declarationName, kind, sourceRoot = 'src') { + const searchPattern = getSearchPattern(declarationName, kind) + if (!searchPattern) return null + + try { + // Search for the declaration pattern in source files + const result = execSync( + `grep -rn "${searchPattern}" ${sourceRoot} --include="*.ts" --include="*.tsx" | head -1`, + { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] } + ).trim() + + if (result) { + // Parse grep output: filepath:line:content + const match = result.match(/^([^:]+):(\d+):/) + if (match) { + return { + file: match[1], + line: parseInt(match[2], 10) + } + } + } + } catch (error) { + // grep returns non-zero exit code if no match found + } + + return null +} + +/** + * Generate search pattern for finding declaration in source + */ +function getSearchPattern(name, kind) { + switch (kind) { + case 'interface': + return `export interface ${name}` + case 'class': + return `export class ${name}` + case 'type': + return `export type ${name}` + case 'enum': + return `export enum ${name}` + case 'function': + return `export function ${name}` + case 'constant': + return `export const ${name}` + default: + return null + } +} + +/** + * 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 + const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart()) + const sourceLocation = findInSourceFiles(name, 'type') + api.types[name] = { + kind: 'type', + name, + text: node.getText(sourceFile), + exported: hasExportModifier(node), + line: line + 1, // Convert to 1-indexed + sourceFile: sourceLocation?.file, + sourceLine: sourceLocation?.line + } + } + + // Extract interfaces + if (ts.isInterfaceDeclaration(node) && node.name) { + const name = node.name.text + const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart()) + 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' + }) + } + }) + + const sourceLocation = findInSourceFiles(name, 'interface') + 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() + : [], + line: line + 1, // Convert to 1-indexed + sourceFile: sourceLocation?.file, + sourceLine: sourceLocation?.line + } + } + + // Extract enums + if (ts.isEnumDeclaration(node) && node.name) { + const name = node.name.text + const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart()) + const members = node.members.map((member) => ({ + name: member.name.getText(sourceFile), + value: member.initializer + ? member.initializer.getText(sourceFile) + : undefined + })) + + const sourceLocation = findInSourceFiles(name, 'enum') + api.enums[name] = { + kind: 'enum', + name, + members, + exported: hasExportModifier(node), + line: line + 1, // Convert to 1-indexed + sourceFile: sourceLocation?.file, + sourceLine: sourceLocation?.line + } + } + + // Extract functions + if (ts.isFunctionDeclaration(node) && node.name) { + const name = node.name.text + const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart()) + const sourceLocation = findInSourceFiles(name, 'function') + 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), + line: line + 1, // Convert to 1-indexed + sourceFile: sourceLocation?.file, + sourceLine: sourceLocation?.line + } + } + + // Extract classes + if (ts.isClassDeclaration(node) && node.name) { + const name = node.name.text + const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart()) + 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) + }) + } + }) + + const sourceLocation = findInSourceFiles(name, 'class') + 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() + : [], + line: line + 1, // Convert to 1-indexed + sourceFile: sourceLocation?.file, + sourceLine: sourceLocation?.line + } + } + + // Extract variable declarations (constants) + if (ts.isVariableStatement(node)) { + const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart()) + node.declarationList.declarations.forEach((decl) => { + if (decl.name && ts.isIdentifier(decl.name)) { + const name = decl.name.text + const sourceLocation = findInSourceFiles(name, 'constant') + api.constants[name] = { + kind: 'constant', + name, + type: decl.type ? decl.type.getText(sourceFile) : 'unknown', + exported: hasExportModifier(node), + line: line + 1, // Convert to 1-indexed + sourceFile: sourceLocation?.file, + sourceLine: sourceLocation?.line + } + } + }) + } + + 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 +// eslint-disable-next-line no-console +console.log(JSON.stringify(apiSurface, null, 2)) diff --git a/tsconfig.types.json b/tsconfig.types.json index d1ee9554bd..fcf58ff430 100644 --- a/tsconfig.types.json +++ b/tsconfig.types.json @@ -2,6 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "declaration": true, + "declarationMap": true, "noEmit": false, "paths": { "@/*": ["./src/*"]