#!/usr/bin/env node
/**
* Version Synchronization Script
* Keeps JavaScript and Python package versions in sync
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
class VersionSync {
constructor() {
this.rootDir = path.resolve(__dirname, '..');
this.jsPackagePath = path.join(this.rootDir, 'packages/js/package.json');
this.pyPackagePath = path.join(this.rootDir, 'packages/py/pyproject.toml');
this.pyInitPath = path.join(this.rootDir, 'packages/py/glin_profanity/__init__.py');
this.rootPackagePath = path.join(this.rootDir, 'package.json');
}
/**
* Get current version from JavaScript package
*/
getJsVersion() {
const packageJson = JSON.parse(fs.readFileSync(this.jsPackagePath, 'utf8'));
return packageJson.version;
}
/**
* Get current version from Python package
*/
getPyVersion() {
try {
// Primary source: __init__.py (since pyproject.toml uses dynamic versioning)
const initContent = fs.readFileSync(this.pyInitPath, 'utf8');
const initMatch = initContent.match(/__version__ = "(.*?)"/);
if (initMatch) {
return initMatch[1];
}
// Fallback: pyproject.toml (only if it has explicit version, not dynamic)
const pyprojectContent = fs.readFileSync(this.pyPackagePath, 'utf8');
// More specific regex to avoid matching Python version requirements
const versionMatch = pyprojectContent.match(/^\s*version\s*=\s*"(.*?)"$/m);
return versionMatch ? versionMatch[1] : '0.0.0';
} catch (error) {
console.warn(`Warning: Could not read Python version: ${error.message}`);
return '0.0.0';
}
}
/**
* Update JavaScript package version
*/
setJsVersion(version) {
const packageJson = JSON.parse(fs.readFileSync(this.jsPackagePath, 'utf8'));
packageJson.version = version;
fs.writeFileSync(this.jsPackagePath, JSON.stringify(packageJson, null, 2) + '\n');
// Also update root package.json
const rootPackageJson = JSON.parse(fs.readFileSync(this.rootPackagePath, 'utf8'));
rootPackageJson.version = version;
fs.writeFileSync(this.rootPackagePath, JSON.stringify(rootPackageJson, null, 2) + '\n');
}
/**
* Update Python package version
*/
setPyVersion(version) {
// Update __init__.py (this is where hatch reads the version from)
let initContent = fs.readFileSync(this.pyInitPath, 'utf8');
initContent = initContent.replace(
/__version__ = "(.*?)"/,
`__version__ = "${version}"`
);
fs.writeFileSync(this.pyInitPath, initContent);
// Don't update pyproject.toml as it uses dynamic versioning from __init__.py
const isCI = process.env.CI || process.env.GITHUB_ACTIONS;
if (!isCI) {
console.log(` Updated Python version in ${this.pyInitPath}`);
}
}
/**
* Bump version based on release type
*/
bumpVersion(currentVersion, releaseType, channel = 'stable') {
// Validate current version format
if (!currentVersion || !this.validateVersion(currentVersion.replace(/-.*$/, ''))) {
throw new Error(`Invalid current version format: ${currentVersion}`);
}
const [major, minor, patch] = currentVersion.split('-')[0].split('.').map(Number);
// Validate parsed version numbers
if (isNaN(major) || isNaN(minor) || isNaN(patch)) {
throw new Error(`Failed to parse version numbers from: ${currentVersion}`);
}
let newVersion;
switch (releaseType) {
case 'major':
newVersion = `${major + 1}.0.0`;
break;
case 'minor':
newVersion = `${major}.${minor + 1}.0`;
break;
case 'patch':
newVersion = `${major}.${minor}.${patch + 1}`;
break;
default:
throw new Error(`Unknown release type: ${releaseType}`);
}
// Add prerelease identifier
if (channel === 'beta') {
newVersion += '-beta.1';
} else if (channel === 'alpha') {
newVersion += '-alpha.1';
}
// Handle prerelease increments
if (currentVersion.includes('-')) {
const [baseVersion, prerelease] = currentVersion.split('-');
const [prereleaseType, prereleaseNumber] = prerelease.split('.');
if (channel === prereleaseType) {
// Increment prerelease number
const newPrereleaseNumber = parseInt(prereleaseNumber) + 1;
newVersion = `${baseVersion}-${prereleaseType}.${newPrereleaseNumber}`;
}
}
return newVersion;
}
/**
* Parse commit message to determine release type and channel
*/
parseCommitMessage(message) {
// Traditional release patterns
const releasePatterns = {
'patch': /^release: patch /,
'minor': /^release: minor /,
'major': /^release: major /,
'beta-patch': /^release: beta-patch /,
'beta-minor': /^release: beta-minor /,
'beta-major': /^release: beta-major /,
'alpha-patch': /^release: alpha-patch /,
'alpha-minor': /^release: alpha-minor /,
'alpha-major': /^release: alpha-major /,
};
for (const [type, pattern] of Object.entries(releasePatterns)) {
if (pattern.test(message)) {
const [channel, releaseType] = type.includes('-')
? type.split('-')
: ['stable', type];
return { releaseType, channel };
}
}
// Conventional commit patterns (semantic release style)
const conventionalPatterns = {
feat: 'minor', // new features
fix: 'patch', // bug fixes
perf: 'patch', // performance improvements
docs: 'patch', // documentation changes
style: 'patch', // formatting changes
refactor: 'patch', // code refactoring
test: 'patch', // adding tests
chore: 'patch', // maintenance tasks
};
// Check for breaking changes (major release)
if (message.includes('BREAKING CHANGE') || message.includes('!:')) {
return { releaseType: 'major', channel: 'stable' };
}
// Check for conventional commit types
for (const [type, releaseType] of Object.entries(conventionalPatterns)) {
// Pattern to match emoji + conventional commit format or just conventional commit
const pattern = new RegExp(`(^|\\s)(${type})(\\(.*?\\))?!?:\\s+`, 'i');
if (pattern.test(message)) {
return { releaseType, channel: 'stable' };
}
}
return null;
}
/**
* Get npm dist tag for version
*/
getNpmTag(version) {
if (version.includes('-beta')) return 'beta';
if (version.includes('-alpha')) return 'alpha';
return 'latest';
}
/**
* Get PyPI classifier for version
*/
getPyPIClassifier(version) {
if (version.includes('-alpha')) return '3 - Alpha';
if (version.includes('-beta')) return '4 - Beta';
return '5 - Production/Stable';
}
/**
* Sync versions between packages
*/
syncVersions(targetVersion = null) {
const jsVersion = this.getJsVersion();
const pyVersion = this.getPyVersion();
// Validate versions aren't 0.0.0
if (jsVersion === '0.0.0') {
throw new Error('JavaScript package version is 0.0.0 - this indicates a parsing error');
}
if (pyVersion === '0.0.0' && !targetVersion) {
console.warn('Warning: Python version is 0.0.0, using JavaScript version as source of truth');
}
console.log(`π¦ Current versions:`);
console.log(` JavaScript: ${jsVersion}`);
console.log(` Python: ${pyVersion}`);
if (targetVersion) {
console.log(`π― Setting both packages to: ${targetVersion}`);
this.setJsVersion(targetVersion);
this.setPyVersion(targetVersion);
} else if (jsVersion !== pyVersion) {
console.log(`β οΈ Version mismatch detected!`);
console.log(`π Syncing Python version to match JavaScript: ${jsVersion}`);
this.setPyVersion(jsVersion);
} else {
console.log(`β
Versions are already synchronized`);
}
}
/**
* Release with automatic version bumping
*/
release(releaseType, channel = 'stable') {
const currentVersion = this.getJsVersion();
const newVersion = this.bumpVersion(currentVersion, releaseType, channel);
// Check if running in CI environment (suppress verbose output)
const isCI = process.env.CI || process.env.GITHUB_ACTIONS;
if (!isCI) {
console.log(`π Releasing new ${channel} ${releaseType} version`);
console.log(` ${currentVersion} β ${newVersion}`);
}
// Update versions
this.setJsVersion(newVersion);
this.setPyVersion(newVersion);
if (!isCI) {
console.log(`β
Version bumped to: ${newVersion}`);
console.log(`π¦ npm tag: ${this.getNpmTag(newVersion)}`);
console.log(`π PyPI classifier: ${this.getPyPIClassifier(newVersion)}`);
}
return newVersion;
}
/**
* Auto-release based on commit message
*/
autoRelease() {
try {
// Get the last commit message
const lastCommit = execSync('git log -1 --pretty=%B', { encoding: 'utf8' }).trim();
console.log(`π Last commit: ${lastCommit}`);
const releaseInfo = this.parseCommitMessage(lastCommit);
if (releaseInfo) {
const { releaseType, channel } = releaseInfo;
console.log(`π― Detected ${channel} ${releaseType} release`);
return this.release(releaseType, channel);
} else {
console.log(`βΉοΈ No release pattern detected in commit message`);
return null;
}
} catch (error) {
console.error(`β Error in auto-release: ${error.message}`);
return null;
}
}
/**
* Validate version format
*/
validateVersion(version) {
const semverRegex = /^(\d+)\.(\d+)\.(\d+)(-((alpha|beta)\.(\d+)))?$/;
return semverRegex.test(version);
}
/**
* Display current status
*/
status() {
const jsVersion = this.getJsVersion();
const pyVersion = this.getPyVersion();
console.log(`π Version Status:`);
console.log(`βββββββββββββββββββ¬βββββββββββββββ`);
console.log(`β Package β Version β`);
console.log(`βββββββββββββββββββΌβββββββββββββββ€`);
console.log(`β JavaScript β ${jsVersion.padEnd(12)} β`);
console.log(`β Python β ${pyVersion.padEnd(12)} β`);
console.log(`βββββββββββββββββββ΄βββββββββββββββ`);
if (jsVersion === pyVersion) {
console.log(`β
Versions are synchronized`);
} else {
console.log(`β οΈ Versions are out of sync!`);
}
console.log(`\nπ·οΈ Tags:`);
console.log(` npm: ${this.getNpmTag(jsVersion)}`);
console.log(` PyPI: Development Status :: ${this.getPyPIClassifier(jsVersion)}`);
}
}
// CLI Interface
if (require.main === module) {
const versionSync = new VersionSync();
const args = process.argv.slice(2);
const command = args[0];
switch (command) {
case 'sync':
const targetVersion = args[1];
versionSync.syncVersions(targetVersion);
break;
case 'release':
const releaseType = args[1] || 'patch';
const channel = args[2] || 'stable';
if (!['patch', 'minor', 'major'].includes(releaseType)) {
console.error(`β Invalid release type: ${releaseType}`);
process.exit(1);
}
if (!['stable', 'beta', 'alpha'].includes(channel)) {
console.error(`β Invalid channel: ${channel}`);
process.exit(1);
}
const newVersion = versionSync.release(releaseType, channel);
// In CI, only output the version number for GitHub Actions
if (process.env.CI || process.env.GITHUB_ACTIONS) {
console.log(newVersion);
}
break;
case 'auto':
versionSync.autoRelease();
break;
case 'status':
versionSync.status();
break;
case 'detect':
try {
// Get the last commit message
const lastCommit = execSync('git log -1 --pretty=%B', { encoding: 'utf8' }).trim();
const releaseInfo = versionSync.parseCommitMessage(lastCommit);
if (releaseInfo) {
const { releaseType, channel } = releaseInfo;
// Output GitHub Actions compatible format
console.log(`should_release=true`);
console.log(`release_type=${releaseType}`);
console.log(`channel=${channel}`);
} else {
console.log(`should_release=false`);
}
} catch (error) {
console.log(`should_release=false`);
process.exit(1);
}
break;
case 'validate':
const version = args[1];
if (!version) {
console.error(`β Please provide a version to validate`);
process.exit(1);
}
if (versionSync.validateVersion(version)) {
console.log(`β
Version ${version} is valid`);
} else {
console.log(`β Version ${version} is invalid`);
process.exit(1);
}
break;
default:
console.log(`
π§ Version Sync Tool
Usage:
node scripts/sync-versions.js <command> [options]
Commands:
sync [version] Sync versions between packages (optionally to specific version)
release <type> [channel] Bump version and create release (patch|minor|major) [stable|beta|alpha]
auto Auto-release based on last commit message
detect Detect if last commit should trigger release (GitHub Actions format)
status Show current version status
validate <version> Validate version format
Examples:
node scripts/sync-versions.js sync
node scripts/sync-versions.js sync 1.2.3
node scripts/sync-versions.js release minor beta
node scripts/sync-versions.js auto
node scripts/sync-versions.js detect
node scripts/sync-versions.js status
`);
}
}
module.exports = VersionSync;