From 9ba1640c0712472b1640613765af4e0cb6777e22 Mon Sep 17 00:00:00 2001 From: matt korwel Date: Wed, 24 Sep 2025 21:02:00 -0700 Subject: [PATCH] Releasing: Version mgmt (#8964) Co-authored-by: gemini-cli-robot --- package-lock.json | 1 + package.json | 1 + scripts/get-release-version.js | 245 ++++++++++++++++++++-- scripts/releasing/create-patch-pr.js | 25 ++- scripts/tests/get-release-version.test.js | 244 ++++++++++++++++++++- 5 files changed, 478 insertions(+), 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index fb23f11e58..1a55d54832 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "npm-run-all": "^4.1.5", "prettier": "^3.5.3", "react-devtools-core": "^4.28.5", + "semver": "^7.7.2", "tsx": "^4.20.3", "typescript-eslint": "^8.30.1", "vitest": "^3.2.4", diff --git a/package.json b/package.json index 42d63e3c2b..b321dd3fd3 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "npm-run-all": "^4.1.5", "prettier": "^3.5.3", "react-devtools-core": "^4.28.5", + "semver": "^7.7.2", "tsx": "^4.20.3", "typescript-eslint": "^8.30.1", "vitest": "^3.2.4", diff --git a/scripts/get-release-version.js b/scripts/get-release-version.js index 862ef542dd..0e3d6164d5 100644 --- a/scripts/get-release-version.js +++ b/scripts/get-release-version.js @@ -8,6 +8,7 @@ import { execSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import semver from 'semver'; function getArgs() { const args = {}; @@ -21,10 +22,29 @@ function getArgs() { } function getLatestTag(pattern) { - const command = `git tag --sort=-creatordate -l '${pattern}' | head -n 1`; + const command = `git tag -l '${pattern}'`; try { - return execSync(command).toString().trim(); - } catch { + const tags = execSync(command) + .toString() + .trim() + .split('\n') + .filter(Boolean); + if (tags.length === 0) return ''; + + // Convert tags to versions (remove 'v' prefix) and sort by semver + const versions = tags + .map((tag) => tag.replace(/^v/, '')) + .filter((version) => semver.valid(version)) + .sort((a, b) => semver.rcompare(a, b)); // rcompare for descending order + + if (versions.length === 0) return ''; + + // Return the latest version with 'v' prefix restored + return `v${versions[0]}`; + } catch (error) { + console.error( + `Failed to get latest git tag for pattern "${pattern}": ${error.message}`, + ); return ''; } } @@ -33,11 +53,75 @@ function getVersionFromNPM(distTag) { const command = `npm view @google/gemini-cli version --tag=${distTag}`; try { return execSync(command).toString().trim(); - } catch { + } catch (error) { + console.error( + `Failed to get NPM version for dist-tag "${distTag}": ${error.message}`, + ); return ''; } } +function getAllVersionsFromNPM() { + const command = `npm view @google/gemini-cli versions --json`; + try { + const versionsJson = execSync(command).toString().trim(); + return JSON.parse(versionsJson); + } catch (error) { + console.error(`Failed to get all NPM versions: ${error.message}`); + return []; + } +} + +function detectRollbackAndGetBaseline(npmDistTag) { + // Get the current dist-tag version + const distTagVersion = getVersionFromNPM(npmDistTag); + if (!distTagVersion) return { baseline: '', isRollback: false }; + + // Get all published versions + const allVersions = getAllVersionsFromNPM(); + if (allVersions.length === 0) + return { baseline: distTagVersion, isRollback: false }; + + // Filter versions by type to match the dist-tag + let matchingVersions; + if (npmDistTag === 'latest') { + // Stable versions: no prerelease identifiers + matchingVersions = allVersions.filter( + (v) => semver.valid(v) && !semver.prerelease(v), + ); + } else if (npmDistTag === 'preview') { + // Preview versions: contain -preview + matchingVersions = allVersions.filter( + (v) => semver.valid(v) && v.includes('-preview'), + ); + } else if (npmDistTag === 'nightly') { + // Nightly versions: contain -nightly + matchingVersions = allVersions.filter( + (v) => semver.valid(v) && v.includes('-nightly'), + ); + } else { + // For other dist-tags, just use the dist-tag version + return { baseline: distTagVersion, isRollback: false }; + } + + if (matchingVersions.length === 0) + return { baseline: distTagVersion, isRollback: false }; + + // Sort by semver and get the highest existing version + matchingVersions.sort((a, b) => semver.rcompare(a, b)); + const highestExistingVersion = matchingVersions[0]; + + // Check if we're in a rollback scenario + const isRollback = semver.gt(highestExistingVersion, distTagVersion); + + return { + baseline: isRollback ? highestExistingVersion : distTagVersion, + isRollback, + distTagVersion, + highestExistingVersion, + }; +} + function verifyGitHubReleaseExists(tagName) { const command = `gh release view "${tagName}" --json tagName --jq .tagName`; try { @@ -54,20 +138,122 @@ function verifyGitHubReleaseExists(tagName) { } } -function getAndVerifyTags(npmDistTag, gitTagPattern) { - const latestVersion = getVersionFromNPM(npmDistTag); - const latestTag = getLatestTag(gitTagPattern); - if (`v${latestVersion}` !== latestTag) { - throw new Error( - `Discrepancy found! NPM ${npmDistTag} tag (${latestVersion}) does not match latest git ${npmDistTag} tag (${latestTag}).`, +function validateVersionConflicts(newVersion) { + // Check if the calculated version already exists in any of the 3 sources + const conflicts = []; + + // Check NPM - get all published versions + try { + const command = `npm view @google/gemini-cli versions --json`; + const versionsJson = execSync(command).toString().trim(); + const allVersions = JSON.parse(versionsJson); + if (allVersions.includes(newVersion)) { + conflicts.push(`NPM registry already has version ${newVersion}`); + } + } catch (error) { + console.warn( + `Failed to check NPM versions for conflicts: ${error.message}`, ); } - verifyGitHubReleaseExists(latestTag); - return { latestVersion, latestTag }; + + // Check Git tags + try { + const command = `git tag -l 'v${newVersion}'`; + const tagOutput = execSync(command).toString().trim(); + if (tagOutput === `v${newVersion}`) { + conflicts.push(`Git tag v${newVersion} already exists`); + } + } catch (error) { + console.warn(`Failed to check git tags for conflicts: ${error.message}`); + } + + // Check GitHub releases + try { + const command = `gh release view "v${newVersion}" --json tagName --jq .tagName`; + const output = execSync(command).toString().trim(); + if (output === `v${newVersion}`) { + conflicts.push(`GitHub release v${newVersion} already exists`); + } + } catch (error) { + // This is expected if the release doesn't exist - only warn on unexpected errors + const isExpectedNotFound = + error.message.includes('release not found') || + error.message.includes('Not Found') || + error.message.includes('not found') || + error.status === 1; // gh command exit code for not found + if (!isExpectedNotFound) { + console.warn( + `Failed to check GitHub releases for conflicts: ${error.message}`, + ); + } + } + + if (conflicts.length > 0) { + throw new Error( + `Version conflict! Cannot create ${newVersion}:\n${conflicts.join('\n')}`, + ); + } +} + +function getAndVerifyTags(npmDistTag, gitTagPattern) { + // Detect rollback scenarios and get the correct baseline + const rollbackInfo = detectRollbackAndGetBaseline(npmDistTag); + const baselineVersion = rollbackInfo.baseline; + + if (!baselineVersion) { + throw new Error(`Unable to determine baseline version for ${npmDistTag}`); + } + + const latestTag = getLatestTag(gitTagPattern); + + // In rollback scenarios, we don't require git tags to match the dist-tag + // Instead, we verify the baseline version exists as a git tag + if (!rollbackInfo.isRollback) { + // Normal scenario: NPM dist-tag should match latest git tag + if (`v${baselineVersion}` !== latestTag) { + throw new Error( + `Discrepancy found! NPM ${npmDistTag} tag (${baselineVersion}) does not match latest git ${npmDistTag} tag (${latestTag}).`, + ); + } + } else { + // Rollback scenario: warn about the rollback but don't fail + console.warn( + `Rollback detected! NPM ${npmDistTag} tag is ${rollbackInfo.distTagVersion}, but using ${baselineVersion} as baseline for next version calculation (highest existing version).`, + ); + + // Verify the baseline version has corresponding git tag + try { + const baselineTagExists = execSync(`git tag -l 'v${baselineVersion}'`) + .toString() + .trim(); + if (baselineTagExists !== `v${baselineVersion}`) { + throw new Error( + `Rollback scenario detected, but git tag v${baselineVersion} does not exist! This is required to calculate the next version.`, + ); + } + } catch (error) { + // If the git command itself failed, log the original error + console.error( + `Failed to check for git tag v${baselineVersion}: ${error.message}`, + ); + throw new Error( + `Rollback scenario detected, but git tag v${baselineVersion} does not exist! This is required to calculate the next version.`, + ); + } + } + + // Always verify GitHub release exists for the baseline version (not necessarily the dist-tag) + verifyGitHubReleaseExists(`v${baselineVersion}`); + + return { + latestVersion: baselineVersion, + latestTag: `v${baselineVersion}`, + rollbackInfo, + }; } function getNightlyVersion() { - const { latestVersion, latestTag } = getAndVerifyTags( + const { latestVersion, latestTag, rollbackInfo } = getAndVerifyTags( 'nightly', 'v*-nightly*', ); @@ -82,6 +268,7 @@ function getNightlyVersion() { releaseVersion: `${major}.${nextMinor}.0-nightly.${date}.${gitShortHash}`, npmTag: 'nightly', previousReleaseTag: latestTag, + rollbackInfo, }; } @@ -112,7 +299,7 @@ function getStableVersion(args) { releaseVersion = latestPreviewVersion.replace(/-preview.*/, ''); } - const { latestTag: previousStableTag } = getAndVerifyTags( + const { latestTag: previousStableTag, rollbackInfo } = getAndVerifyTags( 'latest', 'v[0-9].[0-9].[0-9]', ); @@ -121,6 +308,7 @@ function getStableVersion(args) { releaseVersion, npmTag: 'latest', previousReleaseTag: previousStableTag, + rollbackInfo, }; } @@ -143,7 +331,7 @@ function getPreviewVersion(args) { latestNightlyVersion.replace(/-nightly.*/, '') + '-preview.0'; } - const { latestTag: previousPreviewTag } = getAndVerifyTags( + const { latestTag: previousPreviewTag, rollbackInfo } = getAndVerifyTags( 'preview', 'v*-preview*', ); @@ -152,6 +340,7 @@ function getPreviewVersion(args) { releaseVersion, npmTag: 'preview', previousReleaseTag: previousPreviewTag, + rollbackInfo, }; } @@ -163,7 +352,10 @@ function getPatchVersion(patchFrom) { } const distTag = patchFrom === 'stable' ? 'latest' : 'preview'; const pattern = distTag === 'latest' ? 'v[0-9].[0-9].[0-9]' : 'v*-preview*'; - const { latestVersion, latestTag } = getAndVerifyTags(distTag, pattern); + const { latestVersion, latestTag, rollbackInfo } = getAndVerifyTags( + distTag, + pattern, + ); if (patchFrom === 'stable') { // For stable versions, increment the patch number: 0.5.4 -> 0.5.5 @@ -176,6 +368,7 @@ function getPatchVersion(patchFrom) { releaseVersion, npmTag: distTag, previousReleaseTag: latestTag, + rollbackInfo, }; } else { // For preview versions, increment the preview number: 0.6.0-preview.2 -> 0.6.0-preview.3 @@ -196,6 +389,7 @@ function getPatchVersion(patchFrom) { releaseVersion, npmTag: distTag, previousReleaseTag: latestTag, + rollbackInfo, }; } } @@ -222,10 +416,27 @@ export function getVersion(options = {}) { throw new Error(`Unknown release type: ${type}`); } - return { + // Validate that the calculated version doesn't conflict with existing versions + validateVersionConflicts(versionData.releaseVersion); + + // Include rollback information in the output if available + const result = { releaseTag: `v${versionData.releaseVersion}`, ...versionData, }; + + // Add rollback information to output if it exists + if (versionData.rollbackInfo && versionData.rollbackInfo.isRollback) { + result.rollbackDetected = { + rollbackScenario: true, + distTagVersion: versionData.rollbackInfo.distTagVersion, + highestExistingVersion: versionData.rollbackInfo.highestExistingVersion, + baselineUsed: versionData.rollbackInfo.baseline, + message: `Rollback detected: NPM tag was ${versionData.rollbackInfo.distTagVersion}, but using ${versionData.rollbackInfo.baseline} as baseline for next version calculation (highest existing version)`, + }; + } + + return result; } if (process.argv[1] === fileURLToPath(import.meta.url)) { diff --git a/scripts/releasing/create-patch-pr.js b/scripts/releasing/create-patch-pr.js index e647c8e22c..1a32a50b21 100644 --- a/scripts/releasing/create-patch-pr.js +++ b/scripts/releasing/create-patch-pr.js @@ -42,8 +42,8 @@ async function main() { run('git fetch --all --tags --prune', dryRun); - const latestTag = getLatestTag(channel); - console.log(`Found latest tag for ${channel}: ${latestTag}`); + const releaseInfo = getLatestReleaseInfo(channel); + const latestTag = releaseInfo.currentTag; const releaseBranch = `release/${latestTag}`; const hotfixBranch = `hotfix/${latestTag}/${channel}/cherry-pick-${commit.substring(0, 7)}`; @@ -260,17 +260,20 @@ function branchExists(branchName) { } } -function getLatestTag(channel) { - console.log(`Fetching latest tag for channel: ${channel}...`); - const pattern = - channel === 'stable' - ? '(contains("nightly") or contains("preview")) | not' - : '(contains("preview"))'; - const command = `gh release list --limit 30 --json tagName | jq -r '[.[] | select(.tagName | ${pattern})] | .[0].tagName'`; +function getLatestReleaseInfo(channel) { + console.log(`Fetching latest release info for channel: ${channel}...`); + const patchFrom = channel; // 'stable' or 'preview' + const command = `node scripts/get-release-version.js --type=patch --patch-from=${patchFrom}`; try { - return execSync(command).toString().trim(); + const result = JSON.parse(execSync(command).toString().trim()); + console.log(`Current ${channel} tag: ${result.previousReleaseTag}`); + console.log(`Next ${channel} version would be: ${result.releaseVersion}`); + return { + currentTag: result.previousReleaseTag, + nextVersion: result.releaseVersion, + }; } catch (err) { - console.error(`Failed to get latest tag for channel: ${channel}`); + console.error(`Failed to get release info for channel: ${channel}`); throw err; } } diff --git a/scripts/tests/get-release-version.test.js b/scripts/tests/get-release-version.test.js index 6e9e194433..7250219ec2 100644 --- a/scripts/tests/get-release-version.test.js +++ b/scripts/tests/get-release-version.test.js @@ -17,7 +17,7 @@ describe('getVersion', () => { }); const mockExecSync = (command) => { - // NPM Mocks + // NPM dist-tags - source of truth if (command.includes('npm view') && command.includes('--tag=latest')) return '0.4.1'; if (command.includes('npm view') && command.includes('--tag=preview')) @@ -25,14 +25,25 @@ describe('getVersion', () => { if (command.includes('npm view') && command.includes('--tag=nightly')) return '0.6.0-nightly.20250910.a31830a3'; - // Git Tag Mocks - if (command.includes("git tag --sort=-creatordate -l 'v[0-9].[0-9].[0-9]'")) - return 'v0.4.1'; - if (command.includes("git tag --sort=-creatordate -l 'v*-preview*'")) - return 'v0.5.0-preview.2'; - if (command.includes("git tag --sort=-creatordate -l 'v*-nightly*'")) + // NPM versions list - for conflict validation + if (command.includes('npm view') && command.includes('versions --json')) + return JSON.stringify([ + '0.4.1', + '0.5.0-preview.2', + '0.6.0-nightly.20250910.a31830a3', + ]); + + // Git Tag Mocks - with semantic sorting + if (command.includes("git tag -l 'v[0-9].[0-9].[0-9]'")) return 'v0.4.1'; + if (command.includes("git tag -l 'v*-preview*'")) return 'v0.5.0-preview.2'; + if (command.includes("git tag -l 'v*-nightly*'")) return 'v0.6.0-nightly.20250910.a31830a3'; + // Conflict validation - Git tag checks + if (command.includes("git tag -l 'v0.5.0'")) return ''; // Version doesn't exist yet + if (command.includes("git tag -l 'v0.4.2'")) return ''; // Version doesn't exist yet + if (command.includes("git tag -l 'v0.6.0-preview.0'")) return ''; // Version doesn't exist yet + // GitHub Release Mocks if (command.includes('gh release view "v0.4.1"')) return 'v0.4.1'; if (command.includes('gh release view "v0.5.0-preview.2"')) @@ -150,17 +161,230 @@ describe('getVersion', () => { }); }); + describe('Semver Sorting Edge Cases', () => { + it('should handle Git tag creation date vs semantic version sorting', () => { + const mockWithSemverGitSorting = (command) => { + // NPM dist-tags are correct (source of truth) + if (command.includes('npm view') && command.includes('--tag=latest')) + return '0.5.0'; // NPM correctly has 0.5.0 as latest + if (command.includes('npm view') && command.includes('--tag=preview')) + return '0.6.0-preview.2'; + if (command.includes('npm view') && command.includes('--tag=nightly')) + return '0.7.0-nightly.20250910.a31830a3'; + + // NPM versions list for conflict validation + if (command.includes('npm view') && command.includes('versions --json')) + return JSON.stringify([ + '0.0.77', // This was the problematic dev version + '0.4.1', + '0.5.0', + '0.6.0-preview.1', + '0.6.0-preview.2', + '0.7.0-nightly.20250910.a31830a3', + ]); + + // Git tags - test that semantic sorting works correctly + if (command.includes("git tag -l 'v[0-9].[0-9].[0-9]'")) + return 'v0.0.77\nv0.5.0\nv0.4.1'; // Multiple tags - should pick v0.5.0 semantically + if (command.includes("git tag -l 'v*-preview*'")) + return 'v0.6.0-preview.2'; + if (command.includes("git tag -l 'v*-nightly*'")) + return 'v0.7.0-nightly.20250910.a31830a3'; + + // Conflict validation - new versions don't exist yet + if (command.includes("git tag -l 'v0.5.1'")) return ''; + if (command.includes("git tag -l 'v0.6.0'")) return ''; + + // GitHub releases + if (command.includes('gh release view "v0.5.0"')) return 'v0.5.0'; + if (command.includes('gh release view "v0.6.0-preview.2"')) + return 'v0.6.0-preview.2'; + if ( + command.includes('gh release view "v0.7.0-nightly.20250910.a31830a3"') + ) + return 'v0.7.0-nightly.20250910.a31830a3'; + + // GitHub conflict validation - new versions don't exist + if (command.includes('gh release view "v0.5.1"')) + throw new Error('Not found'); + if (command.includes('gh release view "v0.6.0"')) + throw new Error('Not found'); + + // Git Hash Mock + if (command.includes('git rev-parse --short HEAD')) return 'd3bf8a3d'; + + return mockExecSync(command); + }; + + vi.mocked(execSync).mockImplementation(mockWithSemverGitSorting); + + // Test patch calculation - should be 0.5.1 from NPM's latest=0.5.0 + const patchResult = getVersion({ type: 'patch', 'patch-from': 'stable' }); + expect(patchResult.releaseVersion).toBe('0.5.1'); + expect(patchResult.previousReleaseTag).toBe('v0.5.0'); + + // Verify no rollback information is included in normal scenarios + expect(patchResult.rollbackDetected).toBeUndefined(); + + // Test stable calculation - should be 0.6.0 from preview + const stableResult = getVersion({ type: 'stable' }); + expect(stableResult.releaseVersion).toBe('0.6.0'); + expect(stableResult.previousReleaseTag).toBe('v0.5.0'); + + // Verify no rollback information for stable calculation either + expect(stableResult.rollbackDetected).toBeUndefined(); + }); + + it('should fail when git tags are not semver-sorted correctly', () => { + const mockWithIncorrectGitSorting = (command) => { + // NPM correctly returns 0.5.0 as latest + if (command.includes('npm view') && command.includes('--tag=latest')) + return '0.5.0'; + + // But git tag sorting returns wrong semantic version + if (command.includes("git tag -l 'v[0-9].[0-9].[0-9]'")) + return 'v0.4.1'; // This should cause a discrepancy error (NPM says 0.5.0) + + return mockExecSync(command); + }; + + vi.mocked(execSync).mockImplementation(mockWithIncorrectGitSorting); + + // This should throw because NPM says 0.5.0 but git tag sorting says v0.4.1 + expect(() => + getVersion({ type: 'patch', 'patch-from': 'stable' }), + ).toThrow( + 'Discrepancy found! NPM latest tag (0.5.0) does not match latest git latest tag (v0.4.1).', + ); + }); + + it('should handle rollback scenarios by using highest existing version', () => { + const mockWithRollback = (command) => { + // NPM dist-tag was rolled back from 0.5.0 to 0.4.1 due to issues + if (command.includes('npm view') && command.includes('--tag=latest')) + return '0.4.1'; // Rolled back version + if (command.includes('npm view') && command.includes('--tag=preview')) + return '0.6.0-preview.2'; + if (command.includes('npm view') && command.includes('--tag=nightly')) + return '0.7.0-nightly.20250910.a31830a3'; + + // NPM versions list shows 0.5.0 was published (but rolled back) + if (command.includes('npm view') && command.includes('versions --json')) + return JSON.stringify([ + '0.3.0', + '0.4.1', // Current dist-tag + '0.5.0', // Published but rolled back + '0.6.0-preview.1', + '0.6.0-preview.2', + '0.7.0-nightly.20250910.a31830a3', + ]); + + // Git tags show both versions exist + if (command.includes("git tag -l 'v[0-9].[0-9].[0-9]'")) + return 'v0.4.1\nv0.5.0'; // Both tags exist + if (command.includes("git tag -l 'v*-preview*'")) + return 'v0.6.0-preview.2'; + if (command.includes("git tag -l 'v*-nightly*'")) + return 'v0.7.0-nightly.20250910.a31830a3'; + + // Specific git tag checks for rollback validation + if (command.includes("git tag -l 'v0.5.0'")) return 'v0.5.0'; + + // Conflict validation - new versions don't exist yet + if (command.includes("git tag -l 'v0.5.1'")) return ''; + if (command.includes("git tag -l 'v0.6.0'")) return ''; + + // GitHub releases exist for both versions + if (command.includes('gh release view "v0.4.1"')) return 'v0.4.1'; + if (command.includes('gh release view "v0.5.0"')) return 'v0.5.0'; // Exists but rolled back + if (command.includes('gh release view "v0.6.0-preview.2"')) + return 'v0.6.0-preview.2'; + + // GitHub conflict validation - new versions don't exist + if (command.includes('gh release view "v0.5.1"')) + throw new Error('Not found'); + if (command.includes('gh release view "v0.6.0"')) + throw new Error('Not found'); + + // Git Hash Mock + if (command.includes('git rev-parse --short HEAD')) return 'd3bf8a3d'; + + return mockExecSync(command); + }; + + vi.mocked(execSync).mockImplementation(mockWithRollback); + + // Mock console.warn to capture rollback warning + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Test patch calculation - should be 0.5.1 (from rolled back 0.5.0, not current dist-tag 0.4.1) + const patchResult = getVersion({ type: 'patch', 'patch-from': 'stable' }); + expect(patchResult.releaseVersion).toBe('0.5.1'); // Fix for 0.5.0, not 0.4.2 + expect(patchResult.previousReleaseTag).toBe('v0.5.0'); // Uses highest existing, not dist-tag + + // Verify rollback information is included in output + expect(patchResult.rollbackDetected).toBeDefined(); + expect(patchResult.rollbackDetected.rollbackScenario).toBe(true); + expect(patchResult.rollbackDetected.distTagVersion).toBe('0.4.1'); + expect(patchResult.rollbackDetected.highestExistingVersion).toBe('0.5.0'); + expect(patchResult.rollbackDetected.baselineUsed).toBe('0.5.0'); + expect(patchResult.rollbackDetected.message).toContain( + 'Rollback detected: NPM tag was 0.4.1, but using 0.5.0 as baseline for next version calculation', + ); + + // Verify rollback was detected and warning was shown + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Rollback detected! NPM latest tag is 0.4.1, but using 0.5.0 as baseline for next version calculation', + ), + ); + + // Test stable calculation - should be 0.6.0 from preview + const stableResult = getVersion({ type: 'stable' }); + expect(stableResult.releaseVersion).toBe('0.6.0'); + expect(stableResult.previousReleaseTag).toBe('v0.5.0'); // Uses rollback baseline + + consoleSpy.mockRestore(); + }); + + it('should fail rollback scenario when git tag for highest version is missing', () => { + const mockWithMissingGitTag = (command) => { + // NPM rolled back but git tag was deleted (bad practice) + if (command.includes('npm view') && command.includes('--tag=latest')) + return '0.4.1'; // Rolled back + + if (command.includes('npm view') && command.includes('versions --json')) + return JSON.stringify(['0.4.1', '0.5.0']); // 0.5.0 exists in NPM + + if (command.includes("git tag -l 'v[0-9].[0-9].[0-9]'")) + return 'v0.4.1'; // Only old tag exists + + if (command.includes("git tag -l 'v0.5.0'")) return ''; // Missing! + + return mockExecSync(command); + }; + + vi.mocked(execSync).mockImplementation(mockWithMissingGitTag); + + expect(() => + getVersion({ type: 'patch', 'patch-from': 'stable' }), + ).toThrow( + 'Rollback scenario detected, but git tag v0.5.0 does not exist! This is required to calculate the next version.', + ); + }); + }); + describe('Failure Path - Discrepancy Checks', () => { it('should throw an error if the git tag does not match npm', () => { const mockWithMismatchGitTag = (command) => { - if (command.includes("git tag --sort=-creatordate -l 'v*-preview*'")) - return 'v0.4.0-preview-99'; // Mismatch + if (command.includes("git tag -l 'v*-preview*'")) + return 'v0.4.0-preview.99'; // Mismatch with NPM's 0.5.0-preview.2 return mockExecSync(command); }; vi.mocked(execSync).mockImplementation(mockWithMismatchGitTag); expect(() => getVersion({ type: 'stable' })).toThrow( - 'Discrepancy found! NPM preview tag (0.5.0-preview.2) does not match latest git preview tag (v0.4.0-preview-99).', + 'Discrepancy found! NPM preview tag (0.5.0-preview.2) does not match latest git preview tag (v0.4.0-preview.99).', ); });