refactor: switch S4 to use Git Tags as SSOT, remove VERSION file
This commit is contained in:
@@ -788,6 +788,9 @@ program.command('version').alias('v')
|
|||||||
.option('-s, --stage <stage>', 'Set prerelease stage (dev, alpha, beta, rc, stable)')
|
.option('-s, --stage <stage>', 'Set prerelease stage (dev, alpha, beta, rc, stable)')
|
||||||
.option('-n, --dry-run', 'Show next version without applying')
|
.option('-n, --dry-run', 'Show next version without applying')
|
||||||
.option('-i, --info', 'Show info about current version')
|
.option('-i, --info', 'Show info about current version')
|
||||||
|
.option('--list', 'List S4 tags (hides dev tags by default)')
|
||||||
|
.option('--prune', 'Prune old dev tags (keeps last 10)')
|
||||||
|
.option('--all', 'Show all tags (including dev) when listing')
|
||||||
.action(async (type, options) => {
|
.action(async (type, options) => {
|
||||||
await ensureRepo();
|
await ensureRepo();
|
||||||
if (!versionCmd) initializeComponents();
|
if (!versionCmd) initializeComponents();
|
||||||
|
|||||||
+106
-51
@@ -12,17 +12,35 @@ class VersionCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async run(type = 'auto', options = {}) {
|
async run(type = 'auto', options = {}) {
|
||||||
const { info, dryRun, stage } = options;
|
const { info, dryRun, stage, list, prune, all } = options;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Get current version
|
// --- Special Commands: List & Prune ---
|
||||||
const currentVersion = await this.getCurrentVersion();
|
if (list) {
|
||||||
|
await this.listTags(all);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prune) {
|
||||||
|
await this.pruneTags();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main Bump Logic ---
|
||||||
|
|
||||||
|
// 1. Get current version from Git Tags (SSOT)
|
||||||
|
let currentVersion = await this.s4.getCurrentVersion();
|
||||||
|
let source = 'git-tag';
|
||||||
|
|
||||||
|
// Fallback: If no tags, check package.json just for migration base
|
||||||
if (!currentVersion) {
|
if (!currentVersion) {
|
||||||
throw new Error('Could not find version in package.json or VERSION file');
|
currentVersion = this.getPackageJsonVersion() || '0.0.0';
|
||||||
|
source = 'package.json (fallback)';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Info mode
|
// 2. Info mode
|
||||||
if (info) {
|
if (info) {
|
||||||
|
console.log(`Current Version: ${color.green(currentVersion)} (${source})`);
|
||||||
this.showInfo(currentVersion);
|
this.showInfo(currentVersion);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -41,56 +59,36 @@ class VersionCommand {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Update files
|
// 5. Execution (Tag + .env)
|
||||||
console.log(`\nBumping version: ${color.dim(currentVersion)} → ${color.green(nextVersion)}`);
|
console.log(`\nBumping version: ${color.dim(currentVersion)} → ${color.green(nextVersion)}`);
|
||||||
|
|
||||||
const updatedFiles = [];
|
const updatedFiles = [];
|
||||||
|
|
||||||
// Update package.json
|
// Update .env (Only if exists)
|
||||||
if (this.updatePackageJson(nextVersion)) updatedFiles.push('package.json');
|
|
||||||
|
|
||||||
// Update .env
|
|
||||||
const envUpdates = this.updateEnvFiles(nextVersion);
|
const envUpdates = this.updateEnvFiles(nextVersion);
|
||||||
updatedFiles.push(...envUpdates);
|
updatedFiles.push(...envUpdates);
|
||||||
|
|
||||||
// Create VERSION file if it doesn't exist or update it
|
|
||||||
fs.writeFileSync('VERSION', nextVersion);
|
|
||||||
updatedFiles.push('VERSION');
|
|
||||||
|
|
||||||
if (updatedFiles.length > 0) {
|
if (updatedFiles.length > 0) {
|
||||||
console.log(`${color.green('✔')} Updated ${updatedFiles.join(', ')}`);
|
console.log(`${color.green('✔')} Updated ${updatedFiles.join(', ')}`);
|
||||||
|
// Stage .env updates if any (usually gitignored, but if not...)
|
||||||
|
// We won't auto-commit them to avoid messing with user's git config too much logic
|
||||||
|
// But usually .env is ignored.
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Git commit and tag
|
// 6. Create Git Tag (The Truth)
|
||||||
await this.createRelease(nextVersion, updatedFiles);
|
await this.createTag(nextVersion);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Progress.error(`Version bump failed: ${error.message}`);
|
Progress.error(`Version bump failed: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCurrentVersion() {
|
getPackageJsonVersion() {
|
||||||
// Try package.json
|
|
||||||
if (fs.existsSync('package.json')) {
|
if (fs.existsSync('package.json')) {
|
||||||
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
||||||
if (pkg.version) return pkg.version;
|
return pkg.version;
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
// Try VERSION file
|
|
||||||
if (fs.existsSync('VERSION')) {
|
|
||||||
return fs.readFileSync('VERSION', 'utf8').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
return '0.0.0'; // Default start
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePackageJson(newVersion) {
|
|
||||||
if (!fs.existsSync('package.json')) return false;
|
|
||||||
|
|
||||||
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
|
||||||
pkg.version = newVersion;
|
|
||||||
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateEnvFiles(newVersion) {
|
updateEnvFiles(newVersion) {
|
||||||
@@ -102,14 +100,12 @@ class VersionCommand {
|
|||||||
let content = fs.readFileSync(file, 'utf8');
|
let content = fs.readFileSync(file, 'utf8');
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
|
||||||
// Match VERSION=... or APP_VERSION=...
|
// Match common version vars
|
||||||
const regex = /^(VERSION|APP_VERSION|NEXT_PUBLIC_VERSION)=(.*)$/gm;
|
const regex = /^(VERSION|APP_VERSION|NEXT_PUBLIC_VERSION|REACT_APP_VERSION|S4_VERSION)=(.*)$/gm;
|
||||||
|
|
||||||
if (regex.test(content)) {
|
if (regex.test(content)) {
|
||||||
content = content.replace(regex, `$1=${newVersion}`);
|
content = content.replace(regex, `$1=${newVersion}`);
|
||||||
changed = true;
|
changed = true;
|
||||||
} else {
|
|
||||||
// Optionally append if missing? Maybe too aggressive.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (changed) {
|
if (changed) {
|
||||||
@@ -122,25 +118,84 @@ class VersionCommand {
|
|||||||
return updates;
|
return updates;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createRelease(version, files) {
|
async createTag(version) {
|
||||||
try {
|
try {
|
||||||
// Stage files
|
// Create lightweight or annotated tag? S4 recommends annotated.
|
||||||
await this.git.add(files);
|
|
||||||
|
|
||||||
// Commit
|
|
||||||
const msg = `chore(release): bump to ${version}`;
|
|
||||||
await this.git.commit(msg);
|
|
||||||
|
|
||||||
// Tag
|
|
||||||
await this.git.addAnnotatedTag(version, `Release ${version}`);
|
await this.git.addAnnotatedTag(version, `Release ${version}`);
|
||||||
|
console.log(`${color.green('✔')} Created git tag: ${color.cyan(version)}`);
|
||||||
console.log(`${color.green('✔')} Created git commit and tag: ${color.cyan(version)}`);
|
console.log(`\nRun ${color.cyan('g push --tags')} to publish`);
|
||||||
console.log(`\nRun ${color.cyan('g push --follow-tags')} to publish`);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(color.yellow(`⚠ Git operations failed: ${e.message}`));
|
console.log(color.yellow(`⚠ Git tag failed: ${e.message}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listTags(showAll) {
|
||||||
|
const tags = await this.git.tags();
|
||||||
|
if (!tags.all.length) {
|
||||||
|
console.log('No tags found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(color.bold('\nRelease History:'));
|
||||||
|
|
||||||
|
// Reverse order (newest first)
|
||||||
|
// Filter out dev tags unless --all
|
||||||
|
const filtered = tags.all.filter(t => {
|
||||||
|
if (showAll) return true;
|
||||||
|
return !t.includes('-dev.');
|
||||||
|
}).reverse();
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
console.log(color.dim('No stable/beta/rc tags found. Use --all to see dev tags.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered.forEach(t => {
|
||||||
|
console.log(`- ${t}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async pruneTags() {
|
||||||
|
Progress.start('Pruning old dev tags...');
|
||||||
|
const tags = await this.git.tags();
|
||||||
|
const allTags = tags.all;
|
||||||
|
|
||||||
|
// Identify dev tags
|
||||||
|
const devTags = allTags.filter(t => t.includes('-dev.'));
|
||||||
|
|
||||||
|
// Sort them ensuring we keep the newest
|
||||||
|
// (Using simple string sort works roughly if S4 format is strictly kept,
|
||||||
|
// but better to rely on S4 parse if possible. For simplicity here, we assume standard S4)
|
||||||
|
|
||||||
|
// We want to KEEP the last 10 dev tags.
|
||||||
|
if (devTags.length <= 10) {
|
||||||
|
Progress.stop('');
|
||||||
|
console.log('Fewer than 10 dev tags. No pruning needed.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identify tags to delete (older ones)
|
||||||
|
// S4 strings sort chronologically well enough lexicographically mostly,
|
||||||
|
// but let's trust the array order returned by git tags usually...
|
||||||
|
// actually git tags return is arbitrary.
|
||||||
|
|
||||||
|
// Let's use our S4 sorter from utility if possible, or just simple sort
|
||||||
|
// Assuming S4Versioning.getCurrentVersion logic uses sort, we can reuse it?
|
||||||
|
// Or just simple sort for now.
|
||||||
|
devTags.sort(); // Lexicographical sort (older dates come first: 2025... < 2026...)
|
||||||
|
|
||||||
|
const toKeep = 10;
|
||||||
|
const toDelete = devTags.slice(0, devTags.length - toKeep);
|
||||||
|
|
||||||
|
for (const tag of toDelete) {
|
||||||
|
await this.git.tag(['-d', tag]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Progress.stop('');
|
||||||
|
console.log(`${color.green('✔')} Deleted ${toDelete.length} old dev tags.`);
|
||||||
|
console.log(color.dim('Kept last 10 dev tags + all stable tags.'));
|
||||||
|
}
|
||||||
|
|
||||||
showInfo(versionStr) {
|
showInfo(versionStr) {
|
||||||
const parsed = S4Versioning.parse(versionStr);
|
const parsed = S4Versioning.parse(versionStr);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
|
|||||||
+62
-10
@@ -16,6 +16,7 @@ class S4Versioning {
|
|||||||
* Parse an S4 version string into components
|
* Parse an S4 version string into components
|
||||||
*/
|
*/
|
||||||
static parse(versionStr) {
|
static parse(versionStr) {
|
||||||
|
if (!versionStr) return null;
|
||||||
const match = versionStr.match(S4Versioning.REGEX);
|
const match = versionStr.match(S4Versioning.REGEX);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
|
|
||||||
@@ -41,6 +42,62 @@ class S4Versioning {
|
|||||||
return `${major}.${minor}.${patch}-${stage}.${build}+${date}.${time}.${commit}.${branch}`;
|
return `${major}.${minor}.${patch}-${stage}.${build}+${date}.${time}.${commit}.${branch}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the latest S4 version from Git Tags
|
||||||
|
*/
|
||||||
|
async getCurrentVersion() {
|
||||||
|
try {
|
||||||
|
// Get all tags
|
||||||
|
const tags = await this.git.tags();
|
||||||
|
if (!tags.all || tags.all.length === 0) {
|
||||||
|
return null; // No tags yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter and sort S4 tags
|
||||||
|
// Sorting strategy: We rely on the date+time in the S4 string for chronological order
|
||||||
|
// but S4 strings format puts SemVer first.
|
||||||
|
// Let's parse all valid S4 tags and sort them.
|
||||||
|
|
||||||
|
const parsedTags = tags.all
|
||||||
|
.map(t => ({ tag: t, parsed: S4Versioning.parse(t) }))
|
||||||
|
.filter(item => item.parsed !== null);
|
||||||
|
|
||||||
|
if (parsedTags.length === 0) return null;
|
||||||
|
|
||||||
|
// Sort logic:
|
||||||
|
// 1. Major.Minor.Patch
|
||||||
|
// 2. Date.Time
|
||||||
|
// 3. Build
|
||||||
|
|
||||||
|
parsedTags.sort((a, b) => {
|
||||||
|
const pA = a.parsed;
|
||||||
|
const pB = b.parsed;
|
||||||
|
|
||||||
|
if (pA.major !== pB.major) return pA.major - pB.major;
|
||||||
|
if (pA.minor !== pB.minor) return pA.minor - pB.minor;
|
||||||
|
if (pA.patch !== pB.patch) return pA.patch - pB.patch;
|
||||||
|
|
||||||
|
// Compare Date (YYYYMMDD)
|
||||||
|
const dateA = parseInt(pA.date);
|
||||||
|
const dateB = parseInt(pB.date);
|
||||||
|
if (dateA !== dateB) return dateA - dateB;
|
||||||
|
|
||||||
|
// Compare Time (HHMM)
|
||||||
|
const timeA = parseInt(pA.time);
|
||||||
|
const timeB = parseInt(pB.time);
|
||||||
|
if (timeA !== timeB) return timeA - timeB;
|
||||||
|
|
||||||
|
return pA.build - pB.build;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return the latest
|
||||||
|
return parsedTags[parsedTags.length - 1].tag;
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Migrate a standard SemVer or empty version to S4
|
* Migrate a standard SemVer or empty version to S4
|
||||||
*/
|
*/
|
||||||
@@ -114,11 +171,7 @@ class S4Versioning {
|
|||||||
// Handle stage change
|
// Handle stage change
|
||||||
if (stageVal && stageVal !== parsed.stage) {
|
if (stageVal && stageVal !== parsed.stage) {
|
||||||
next.stage = stageVal;
|
next.stage = stageVal;
|
||||||
next.build = 1; // Stage change resets build usually? S4 "Sequence" implies uniqueness.
|
next.build = 1;
|
||||||
// S4 doc: "When to Increment: Each new build of the same version on the same day"
|
|
||||||
// Changing stage is effectively a new version identity.
|
|
||||||
} else {
|
|
||||||
// Keep existing stage if not specified
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logic for bumping
|
// Logic for bumping
|
||||||
@@ -147,14 +200,13 @@ class S4Versioning {
|
|||||||
}
|
}
|
||||||
// 2. If Date is SAME
|
// 2. If Date is SAME
|
||||||
else {
|
else {
|
||||||
// If Commit Changed -> It's a new build of the same day
|
// If Commit Changed -> new build
|
||||||
if (parsed.commit !== ctx.commit) {
|
if (parsed.commit !== ctx.commit) {
|
||||||
next.build = parsed.build + 1;
|
next.build = parsed.build + 1;
|
||||||
}
|
}
|
||||||
// If Commit is SAME and user requested bump, force build increment?
|
// If same commit & same date, usually we don't need a new tag
|
||||||
// "auto" implies we just want the "next" logical version for the current state.
|
// unless user forces it.
|
||||||
// If nothing changed, maybe we shouldn't bump?
|
// For safety, increment build to ensure uniqueness if they insisted on running `g v`.
|
||||||
// But usually CI calls this to FORCE a new version.
|
|
||||||
else {
|
else {
|
||||||
next.build = parsed.build + 1;
|
next.build = parsed.build + 1;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user