#!/usr/bin/env node /** * Version Bumping Script * * Bumps the version based on conventional commits since last release. * Supports: * - major: Breaking changes (! or BREAKING CHANGE) * - minor: Features (feat:) * - patch: Fixes (fix:) and other changes */ const { execSync } = require('child_process'); const fs = require('fs'); const path = require('path'); // Parse command line arguments const args = process.argv.slice(2); const forcedVersion = args[0]; // e.g., "0.2.0" or "patch/minor/major" // Paths const packageJsonPath = path.join(__dirname, '..', 'package.json'); const changelogPath = path.join(__dirname, '..', 'CHANGELOG.md'); // Load current version from package.json function getCurrentVersion() { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); return packageJson.version; } // Parse version string into components function parseVersion(version) { const [major, minor, patch] = version.split('.').map(Number); return { major, minor, patch }; } // Format version string function formatVersion(major, minor, patch) { return `${major}.${minor}.${patch}`; } // Get commits since last tag function getCommitsSinceLastTag() { try { const tags = execSync('git tag --sort=-version:refname', { encoding: 'utf8' }).trim(); const latestTag = tags.split('\n')[0]; if (!latestTag) { // No tags yet, get all commits return execSync('git log --oneline --format=%s', { encoding: 'utf8' }).trim().split('\n'); } const commits = execSync(`git log ${latestTag}..HEAD --oneline --format=%s`, { encoding: 'utf8' }).trim(); return commits ? commits.split('\n') : []; } catch (error) { // If no commits since tag or other error, get recent commits return execSync('git log --oneline --format=%s -n 20', { encoding: 'utf8' }).trim().split('\n'); } } // Analyze commits to determine version bump type function analyzeCommits(commits) { let bumpType = 'patch'; // default for (const commit of commits) { if (!commit) continue; // Breaking change indicators if (commit.includes('!') || commit.includes('BREAKING CHANGE')) { bumpType = 'major'; break; // Major takes precedence } // Feature indicators if (commit.startsWith('feat:') || commit.startsWith('feat(')) { if (bumpType === 'patch') { bumpType = 'minor'; } } // Fix indicators (already patch by default) } return bumpType; } // Generate new version based on bump type function generateNewVersion(currentVersion, bumpType) { const { major, minor, patch } = parseVersion(currentVersion); switch (bumpType) { case 'major': return formatVersion(major + 1, 0, 0); case 'minor': return formatVersion(major, minor + 1, 0); case 'patch': default: return formatVersion(major, minor, patch + 1); } } // Update package.json version function updatePackageJson(newVersion) { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); packageJson.version = newVersion; fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n'); console.log(`āœ“ Updated package.json to version ${newVersion}`); } // Generate changelog entry function generateChangelogEntry(newVersion, commits, bumpType) { const date = new Date().toISOString().split('T')[0]; const relevantCommits = commits.filter(commit => { if (!commit) return false; // Filter out non-user-facing commits if (commit.startsWith('chore:') && !commit.includes('BREAKING')) return false; return true; }); const changelogEntry = `## [${newVersion}] - ${date}\n\n` + `### ${bumpType.charAt(0).toUpperCase() + bumpType.slice(1)} Changes\n\n` + relevantCommits.map(commit => `- ${commit}`).join('\n') + '\n\n'; return changelogEntry; } // Update CHANGELOG.md function updateChangelog(newVersion, commits, bumpType) { const changelogEntry = generateChangelogEntry(newVersion, commits, bumpType); if (fs.existsSync(changelogPath)) { const content = fs.readFileSync(changelogPath, 'utf8'); const newContent = changelogEntry + content; fs.writeFileSync(changelogPath, newContent); } else { fs.writeFileSync(changelogPath, `# Changelog\n\n${changelogEntry}`); } console.log(`āœ“ Updated CHANGELOG.md`); } // Main function function main() { console.log('šŸ”„ Version Bumping Tool\n'); const currentVersion = getCurrentVersion(); console.log(`Current version: ${currentVersion}`); let newVersion; let bumpType; if (forcedVersion) { // Check if it's a bump type or explicit version if (['patch', 'minor', 'major'].includes(forcedVersion.toLowerCase())) { bumpType = forcedVersion.toLowerCase(); newVersion = generateNewVersion(currentVersion, bumpType); console.log(`Forced bump type: ${bumpType}`); } else { // Assume it's an explicit version newVersion = forcedVersion; bumpType = 'custom'; console.log(`Forced version: ${newVersion}`); } } else { // Analyze commits to determine bump type const commits = getCommitsSinceLastTag(); console.log(`\nAnalyzing ${commits.length} commits since last release...`); bumpType = analyzeCommits(commits); newVersion = generateNewVersion(currentVersion, bumpType); console.log(`\nDetected bump type: ${bumpType}`); console.log(`New version: ${currentVersion} → ${newVersion}`); } // Confirm with user const readline = require('readline'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); rl.question(`\nApply version ${newVersion}? (y/N) `, (answer) => { if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') { // Update package.json updatePackageJson(newVersion); // Update changelog if (bumpType !== 'custom') { const commits = getCommitsSinceLastTag(); updateChangelog(newVersion, commits, bumpType); } console.log(`\nāœ… Version bumped to ${newVersion}`); console.log(`\nNext steps:`); console.log(` 1. Review changes: git diff`); console.log(` 2. Commit changes: git commit -am "chore: bump version to v${newVersion}"`); console.log(` 3. Create tag: git tag -a v${newVersion} -m "Release v${newVersion}"`); console.log(` 4. Push changes: git push origin main`); console.log(` 5. Push tag: git push origin v${newVersion}`); } else { console.log('āŒ Version bump cancelled'); } rl.close(); }); } // Run main function main();