- Add release.yml GitHub workflow for automated npm releases - Add prepare-release.js script for version bumping and changelog - Add extract-changelog.js for release notes extraction - Add test-release-automation.js for testing the workflow - Add documentation for automated releases This enables automatic npm publishing when tags are pushed, fixing the issue where releases were created but npm packages were not published. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
400 lines
13 KiB
JavaScript
Executable File
400 lines
13 KiB
JavaScript
Executable File
#!/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 <noreply@anthropic.com>`;
|
||
|
||
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; |