diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9a2e365 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,513 @@ +name: Automated Release + +on: + push: + branches: [main] + paths: + - 'package.json' + - 'package.runtime.json' + +permissions: + contents: write + packages: write + issues: write + pull-requests: write + +# Prevent concurrent releases +concurrency: + group: release + cancel-in-progress: false + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + detect-version-change: + name: Detect Version Change + runs-on: ubuntu-latest + outputs: + version-changed: ${{ steps.check.outputs.changed }} + new-version: ${{ steps.check.outputs.version }} + previous-version: ${{ steps.check.outputs.previous-version }} + is-prerelease: ${{ steps.check.outputs.is-prerelease }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Check for version change + id: check + run: | + # Get current version from package.json + CURRENT_VERSION=$(node -e "console.log(require('./package.json').version)") + + # Get previous version from git history safely + PREVIOUS_VERSION=$(git show HEAD~1:package.json 2>/dev/null | node -e " + try { + const data = require('fs').readFileSync(0, 'utf8'); + const pkg = JSON.parse(data); + console.log(pkg.version || '0.0.0'); + } catch (e) { + console.log('0.0.0'); + } + " || echo "0.0.0") + + echo "Previous version: $PREVIOUS_VERSION" + echo "Current version: $CURRENT_VERSION" + + # Check if version changed + if [ "$CURRENT_VERSION" != "$PREVIOUS_VERSION" ]; then + echo "changed=true" >> $GITHUB_OUTPUT + echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + echo "previous-version=$PREVIOUS_VERSION" >> $GITHUB_OUTPUT + + # Check if it's a prerelease (contains alpha, beta, rc, dev) + if echo "$CURRENT_VERSION" | grep -E "(alpha|beta|rc|dev)" > /dev/null; then + echo "is-prerelease=true" >> $GITHUB_OUTPUT + else + echo "is-prerelease=false" >> $GITHUB_OUTPUT + fi + + echo "๐ŸŽ‰ Version changed from $PREVIOUS_VERSION to $CURRENT_VERSION" + else + echo "changed=false" >> $GITHUB_OUTPUT + echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + echo "previous-version=$PREVIOUS_VERSION" >> $GITHUB_OUTPUT + echo "is-prerelease=false" >> $GITHUB_OUTPUT + echo "โ„น๏ธ No version change detected" + fi + + extract-changelog: + name: Extract Changelog + runs-on: ubuntu-latest + needs: detect-version-change + if: needs.detect-version-change.outputs.version-changed == 'true' + outputs: + release-notes: ${{ steps.extract.outputs.notes }} + has-notes: ${{ steps.extract.outputs.has-notes }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Extract changelog for version + id: extract + run: | + VERSION="${{ needs.detect-version-change.outputs.new-version }}" + CHANGELOG_FILE="docs/CHANGELOG.md" + + if [ ! -f "$CHANGELOG_FILE" ]; then + echo "Changelog file not found at $CHANGELOG_FILE" + echo "has-notes=false" >> $GITHUB_OUTPUT + echo "notes=No changelog entries found for version $VERSION" >> $GITHUB_OUTPUT + exit 0 + fi + + # Use the extracted changelog script + if NOTES=$(node scripts/extract-changelog.js "$VERSION" "$CHANGELOG_FILE" 2>/dev/null); then + echo "has-notes=true" >> $GITHUB_OUTPUT + + # Use heredoc to properly handle multiline content + { + echo "notes<> $GITHUB_OUTPUT + + echo "โœ… Successfully extracted changelog for version $VERSION" + else + echo "has-notes=false" >> $GITHUB_OUTPUT + echo "notes=No changelog entries found for version $VERSION" >> $GITHUB_OUTPUT + echo "โš ๏ธ Could not extract changelog for version $VERSION" + fi + + create-release: + name: Create GitHub Release + runs-on: ubuntu-latest + needs: [detect-version-change, extract-changelog] + if: needs.detect-version-change.outputs.version-changed == 'true' + outputs: + release-id: ${{ steps.create.outputs.id }} + upload-url: ${{ steps.create.outputs.upload_url }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Create Git Tag + run: | + VERSION="${{ needs.detect-version-change.outputs.new-version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Create annotated tag + git tag -a "v$VERSION" -m "Release v$VERSION" + git push origin "v$VERSION" + + - name: Create GitHub Release + id: create + run: | + VERSION="${{ needs.detect-version-change.outputs.new-version }}" + IS_PRERELEASE="${{ needs.detect-version-change.outputs.is-prerelease }}" + + # Create release body + cat > release_body.md << 'EOF' + # Release v${{ needs.detect-version-change.outputs.new-version }} + + ${{ needs.extract-changelog.outputs.release-notes }} + + --- + + ## Installation + + ### NPM Package + ```bash + # Install globally + npm install -g n8n-mcp + + # Or run directly + npx n8n-mcp + ``` + + ### Docker + ```bash + # Standard image + docker run -p 3000:3000 ghcr.io/czlonkowski/n8n-mcp:v${{ needs.detect-version-change.outputs.new-version }} + + # Railway optimized + docker run -p 3000:3000 ghcr.io/czlonkowski/n8n-mcp-railway:v${{ needs.detect-version-change.outputs.new-version }} + ``` + + ## Documentation + - [Installation Guide](https://github.com/czlonkowski/n8n-mcp#installation) + - [Docker Deployment](https://github.com/czlonkowski/n8n-mcp/blob/main/docs/DOCKER_README.md) + - [n8n Integration](https://github.com/czlonkowski/n8n-mcp/blob/main/docs/N8N_DEPLOYMENT.md) + - [Complete Changelog](https://github.com/czlonkowski/n8n-mcp/blob/main/docs/CHANGELOG.md) + + ๐Ÿค– *Generated with [Claude Code](https://claude.ai/code)* + EOF + + # Create release using gh CLI + if [ "$IS_PRERELEASE" = "true" ]; then + PRERELEASE_FLAG="--prerelease" + else + PRERELEASE_FLAG="" + fi + + gh release create "v$VERSION" \ + --title "Release v$VERSION" \ + --notes-file release_body.md \ + $PRERELEASE_FLAG + + # Output release info for next jobs + RELEASE_ID=$(gh release view "v$VERSION" --json id --jq '.id') + echo "id=$RELEASE_ID" >> $GITHUB_OUTPUT + echo "upload_url=https://uploads.github.com/repos/${{ github.repository }}/releases/$RELEASE_ID/assets{?name,label}" >> $GITHUB_OUTPUT + + build-and-test: + name: Build and Test + runs-on: ubuntu-latest + needs: detect-version-change + if: needs.detect-version-change.outputs.version-changed == 'true' + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build project + run: npm run build + + - name: Rebuild database + run: npm run rebuild + + - name: Run tests + run: npm test + env: + CI: true + + - name: Run type checking + run: npm run typecheck + + publish-npm: + name: Publish to NPM + runs-on: ubuntu-latest + needs: [detect-version-change, build-and-test, create-release] + if: needs.detect-version-change.outputs.version-changed == 'true' + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: npm ci + + - name: Build project + run: npm run build + + - name: Rebuild database + run: npm run rebuild + + - name: Sync runtime version + run: npm run sync:runtime-version + + - name: Prepare package for publishing + run: | + # Create publish directory + PUBLISH_DIR="npm-publish-temp" + rm -rf $PUBLISH_DIR + mkdir -p $PUBLISH_DIR + + # Copy necessary files + cp -r dist $PUBLISH_DIR/ + cp -r data $PUBLISH_DIR/ + cp README.md $PUBLISH_DIR/ + cp LICENSE $PUBLISH_DIR/ + cp .env.example $PUBLISH_DIR/ + + # Use runtime package.json as base + cp package.runtime.json $PUBLISH_DIR/package.json + + cd $PUBLISH_DIR + + # Update package.json with complete metadata + node -e " + const pkg = require('./package.json'); + pkg.name = 'n8n-mcp'; + pkg.description = 'Integration between n8n workflow automation and Model Context Protocol (MCP)'; + pkg.bin = { 'n8n-mcp': './dist/mcp/index.js' }; + pkg.repository = { type: 'git', url: 'git+https://github.com/czlonkowski/n8n-mcp.git' }; + pkg.keywords = ['n8n', 'mcp', 'model-context-protocol', 'ai', 'workflow', 'automation']; + pkg.author = 'Romuald Czlonkowski @ www.aiadvisors.pl/en'; + pkg.license = 'MIT'; + pkg.bugs = { url: 'https://github.com/czlonkowski/n8n-mcp/issues' }; + pkg.homepage = 'https://github.com/czlonkowski/n8n-mcp#readme'; + pkg.files = ['dist/**/*', 'data/nodes.db', '.env.example', 'README.md', 'LICENSE']; + delete pkg.private; + require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2)); + " + + echo "Package prepared for publishing:" + echo "Name: $(node -e "console.log(require('./package.json').name)")" + echo "Version: $(node -e "console.log(require('./package.json').version)")" + + - name: Publish to NPM with retry + uses: nick-invision/retry@v2 + with: + timeout_minutes: 5 + max_attempts: 3 + command: | + cd npm-publish-temp + npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Clean up + if: always() + run: rm -rf npm-publish-temp + + build-docker: + name: Build and Push Docker Images + runs-on: ubuntu-latest + needs: [detect-version-change, build-and-test] + if: needs.detect-version-change.outputs.version-changed == 'true' + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + lfs: true + + - name: Check disk space + run: | + echo "Disk usage before Docker build:" + df -h + + # Check available space (require at least 2GB) + AVAILABLE_GB=$(df / --output=avail --block-size=1G | tail -1) + if [ "$AVAILABLE_GB" -lt 2 ]; then + echo "โŒ Insufficient disk space: ${AVAILABLE_GB}GB available, 2GB required" + exit 1 + fi + echo "โœ… Sufficient disk space: ${AVAILABLE_GB}GB available" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata for standard image + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}},value=v${{ needs.detect-version-change.outputs.new-version }} + type=semver,pattern={{major}}.{{minor}},value=v${{ needs.detect-version-change.outputs.new-version }} + type=semver,pattern={{major}},value=v${{ needs.detect-version-change.outputs.new-version }} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push standard Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Extract metadata for Railway image + id: meta-railway + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-railway + tags: | + type=semver,pattern={{version}},value=v${{ needs.detect-version-change.outputs.new-version }} + type=semver,pattern={{major}}.{{minor}},value=v${{ needs.detect-version-change.outputs.new-version }} + type=semver,pattern={{major}},value=v${{ needs.detect-version-change.outputs.new-version }} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Railway Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.railway + platforms: linux/amd64 + push: true + tags: ${{ steps.meta-railway.outputs.tags }} + labels: ${{ steps.meta-railway.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + update-documentation: + name: Update Documentation + runs-on: ubuntu-latest + needs: [detect-version-change, create-release, publish-npm, build-docker] + if: needs.detect-version-change.outputs.version-changed == 'true' && !failure() + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Update version badges in README + run: | + VERSION="${{ needs.detect-version-change.outputs.new-version }}" + + # Update README version badges + if [ -f "README.md" ]; then + # Update npm version badge + sed -i.bak "s|npm/v/n8n-mcp/[^)]*|npm/v/n8n-mcp/$VERSION|g" README.md + + # Update any other version references + sed -i.bak "s|version-[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*|version-$VERSION|g" README.md + + # Clean up backup file + rm -f README.md.bak + + echo "โœ… Updated version badges in README.md to $VERSION" + fi + + - name: Commit documentation updates + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + if git diff --quiet; then + echo "No documentation changes to commit" + else + git add README.md + git commit -m "docs: update version badges to v${{ needs.detect-version-change.outputs.new-version }} + +๐Ÿค– Generated with [Claude Code](https://claude.ai/code) + +Co-Authored-By: Claude " + git push + echo "โœ… Committed documentation updates" + fi + + notify-completion: + name: Notify Release Completion + runs-on: ubuntu-latest + needs: [detect-version-change, create-release, publish-npm, build-docker, update-documentation] + if: always() && needs.detect-version-change.outputs.version-changed == 'true' + steps: + - name: Create release summary + run: | + VERSION="${{ needs.detect-version-change.outputs.new-version }}" + RELEASE_URL="https://github.com/${{ github.repository }}/releases/tag/v$VERSION" + + echo "## ๐ŸŽ‰ Release v$VERSION Published Successfully!" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### โœ… Completed Tasks:" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Check job statuses + if [ "${{ needs.create-release.result }}" = "success" ]; then + echo "- โœ… GitHub Release created: [$RELEASE_URL]($RELEASE_URL)" >> $GITHUB_STEP_SUMMARY + else + echo "- โŒ GitHub Release creation failed" >> $GITHUB_STEP_SUMMARY + fi + + if [ "${{ needs.publish-npm.result }}" = "success" ]; then + echo "- โœ… NPM package published: [npmjs.com/package/n8n-mcp](https://www.npmjs.com/package/n8n-mcp)" >> $GITHUB_STEP_SUMMARY + else + echo "- โŒ NPM publishing failed" >> $GITHUB_STEP_SUMMARY + fi + + if [ "${{ needs.build-docker.result }}" = "success" ]; then + echo "- โœ… Docker images built and pushed" >> $GITHUB_STEP_SUMMARY + echo " - Standard: \`ghcr.io/czlonkowski/n8n-mcp:v$VERSION\`" >> $GITHUB_STEP_SUMMARY + echo " - Railway: \`ghcr.io/czlonkowski/n8n-mcp-railway:v$VERSION\`" >> $GITHUB_STEP_SUMMARY + else + echo "- โŒ Docker image building failed" >> $GITHUB_STEP_SUMMARY + fi + + if [ "${{ needs.update-documentation.result }}" = "success" ]; then + echo "- โœ… Documentation updated" >> $GITHUB_STEP_SUMMARY + else + echo "- โš ๏ธ Documentation update skipped or failed" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "### ๐Ÿ“ฆ Installation:" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY + echo "# NPM" >> $GITHUB_STEP_SUMMARY + echo "npx n8n-mcp" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "# Docker" >> $GITHUB_STEP_SUMMARY + echo "docker run -p 3000:3000 ghcr.io/czlonkowski/n8n-mcp:v$VERSION" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + + echo "๐ŸŽ‰ Release automation completed for v$VERSION!" \ No newline at end of file diff --git a/docs/AUTOMATED_RELEASES.md b/docs/AUTOMATED_RELEASES.md new file mode 100644 index 0000000..fe08a1c --- /dev/null +++ b/docs/AUTOMATED_RELEASES.md @@ -0,0 +1,384 @@ +# Automated Release Process + +This document describes the automated release system for n8n-mcp, which handles version detection, changelog parsing, and multi-artifact publishing. + +## Overview + +The automated release system is triggered when the version in `package.json` is updated and pushed to the main branch. It handles: + +- ๐Ÿท๏ธ **GitHub Releases**: Creates releases with changelog content +- ๐Ÿ“ฆ **NPM Publishing**: Publishes optimized runtime package +- ๐Ÿณ **Docker Images**: Builds and pushes multi-platform images +- ๐Ÿ“š **Documentation**: Updates version badges automatically + +## Quick Start + +### For Maintainers + +Use the prepared release script for a guided experience: + +```bash +npm run prepare:release +``` + +This script will: +1. Prompt for the new version +2. Update `package.json` and `package.runtime.json` +3. Update the changelog +4. Run tests and build +5. Create a git commit +6. Optionally push to trigger the release + +### Manual Process + +1. **Update the version**: + ```bash + # Edit package.json version field + vim package.json + + # Sync to runtime package + npm run sync:runtime-version + ``` + +2. **Update the changelog**: + ```bash + # Edit docs/CHANGELOG.md + vim docs/CHANGELOG.md + ``` + +3. **Test and commit**: + ```bash + # Ensure everything works + npm test + npm run build + npm run rebuild + + # Commit changes + git add package.json package.runtime.json docs/CHANGELOG.md + git commit -m "chore: release vX.Y.Z" + git push + ``` + +## Workflow Details + +### Version Detection + +The workflow monitors pushes to the main branch and detects when `package.json` version changes: + +```yaml +paths: + - 'package.json' + - 'package.runtime.json' +``` + +### Changelog Parsing + +Automatically extracts release notes from `docs/CHANGELOG.md` using the version header format: + +```markdown +## [2.10.0] - 2025-08-02 + +### Added +- New feature descriptions + +### Changed +- Changed feature descriptions + +### Fixed +- Bug fix descriptions +``` + +### Release Artifacts + +#### GitHub Release +- Created with extracted changelog content +- Tagged with `vX.Y.Z` format +- Includes installation instructions +- Links to documentation + +#### NPM Package +- Published as `n8n-mcp` on npmjs.com +- Uses runtime-only dependencies (8 packages vs 50+ dev deps) +- Optimized for `npx` usage +- ~50MB vs 1GB+ with dev dependencies + +#### Docker Images +- **Standard**: `ghcr.io/czlonkowski/n8n-mcp:vX.Y.Z` +- **Railway**: `ghcr.io/czlonkowski/n8n-mcp-railway:vX.Y.Z` +- Multi-platform: linux/amd64, linux/arm64 +- Semantic version tags: `vX.Y.Z`, `vX.Y`, `vX`, `latest` + +## Configuration + +### Required Secrets + +Set these in GitHub repository settings โ†’ Secrets: + +| Secret | Description | Required | +|--------|-------------|----------| +| `NPM_TOKEN` | NPM authentication token for publishing | โœ… Yes | +| `GITHUB_TOKEN` | Automatically provided by GitHub Actions | โœ… Auto | + +### NPM Token Setup + +1. Login to [npmjs.com](https://www.npmjs.com) +2. Go to Account Settings โ†’ Access Tokens +3. Create a new **Automation** token +4. Add as `NPM_TOKEN` secret in GitHub + +## Testing + +### Test Release Automation + +Validate the release system without triggering a release: + +```bash +npm run test:release-automation +``` + +This checks: +- โœ… File existence and structure +- โœ… Version detection logic +- โœ… Changelog parsing +- โœ… Build process +- โœ… NPM package preparation +- โœ… Docker configuration +- โœ… Workflow syntax +- โœ… Environment setup + +### Local Testing + +Test individual components: + +```bash +# Test version detection +node -e "console.log(require('./package.json').version)" + +# Test changelog parsing +node scripts/test-release-automation.js + +# Test npm package preparation +npm run prepare:publish + +# Test Docker build +docker build -t test-image . +``` + +## Workflow Jobs + +### 1. Version Detection +- Compares current vs previous version in git history +- Determines if it's a prerelease (alpha, beta, rc, dev) +- Outputs version information for other jobs + +### 2. Changelog Extraction +- Parses `docs/CHANGELOG.md` for the current version +- Extracts content between version headers +- Provides formatted release notes + +### 3. GitHub Release Creation +- Creates annotated git tag +- Creates GitHub release with changelog content +- Handles prerelease flag for alpha/beta versions + +### 4. Build and Test +- Installs dependencies +- Runs full test suite +- Builds TypeScript +- Rebuilds node database +- Type checking + +### 5. NPM Publishing +- Prepares optimized package structure +- Uses `package.runtime.json` for dependencies +- Publishes to npmjs.com registry +- Automatic cleanup + +### 6. Docker Building +- Multi-platform builds (amd64, arm64) +- Two image variants (standard, railway) +- Semantic versioning tags +- GitHub Container Registry + +### 7. Documentation Updates +- Updates version badges in README +- Commits documentation changes +- Automatic push back to repository + +## Monitoring + +### GitHub Actions +Monitor releases at: https://github.com/czlonkowski/n8n-mcp/actions + +### Release Status +- **GitHub Releases**: https://github.com/czlonkowski/n8n-mcp/releases +- **NPM Package**: https://www.npmjs.com/package/n8n-mcp +- **Docker Images**: https://github.com/czlonkowski/n8n-mcp/pkgs/container/n8n-mcp + +### Notifications + +The workflow provides comprehensive summaries: +- โœ… Success notifications with links +- โŒ Failure notifications with error details +- ๐Ÿ“Š Artifact information and installation commands + +## Troubleshooting + +### Common Issues + +#### NPM Publishing Fails +``` +Error: 401 Unauthorized +``` +**Solution**: Check NPM_TOKEN secret is valid and has publishing permissions. + +#### Docker Build Fails +``` +Error: failed to solve: could not read from registry +``` +**Solution**: Check GitHub Container Registry permissions and GITHUB_TOKEN. + +#### Changelog Parsing Fails +``` +No changelog entries found for version X.Y.Z +``` +**Solution**: Ensure changelog follows the correct format: +```markdown +## [X.Y.Z] - YYYY-MM-DD +``` + +#### Version Detection Fails +``` +Version not incremented +``` +**Solution**: Ensure new version is greater than the previous version. + +### Recovery Steps + +#### Failed NPM Publish +1. Check if version was already published +2. If not, manually publish: + ```bash + npm run prepare:publish + cd npm-publish-temp + npm publish + ``` + +#### Failed Docker Build +1. Build locally to test: + ```bash + docker build -t test-build . + ``` +2. Re-trigger workflow or push a fix + +#### Incomplete Release +1. Delete the created tag if needed: + ```bash + git tag -d vX.Y.Z + git push --delete origin vX.Y.Z + ``` +2. Fix issues and push again + +## Security + +### Secrets Management +- NPM_TOKEN has limited scope (publish only) +- GITHUB_TOKEN has automatic scoping +- No secrets are logged or exposed + +### Package Security +- Runtime package excludes development dependencies +- No build tools or test frameworks in published package +- Minimal attack surface (~50MB vs 1GB+) + +### Docker Security +- Multi-stage builds +- Non-root user execution +- Minimal base images +- Security scanning enabled + +## Changelog Format + +The automated system expects changelog entries in [Keep a Changelog](https://keepachangelog.com/) format: + +```markdown +# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] + +### Added +- New features for next release + +## [2.10.0] - 2025-08-02 + +### Added +- Automated release system +- Multi-platform Docker builds + +### Changed +- Improved version detection +- Enhanced error handling + +### Fixed +- Fixed changelog parsing edge cases +- Fixed Docker build optimization + +## [2.9.1] - 2025-08-01 + +... +``` + +## Version Strategy + +### Semantic Versioning +- **MAJOR** (X.0.0): Breaking changes +- **MINOR** (X.Y.0): New features, backward compatible +- **PATCH** (X.Y.Z): Bug fixes, backward compatible + +### Prerelease Versions +- **Alpha**: `X.Y.Z-alpha.N` - Early development +- **Beta**: `X.Y.Z-beta.N` - Feature complete, testing +- **RC**: `X.Y.Z-rc.N` - Release candidate + +Prerelease versions are automatically detected and marked appropriately. + +## Best Practices + +### Before Releasing +1. โœ… Run `npm run test:release-automation` +2. โœ… Update changelog with meaningful descriptions +3. โœ… Test locally with `npm test && npm run build` +4. โœ… Review breaking changes +5. โœ… Consider impact on users + +### Version Bumping +- Use `npm run prepare:release` for guided process +- Follow semantic versioning strictly +- Document breaking changes clearly +- Consider backward compatibility + +### Changelog Writing +- Be specific about changes +- Include migration notes for breaking changes +- Credit contributors +- Use consistent formatting + +## Contributing + +### For Maintainers +1. Use automated tools: `npm run prepare:release` +2. Follow semantic versioning +3. Update changelog thoroughly +4. Test before releasing + +### For Contributors +- Breaking changes require MAJOR version bump +- New features require MINOR version bump +- Bug fixes require PATCH version bump +- Update changelog in PR descriptions + +--- + +๐Ÿค– *This automated release system was designed with [Claude Code](https://claude.ai/code)* \ No newline at end of file diff --git a/scripts/extract-changelog.js b/scripts/extract-changelog.js new file mode 100755 index 0000000..024d00f --- /dev/null +++ b/scripts/extract-changelog.js @@ -0,0 +1,84 @@ +#!/usr/bin/env node + +/** + * Extract changelog content for a specific version + * Used by GitHub Actions to extract release notes + */ + +const fs = require('fs'); +const path = require('path'); + +function extractChangelog(version, changelogPath) { + try { + if (!fs.existsSync(changelogPath)) { + console.error(`Changelog file not found at ${changelogPath}`); + process.exit(1); + } + + const content = fs.readFileSync(changelogPath, 'utf8'); + const lines = content.split('\n'); + + // Find the start of this version's section + const versionHeaderRegex = new RegExp(`^## \\[${version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]`); + let startIndex = -1; + let endIndex = -1; + + for (let i = 0; i < lines.length; i++) { + if (versionHeaderRegex.test(lines[i])) { + startIndex = i; + break; + } + } + + if (startIndex === -1) { + console.error(`No changelog entries found for version ${version}`); + process.exit(1); + } + + // Find the end of this version's section (next version or end of file) + for (let i = startIndex + 1; i < lines.length; i++) { + if (lines[i].startsWith('## [') && !lines[i].includes('Unreleased')) { + endIndex = i; + break; + } + } + + if (endIndex === -1) { + endIndex = lines.length; + } + + // Extract the section content + const sectionLines = lines.slice(startIndex, endIndex); + + // Remove the version header and any trailing empty lines + let contentLines = sectionLines.slice(1); + while (contentLines.length > 0 && contentLines[contentLines.length - 1].trim() === '') { + contentLines.pop(); + } + + if (contentLines.length === 0) { + console.error(`No content found for version ${version}`); + process.exit(1); + } + + const releaseNotes = contentLines.join('\n').trim(); + + // Write to stdout for GitHub Actions + console.log(releaseNotes); + + } catch (error) { + console.error(`Error extracting changelog: ${error.message}`); + process.exit(1); + } +} + +// Parse command line arguments +const version = process.argv[2]; +const changelogPath = process.argv[3]; + +if (!version || !changelogPath) { + console.error('Usage: extract-changelog.js '); + process.exit(1); +} + +extractChangelog(version, changelogPath); \ No newline at end of file diff --git a/scripts/prepare-release.js b/scripts/prepare-release.js new file mode 100755 index 0000000..270bab7 --- /dev/null +++ b/scripts/prepare-release.js @@ -0,0 +1,400 @@ +#!/usr/bin/env node + +/** + * Pre-release preparation script + * Validates and prepares everything needed for a successful release + */ + +const fs = require('fs'); +const path = require('path'); +const { execSync, spawnSync } = require('child_process'); +const readline = require('readline'); + +// Color codes +const colors = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m' +}; + +function log(message, color = 'reset') { + console.log(`${colors[color]}${message}${colors.reset}`); +} + +function success(message) { + log(`โœ… ${message}`, 'green'); +} + +function warning(message) { + log(`โš ๏ธ ${message}`, 'yellow'); +} + +function error(message) { + log(`โŒ ${message}`, 'red'); +} + +function info(message) { + log(`โ„น๏ธ ${message}`, 'blue'); +} + +function header(title) { + log(`\n${'='.repeat(60)}`, 'cyan'); + log(`๐Ÿš€ ${title}`, 'cyan'); + log(`${'='.repeat(60)}`, 'cyan'); +} + +class ReleasePreparation { + constructor() { + this.rootDir = path.resolve(__dirname, '..'); + this.rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + } + + async askQuestion(question) { + return new Promise((resolve) => { + this.rl.question(question, resolve); + }); + } + + /** + * Get current version and ask for new version + */ + async getVersionInfo() { + const packageJson = require(path.join(this.rootDir, 'package.json')); + const currentVersion = packageJson.version; + + log(`\nCurrent version: ${currentVersion}`, 'blue'); + + const newVersion = await this.askQuestion('\nEnter new version (e.g., 2.10.0): '); + + if (!newVersion || !this.isValidSemver(newVersion)) { + error('Invalid semantic version format'); + throw new Error('Invalid version'); + } + + if (this.compareVersions(newVersion, currentVersion) <= 0) { + error('New version must be greater than current version'); + throw new Error('Version not incremented'); + } + + return { currentVersion, newVersion }; + } + + /** + * Validate semantic version format (strict semver compliance) + */ + isValidSemver(version) { + // Strict semantic versioning regex + const semverRegex = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + return semverRegex.test(version); + } + + /** + * Compare two semantic versions + */ + compareVersions(v1, v2) { + const parseVersion = (v) => v.split('-')[0].split('.').map(Number); + const [v1Parts, v2Parts] = [parseVersion(v1), parseVersion(v2)]; + + for (let i = 0; i < 3; i++) { + if (v1Parts[i] > v2Parts[i]) return 1; + if (v1Parts[i] < v2Parts[i]) return -1; + } + return 0; + } + + /** + * Update version in package files + */ + updateVersions(newVersion) { + log('\n๐Ÿ“ Updating version in package files...', 'blue'); + + // Update package.json + const packageJsonPath = path.join(this.rootDir, 'package.json'); + const packageJson = require(packageJsonPath); + packageJson.version = newVersion; + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n'); + success('Updated package.json'); + + // Sync to runtime package + try { + execSync('npm run sync:runtime-version', { cwd: this.rootDir, stdio: 'pipe' }); + success('Synced package.runtime.json'); + } catch (err) { + warning('Could not sync runtime version automatically'); + + // Manual sync + const runtimeJsonPath = path.join(this.rootDir, 'package.runtime.json'); + if (fs.existsSync(runtimeJsonPath)) { + const runtimeJson = require(runtimeJsonPath); + runtimeJson.version = newVersion; + fs.writeFileSync(runtimeJsonPath, JSON.stringify(runtimeJson, null, 2) + '\n'); + success('Manually synced package.runtime.json'); + } + } + } + + /** + * Update changelog + */ + async updateChangelog(newVersion) { + const changelogPath = path.join(this.rootDir, 'docs/CHANGELOG.md'); + + if (!fs.existsSync(changelogPath)) { + warning('Changelog file not found, skipping update'); + return; + } + + log('\n๐Ÿ“‹ Updating changelog...', 'blue'); + + const content = fs.readFileSync(changelogPath, 'utf8'); + const today = new Date().toISOString().split('T')[0]; + + // Check if version already exists in changelog + const versionRegex = new RegExp(`^## \\[${newVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]`, 'm'); + if (versionRegex.test(content)) { + info(`Version ${newVersion} already exists in changelog`); + return; + } + + // Find the Unreleased section + const unreleasedMatch = content.match(/^## \[Unreleased\]\s*\n([\s\S]*?)(?=\n## \[|$)/m); + + if (unreleasedMatch) { + const unreleasedContent = unreleasedMatch[1].trim(); + + if (unreleasedContent) { + log('\nFound content in Unreleased section:', 'blue'); + log(unreleasedContent.substring(0, 200) + '...', 'yellow'); + + const moveContent = await this.askQuestion('\nMove this content to the new version? (y/n): '); + + if (moveContent.toLowerCase() === 'y') { + // Move unreleased content to new version + const newVersionSection = `## [${newVersion}] - ${today}\n\n${unreleasedContent}\n\n`; + const updatedContent = content.replace( + /^## \[Unreleased\]\s*\n[\s\S]*?(?=\n## \[)/m, + `## [Unreleased]\n\n${newVersionSection}## [` + ); + + fs.writeFileSync(changelogPath, updatedContent); + success(`Moved unreleased content to version ${newVersion}`); + } else { + // Just add empty version section + const newVersionSection = `## [${newVersion}] - ${today}\n\n### Added\n- \n\n### Changed\n- \n\n### Fixed\n- \n\n`; + const updatedContent = content.replace( + /^## \[Unreleased\]\s*\n/m, + `## [Unreleased]\n\n${newVersionSection}` + ); + + fs.writeFileSync(changelogPath, updatedContent); + warning(`Added empty version section for ${newVersion} - please fill in the changes`); + } + } else { + // Add empty version section + const newVersionSection = `## [${newVersion}] - ${today}\n\n### Added\n- \n\n### Changed\n- \n\n### Fixed\n- \n\n`; + const updatedContent = content.replace( + /^## \[Unreleased\]\s*\n/m, + `## [Unreleased]\n\n${newVersionSection}` + ); + + fs.writeFileSync(changelogPath, updatedContent); + warning(`Added empty version section for ${newVersion} - please fill in the changes`); + } + } else { + warning('Could not find Unreleased section in changelog'); + } + + info('Please review and edit the changelog before committing'); + } + + /** + * Run tests and build + */ + async runChecks() { + log('\n๐Ÿงช Running pre-release checks...', 'blue'); + + try { + // Run tests + log('Running tests...', 'blue'); + execSync('npm test', { cwd: this.rootDir, stdio: 'inherit' }); + success('All tests passed'); + + // Run build + log('Building project...', 'blue'); + execSync('npm run build', { cwd: this.rootDir, stdio: 'inherit' }); + success('Build completed'); + + // Rebuild database + log('Rebuilding database...', 'blue'); + execSync('npm run rebuild', { cwd: this.rootDir, stdio: 'inherit' }); + success('Database rebuilt'); + + // Run type checking + log('Type checking...', 'blue'); + execSync('npm run typecheck', { cwd: this.rootDir, stdio: 'inherit' }); + success('Type checking passed'); + + } catch (err) { + error('Pre-release checks failed'); + throw err; + } + } + + /** + * Create git commit + */ + async createCommit(newVersion) { + log('\n๐Ÿ“ Creating git commit...', 'blue'); + + try { + // Check git status + const status = execSync('git status --porcelain', { + cwd: this.rootDir, + encoding: 'utf8' + }); + + if (!status.trim()) { + info('No changes to commit'); + return; + } + + // Show what will be committed + log('\nFiles to be committed:', 'blue'); + execSync('git diff --name-only', { cwd: this.rootDir, stdio: 'inherit' }); + + const commit = await this.askQuestion('\nCreate commit for release? (y/n): '); + + if (commit.toLowerCase() === 'y') { + // Add files + execSync('git add package.json package.runtime.json docs/CHANGELOG.md', { + cwd: this.rootDir, + stdio: 'pipe' + }); + + // Create commit + const commitMessage = `chore: release v${newVersion} + +๐Ÿค– Generated with [Claude Code](https://claude.ai/code) + +Co-Authored-By: Claude `; + + const result = spawnSync('git', ['commit', '-m', commitMessage], { + cwd: this.rootDir, + stdio: 'pipe', + encoding: 'utf8' + }); + + if (result.error || result.status !== 0) { + throw new Error(`Git commit failed: ${result.stderr || result.error?.message}`); + } + + success(`Created commit for v${newVersion}`); + + const push = await this.askQuestion('\nPush to trigger release workflow? (y/n): '); + + if (push.toLowerCase() === 'y') { + // Add confirmation for destructive operation + warning('\nโš ๏ธ DESTRUCTIVE OPERATION WARNING โš ๏ธ'); + warning('This will trigger a PUBLIC RELEASE that cannot be undone!'); + warning('The following will happen automatically:'); + warning('โ€ข Create GitHub release with tag'); + warning('โ€ข Publish package to NPM registry'); + warning('โ€ข Build and push Docker images'); + warning('โ€ข Update documentation'); + + const confirmation = await this.askQuestion('\nType "RELEASE" (all caps) to confirm: '); + + if (confirmation === 'RELEASE') { + execSync('git push', { cwd: this.rootDir, stdio: 'inherit' }); + success('Pushed to remote repository'); + log('\n๐ŸŽ‰ Release workflow will be triggered automatically!', 'green'); + log('Monitor progress at: https://github.com/czlonkowski/n8n-mcp/actions', 'blue'); + } else { + warning('Release cancelled. Commit created but not pushed.'); + info('You can push manually later to trigger the release.'); + } + } else { + info('Commit created but not pushed. Push manually to trigger release.'); + } + } + + } catch (err) { + error(`Git operations failed: ${err.message}`); + throw err; + } + } + + /** + * Display final instructions + */ + displayInstructions(newVersion) { + header('Release Preparation Complete'); + + log('๐Ÿ“‹ What happens next:', 'blue'); + log(`1. The GitHub Actions workflow will detect the version change to v${newVersion}`, 'green'); + log('2. It will automatically:', 'green'); + log(' โ€ข Create a GitHub release with changelog content', 'green'); + log(' โ€ข Publish the npm package', 'green'); + log(' โ€ข Build and push Docker images', 'green'); + log(' โ€ข Update documentation badges', 'green'); + log('\n๐Ÿ” Monitor the release at:', 'blue'); + log(' โ€ข GitHub Actions: https://github.com/czlonkowski/n8n-mcp/actions', 'blue'); + log(' โ€ข NPM Package: https://www.npmjs.com/package/n8n-mcp', 'blue'); + log(' โ€ข Docker Images: https://github.com/czlonkowski/n8n-mcp/pkgs/container/n8n-mcp', 'blue'); + + log('\nโœ… Release preparation completed successfully!', 'green'); + } + + /** + * Main execution flow + */ + async run() { + try { + header('n8n-MCP Release Preparation'); + + // Get version information + const { currentVersion, newVersion } = await this.getVersionInfo(); + + log(`\n๐Ÿ”„ Preparing release: ${currentVersion} โ†’ ${newVersion}`, 'magenta'); + + // Update versions + this.updateVersions(newVersion); + + // Update changelog + await this.updateChangelog(newVersion); + + // Run pre-release checks + await this.runChecks(); + + // Create git commit + await this.createCommit(newVersion); + + // Display final instructions + this.displayInstructions(newVersion); + + } catch (err) { + error(`Release preparation failed: ${err.message}`); + process.exit(1); + } finally { + this.rl.close(); + } + } +} + +// Run the script +if (require.main === module) { + const preparation = new ReleasePreparation(); + preparation.run().catch(err => { + console.error('Release preparation failed:', err); + process.exit(1); + }); +} + +module.exports = ReleasePreparation; \ No newline at end of file diff --git a/scripts/test-release-automation.js b/scripts/test-release-automation.js new file mode 100755 index 0000000..fde9181 --- /dev/null +++ b/scripts/test-release-automation.js @@ -0,0 +1,560 @@ +#!/usr/bin/env node + +/** + * Test script for release automation + * Validates the release workflow components locally + */ + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +// Color codes for output +const colors = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m' +}; + +function log(message, color = 'reset') { + console.log(`${colors[color]}${message}${colors.reset}`); +} + +function header(title) { + log(`\n${'='.repeat(60)}`, 'cyan'); + log(`๐Ÿงช ${title}`, 'cyan'); + log(`${'='.repeat(60)}`, 'cyan'); +} + +function section(title) { + log(`\n๐Ÿ“‹ ${title}`, 'blue'); + log(`${'-'.repeat(40)}`, 'blue'); +} + +function success(message) { + log(`โœ… ${message}`, 'green'); +} + +function warning(message) { + log(`โš ๏ธ ${message}`, 'yellow'); +} + +function error(message) { + log(`โŒ ${message}`, 'red'); +} + +function info(message) { + log(`โ„น๏ธ ${message}`, 'blue'); +} + +class ReleaseAutomationTester { + constructor() { + this.rootDir = path.resolve(__dirname, '..'); + this.errors = []; + this.warnings = []; + } + + /** + * Test if required files exist + */ + testFileExistence() { + section('Testing File Existence'); + + const requiredFiles = [ + 'package.json', + 'package.runtime.json', + 'docs/CHANGELOG.md', + '.github/workflows/release.yml', + 'scripts/sync-runtime-version.js', + 'scripts/publish-npm.sh' + ]; + + for (const file of requiredFiles) { + const filePath = path.join(this.rootDir, file); + if (fs.existsSync(filePath)) { + success(`Found: ${file}`); + } else { + error(`Missing: ${file}`); + this.errors.push(`Missing required file: ${file}`); + } + } + } + + /** + * Test version detection logic + */ + testVersionDetection() { + section('Testing Version Detection'); + + try { + const packageJson = require(path.join(this.rootDir, 'package.json')); + const runtimeJson = require(path.join(this.rootDir, 'package.runtime.json')); + + success(`Package.json version: ${packageJson.version}`); + success(`Runtime package version: ${runtimeJson.version}`); + + if (packageJson.version === runtimeJson.version) { + success('Version sync: Both versions match'); + } else { + warning('Version sync: Versions do not match - run sync:runtime-version'); + this.warnings.push('Package versions are not synchronized'); + } + + // Test semantic version format + const semverRegex = /^\d+\.\d+\.\d+(?:-[\w\.-]+)?(?:\+[\w\.-]+)?$/; + if (semverRegex.test(packageJson.version)) { + success(`Version format: Valid semantic version (${packageJson.version})`); + } else { + error(`Version format: Invalid semantic version (${packageJson.version})`); + this.errors.push('Invalid semantic version format'); + } + + } catch (err) { + error(`Version detection failed: ${err.message}`); + this.errors.push(`Version detection error: ${err.message}`); + } + } + + /** + * Test changelog parsing + */ + testChangelogParsing() { + section('Testing Changelog Parsing'); + + try { + const changelogPath = path.join(this.rootDir, 'docs/CHANGELOG.md'); + + if (!fs.existsSync(changelogPath)) { + error('Changelog file not found'); + this.errors.push('Missing changelog file'); + return; + } + + const changelogContent = fs.readFileSync(changelogPath, 'utf8'); + const packageJson = require(path.join(this.rootDir, 'package.json')); + const currentVersion = packageJson.version; + + // Check if current version exists in changelog + const versionRegex = new RegExp(`^## \\[${currentVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]`, 'm'); + + if (versionRegex.test(changelogContent)) { + success(`Changelog entry found for version ${currentVersion}`); + + // Test extraction logic (simplified version of the GitHub Actions script) + const lines = changelogContent.split('\n'); + let startIndex = -1; + let endIndex = -1; + + for (let i = 0; i < lines.length; i++) { + if (versionRegex.test(lines[i])) { + startIndex = i; + break; + } + } + + if (startIndex !== -1) { + // Find the end of this version's section + for (let i = startIndex + 1; i < lines.length; i++) { + if (lines[i].startsWith('## [') && !lines[i].includes('Unreleased')) { + endIndex = i; + break; + } + } + + if (endIndex === -1) { + endIndex = lines.length; + } + + const sectionLines = lines.slice(startIndex + 1, endIndex); + const contentLines = sectionLines.filter(line => line.trim() !== ''); + + if (contentLines.length > 0) { + success(`Changelog content extracted: ${contentLines.length} lines`); + info(`Preview: ${contentLines[0].substring(0, 100)}...`); + } else { + warning('Changelog section appears to be empty'); + this.warnings.push(`Empty changelog section for version ${currentVersion}`); + } + } + + } else { + warning(`No changelog entry found for current version ${currentVersion}`); + this.warnings.push(`Missing changelog entry for version ${currentVersion}`); + } + + // Check changelog format + if (changelogContent.includes('## [Unreleased]')) { + success('Changelog format: Contains Unreleased section'); + } else { + warning('Changelog format: Missing Unreleased section'); + } + + if (changelogContent.includes('Keep a Changelog')) { + success('Changelog format: Follows Keep a Changelog format'); + } else { + warning('Changelog format: Does not reference Keep a Changelog'); + } + + } catch (err) { + error(`Changelog parsing failed: ${err.message}`); + this.errors.push(`Changelog parsing error: ${err.message}`); + } + } + + /** + * Test build process + */ + testBuildProcess() { + section('Testing Build Process'); + + try { + // Check if dist directory exists + const distPath = path.join(this.rootDir, 'dist'); + if (fs.existsSync(distPath)) { + success('Build output: dist directory exists'); + + // Check for key build files + const keyFiles = [ + 'dist/index.js', + 'dist/mcp/index.js', + 'dist/mcp/server.js' + ]; + + for (const file of keyFiles) { + const filePath = path.join(this.rootDir, file); + if (fs.existsSync(filePath)) { + success(`Build file: ${file} exists`); + } else { + warning(`Build file: ${file} missing - run 'npm run build'`); + this.warnings.push(`Missing build file: ${file}`); + } + } + + } else { + warning('Build output: dist directory missing - run "npm run build"'); + this.warnings.push('Missing build output'); + } + + // Check database + const dbPath = path.join(this.rootDir, 'data/nodes.db'); + if (fs.existsSync(dbPath)) { + const stats = fs.statSync(dbPath); + success(`Database: nodes.db exists (${Math.round(stats.size / 1024 / 1024)}MB)`); + } else { + warning('Database: nodes.db missing - run "npm run rebuild"'); + this.warnings.push('Missing database file'); + } + + } catch (err) { + error(`Build process test failed: ${err.message}`); + this.errors.push(`Build process error: ${err.message}`); + } + } + + /** + * Test npm publish preparation + */ + testNpmPublishPrep() { + section('Testing NPM Publish Preparation'); + + try { + const packageJson = require(path.join(this.rootDir, 'package.json')); + const runtimeJson = require(path.join(this.rootDir, 'package.runtime.json')); + + // Check package.json fields + const requiredFields = ['name', 'version', 'description', 'main', 'bin']; + for (const field of requiredFields) { + if (packageJson[field]) { + success(`Package field: ${field} is present`); + } else { + error(`Package field: ${field} is missing`); + this.errors.push(`Missing package.json field: ${field}`); + } + } + + // Check runtime dependencies + if (runtimeJson.dependencies) { + const depCount = Object.keys(runtimeJson.dependencies).length; + success(`Runtime dependencies: ${depCount} packages`); + + // List key dependencies + const keyDeps = ['@modelcontextprotocol/sdk', 'express', 'sql.js']; + for (const dep of keyDeps) { + if (runtimeJson.dependencies[dep]) { + success(`Key dependency: ${dep} (${runtimeJson.dependencies[dep]})`); + } else { + warning(`Key dependency: ${dep} is missing`); + this.warnings.push(`Missing key dependency: ${dep}`); + } + } + + } else { + error('Runtime package has no dependencies'); + this.errors.push('Missing runtime dependencies'); + } + + // Check files array + if (packageJson.files && Array.isArray(packageJson.files)) { + success(`Package files: ${packageJson.files.length} patterns specified`); + info(`Files: ${packageJson.files.join(', ')}`); + } else { + warning('Package files: No files array specified'); + this.warnings.push('No files array in package.json'); + } + + } catch (err) { + error(`NPM publish prep test failed: ${err.message}`); + this.errors.push(`NPM publish prep error: ${err.message}`); + } + } + + /** + * Test Docker configuration + */ + testDockerConfig() { + section('Testing Docker Configuration'); + + try { + const dockerfiles = ['Dockerfile', 'Dockerfile.railway']; + + for (const dockerfile of dockerfiles) { + const dockerfilePath = path.join(this.rootDir, dockerfile); + if (fs.existsSync(dockerfilePath)) { + success(`Dockerfile: ${dockerfile} exists`); + + const content = fs.readFileSync(dockerfilePath, 'utf8'); + + // Check for key instructions + if (content.includes('FROM node:')) { + success(`${dockerfile}: Uses Node.js base image`); + } else { + warning(`${dockerfile}: Does not use standard Node.js base image`); + } + + if (content.includes('COPY dist')) { + success(`${dockerfile}: Copies build output`); + } else { + warning(`${dockerfile}: May not copy build output correctly`); + } + + } else { + warning(`Dockerfile: ${dockerfile} not found`); + this.warnings.push(`Missing Dockerfile: ${dockerfile}`); + } + } + + // Check docker-compose files + const composeFiles = ['docker-compose.yml', 'docker-compose.n8n.yml']; + for (const composeFile of composeFiles) { + const composePath = path.join(this.rootDir, composeFile); + if (fs.existsSync(composePath)) { + success(`Docker Compose: ${composeFile} exists`); + } else { + info(`Docker Compose: ${composeFile} not found (optional)`); + } + } + + } catch (err) { + error(`Docker config test failed: ${err.message}`); + this.errors.push(`Docker config error: ${err.message}`); + } + } + + /** + * Test workflow file syntax + */ + testWorkflowSyntax() { + section('Testing Workflow Syntax'); + + try { + const workflowPath = path.join(this.rootDir, '.github/workflows/release.yml'); + + if (!fs.existsSync(workflowPath)) { + error('Release workflow file not found'); + this.errors.push('Missing release workflow file'); + return; + } + + const workflowContent = fs.readFileSync(workflowPath, 'utf8'); + + // Basic YAML structure checks + if (workflowContent.includes('name: Automated Release')) { + success('Workflow: Has correct name'); + } else { + warning('Workflow: Name may be incorrect'); + } + + if (workflowContent.includes('on:') && workflowContent.includes('push:')) { + success('Workflow: Has push trigger'); + } else { + error('Workflow: Missing push trigger'); + this.errors.push('Workflow missing push trigger'); + } + + if (workflowContent.includes('branches: [main]')) { + success('Workflow: Configured for main branch'); + } else { + warning('Workflow: May not be configured for main branch'); + } + + // Check for required jobs + const requiredJobs = [ + 'detect-version-change', + 'extract-changelog', + 'create-release', + 'publish-npm', + 'build-docker' + ]; + + for (const job of requiredJobs) { + if (workflowContent.includes(`${job}:`)) { + success(`Workflow job: ${job} defined`); + } else { + error(`Workflow job: ${job} missing`); + this.errors.push(`Missing workflow job: ${job}`); + } + } + + // Check for secrets usage + if (workflowContent.includes('${{ secrets.NPM_TOKEN }}')) { + success('Workflow: NPM_TOKEN secret configured'); + } else { + warning('Workflow: NPM_TOKEN secret may be missing'); + this.warnings.push('NPM_TOKEN secret may need to be configured'); + } + + if (workflowContent.includes('${{ secrets.GITHUB_TOKEN }}')) { + success('Workflow: GITHUB_TOKEN secret configured'); + } else { + warning('Workflow: GITHUB_TOKEN secret may be missing'); + } + + } catch (err) { + error(`Workflow syntax test failed: ${err.message}`); + this.errors.push(`Workflow syntax error: ${err.message}`); + } + } + + /** + * Test environment and dependencies + */ + testEnvironment() { + section('Testing Environment'); + + try { + // Check Node.js version + const nodeVersion = process.version; + success(`Node.js version: ${nodeVersion}`); + + // Check if npm is available + try { + const npmVersion = execSync('npm --version', { encoding: 'utf8', stdio: 'pipe' }).trim(); + success(`NPM version: ${npmVersion}`); + } catch (err) { + error('NPM not available'); + this.errors.push('NPM not available'); + } + + // Check if git is available + try { + const gitVersion = execSync('git --version', { encoding: 'utf8', stdio: 'pipe' }).trim(); + success(`Git available: ${gitVersion}`); + } catch (err) { + error('Git not available'); + this.errors.push('Git not available'); + } + + // Check if we're in a git repository + try { + execSync('git rev-parse --git-dir', { stdio: 'pipe' }); + success('Git repository: Detected'); + + // Check current branch + try { + const branch = execSync('git branch --show-current', { encoding: 'utf8', stdio: 'pipe' }).trim(); + info(`Current branch: ${branch}`); + } catch (err) { + info('Could not determine current branch'); + } + + } catch (err) { + warning('Not in a git repository'); + this.warnings.push('Not in a git repository'); + } + + } catch (err) { + error(`Environment test failed: ${err.message}`); + this.errors.push(`Environment error: ${err.message}`); + } + } + + /** + * Run all tests + */ + async runAllTests() { + header('Release Automation Test Suite'); + + info('Testing release automation components...'); + + this.testFileExistence(); + this.testVersionDetection(); + this.testChangelogParsing(); + this.testBuildProcess(); + this.testNpmPublishPrep(); + this.testDockerConfig(); + this.testWorkflowSyntax(); + this.testEnvironment(); + + // Summary + header('Test Summary'); + + if (this.errors.length === 0 && this.warnings.length === 0) { + log('๐ŸŽ‰ All tests passed! Release automation is ready.', 'green'); + } else { + if (this.errors.length > 0) { + log(`\nโŒ ${this.errors.length} Error(s):`, 'red'); + this.errors.forEach(err => log(` โ€ข ${err}`, 'red')); + } + + if (this.warnings.length > 0) { + log(`\nโš ๏ธ ${this.warnings.length} Warning(s):`, 'yellow'); + this.warnings.forEach(warn => log(` โ€ข ${warn}`, 'yellow')); + } + + if (this.errors.length > 0) { + log('\n๐Ÿ”ง Please fix the errors before running the release workflow.', 'red'); + process.exit(1); + } else { + log('\nโœ… No critical errors found. Warnings should be reviewed but won\'t prevent releases.', 'yellow'); + } + } + + // Next steps + log('\n๐Ÿ“‹ Next Steps:', 'cyan'); + log('1. Ensure all secrets are configured in GitHub repository settings:', 'cyan'); + log(' โ€ข NPM_TOKEN (required for npm publishing)', 'cyan'); + log(' โ€ข GITHUB_TOKEN (automatically available)', 'cyan'); + log('\n2. To trigger a release:', 'cyan'); + log(' โ€ข Update version in package.json', 'cyan'); + log(' โ€ข Update changelog in docs/CHANGELOG.md', 'cyan'); + log(' โ€ข Commit and push to main branch', 'cyan'); + log('\n3. Monitor the release workflow in GitHub Actions', 'cyan'); + + return this.errors.length === 0; + } +} + +// Run the tests +if (require.main === module) { + const tester = new ReleaseAutomationTester(); + tester.runAllTests().catch(err => { + console.error('Test suite failed:', err); + process.exit(1); + }); +} + +module.exports = ReleaseAutomationTester; \ No newline at end of file