diff --git a/scripts/get-release-version.js b/scripts/get-release-version.js index 4edef1dada..96e94a592f 100644 --- a/scripts/get-release-version.js +++ b/scripts/get-release-version.js @@ -21,10 +21,10 @@ function getVersionFromNPM(distTag) { try { return execSync(command).toString().trim(); } catch (error) { - console.error( - `Failed to get NPM version for dist-tag "${distTag}": ${error.message}`, - ); - return ''; + if (error.message?.includes('E404')) { + return ''; + } + throw error; } } @@ -34,8 +34,10 @@ function getAllVersionsFromNPM() { const versionsJson = execSync(command).toString().trim(); return JSON.parse(versionsJson); } catch (error) { - console.error(`Failed to get all NPM versions: ${error.message}`); - return []; + if (error.message?.includes('E404')) { + return []; + } + throw error; } } @@ -56,10 +58,66 @@ function isVersionDeprecated(version) { 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(); + let allVersions; + try { + allVersions = getAllVersionsFromNPM(); + } catch (error) { + if (distTagVersion) { + console.error( + `Could not fetch versions list, proceeding with dist-tag: ${error.message}`, + ); + return { baseline: distTagVersion, isRollback: false }; + } + throw error; + } + + if (!distTagVersion) { + // Dist-tag is missing — try to derive baseline from published versions + if (allVersions.length === 0) return { baseline: '', isRollback: false }; + + let matchingVersions; + if (npmDistTag === 'latest') { + matchingVersions = allVersions.filter( + (v) => semver.valid(v) && !semver.prerelease(v), + ); + } else if (npmDistTag === 'preview') { + matchingVersions = allVersions.filter( + (v) => semver.valid(v) && v.includes('-preview'), + ); + } else if (npmDistTag === 'nightly') { + matchingVersions = allVersions.filter( + (v) => semver.valid(v) && v.includes('-nightly'), + ); + } else { + return { baseline: '', isRollback: false }; + } + + if (matchingVersions.length === 0) + return { baseline: '', isRollback: false }; + + matchingVersions.sort((a, b) => semver.rcompare(a, b)); + + let highestExistingVersion = ''; + for (const version of matchingVersions) { + if (!isVersionDeprecated(version)) { + highestExistingVersion = version; + break; + } else { + console.error(`Ignoring deprecated version: ${version}`); + } + } + + if (!highestExistingVersion) return { baseline: '', isRollback: false }; + + return { + baseline: highestExistingVersion, + isRollback: false, + highestExistingVersion, + }; + } + if (allVersions.length === 0) return { baseline: distTagVersion, isRollback: false }; @@ -168,7 +226,10 @@ function getAndVerifyTags(npmDistTag, _gitTagPattern) { const baselineVersion = rollbackInfo.baseline; if (!baselineVersion) { - throw new Error(`Unable to determine baseline version for ${npmDistTag}`); + console.error( + `No baseline version found for dist-tag "${npmDistTag}" — returning null`, + ); + return null; } if (rollbackInfo.isRollback) { @@ -188,8 +249,8 @@ function getAndVerifyTags(npmDistTag, _gitTagPattern) { function getLatestStableReleaseTag() { try { - const { latestTag } = getAndVerifyTags('latest', 'v[0-9].[0-9].[0-9]'); - return latestTag; + const result = getAndVerifyTags('latest', 'v[0-9].[0-9].[0-9]'); + return result ? result.latestTag : ''; } catch (error) { console.error( `Failed to determine latest stable release tag: ${error.message}`, @@ -199,8 +260,13 @@ function getLatestStableReleaseTag() { } function promoteNightlyVersion() { - const { latestVersion } = getAndVerifyTags('nightly', 'v*-nightly*'); - const baseVersion = latestVersion.split('-')[0]; + const result = getAndVerifyTags('nightly', 'v*-nightly*'); + if (!result) { + throw new Error( + 'Unable to determine baseline version for nightly (required for promote-nightly)', + ); + } + const baseVersion = result.latestVersion.split('-')[0]; const versionParts = baseVersion.split('.'); const major = versionParts[0]; const minor = versionParts[1] ? parseInt(versionParts[1]) : 0; @@ -226,17 +292,29 @@ function getNightlyVersion() { } function getStableVersion(args) { - const { latestVersion: latestPreviewVersion } = getAndVerifyTags( - 'preview', - 'v*-preview*', - ); + const tagResult = getAndVerifyTags('preview', 'v*-preview*'); let releaseVersion; if (args.stable_version_override) { const overrideVersion = args.stable_version_override.replace(/^v/, ''); validateVersion(overrideVersion, 'X.Y.Z', 'stable_version_override'); releaseVersion = overrideVersion; + } else if (tagResult) { + releaseVersion = tagResult.latestVersion.replace(/-preview.*/, ''); + validateVersion(releaseVersion, 'X.Y.Z', 'derived from preview dist-tag'); + const latestStable = getVersionFromNPM('latest'); + if ( + latestStable && + semver.valid(latestStable) && + semver.gt(latestStable, releaseVersion) + ) { + throw new Error( + `Derived stable version ${releaseVersion} is lower than published latest ${latestStable}. Refusing retrograde baseline.`, + ); + } } else { - releaseVersion = latestPreviewVersion.replace(/-preview.*/, ''); + const packageJson = readJson('package.json'); + releaseVersion = packageJson.version.split('-')[0]; + validateVersion(releaseVersion, 'X.Y.Z', 'package.json version'); } return { @@ -246,10 +324,7 @@ function getStableVersion(args) { } function getPreviewVersion(args) { - const { latestVersion: latestNightlyVersion } = getAndVerifyTags( - 'nightly', - 'v*-nightly*', - ); + const tagResult = getAndVerifyTags('nightly', 'v*-nightly*'); let releaseVersion; if (args.preview_version_override) { const overrideVersion = args.preview_version_override.replace(/^v/, ''); @@ -259,9 +334,19 @@ function getPreviewVersion(args) { 'preview_version_override', ); releaseVersion = overrideVersion; - } else { + } else if (tagResult) { releaseVersion = - latestNightlyVersion.replace(/-nightly.*/, '') + '-preview.0'; + tagResult.latestVersion.replace(/-nightly.*/, '') + '-preview.0'; + validateVersion( + releaseVersion, + 'X.Y.Z-preview.N', + 'derived from nightly dist-tag', + ); + } else { + const packageJson = readJson('package.json'); + const baseVersion = packageJson.version.split('-')[0]; + releaseVersion = baseVersion + '-preview.0'; + validateVersion(baseVersion, 'X.Y.Z', 'package.json version'); } return { @@ -278,7 +363,13 @@ function getPatchVersion(patchFrom) { } const distTag = patchFrom === 'stable' ? 'latest' : 'preview'; const pattern = distTag === 'latest' ? 'v[0-9].[0-9].[0-9]' : 'v*-preview*'; - const { latestVersion } = getAndVerifyTags(distTag, pattern); + const tagResult = getAndVerifyTags(distTag, pattern); + if (!tagResult) { + throw new Error( + `Unable to determine baseline version for ${distTag} (required for patch)`, + ); + } + const { latestVersion } = tagResult; if (patchFrom === 'stable') { // For stable versions, increment the patch number: 0.5.4 -> 0.5.5 diff --git a/scripts/tests/get-release-version.test.js b/scripts/tests/get-release-version.test.js index 7adf003279..8c0f9eb70b 100644 --- a/scripts/tests/get-release-version.test.js +++ b/scripts/tests/get-release-version.test.js @@ -182,5 +182,358 @@ describe('getVersion', () => { // Should have skipped preview.0 and landed on preview.1 expect(result.releaseVersion).toBe('0.8.0-preview.1'); }); + + it('should fall back to package.json when no nightly dist-tag exists (preview)', () => { + const mockWithNoNightly = (command) => { + // No nightly dist-tag exists + if (command.includes('npm view') && command.includes('--tag=nightly')) { + throw new Error('npm error code E404'); + } + // Stable versions exist but no nightly dist-tag + if (command.includes('npm view') && command.includes('versions --json')) + return JSON.stringify(['0.6.0', '0.6.1']); + + return mockExecSync(command); + }; + vi.mocked(execSync).mockImplementation(mockWithNoNightly); + + const result = getVersion({ type: 'preview' }); + // Should fall back to package.json version (0.8.0) + -preview.0 + expect(result.releaseVersion).toBe('0.8.0-preview.0'); + expect(result.npmTag).toBe('preview'); + expect(result.previousReleaseTag).toBe('v0.6.1'); + }); + + it('should fall back to package.json when no preview dist-tag exists (stable)', () => { + const mockWithNoPreview = (command) => { + // No preview dist-tag exists + if (command.includes('npm view') && command.includes('--tag=preview')) { + throw new Error('npm error code E404'); + } + // Stable versions exist but no preview dist-tag + if (command.includes('npm view') && command.includes('versions --json')) + return JSON.stringify(['0.6.0', '0.6.1']); + + return mockExecSync(command); + }; + vi.mocked(execSync).mockImplementation(mockWithNoPreview); + + const result = getVersion({ type: 'stable' }); + // Should fall back to package.json version (0.8.0) + expect(result.releaseVersion).toBe('0.8.0'); + expect(result.npmTag).toBe('latest'); + expect(result.previousReleaseTag).toBe('v0.6.1'); + }); + + it('should throw when no nightly dist-tag exists (promote-nightly)', () => { + const mockWithNoNightly = (command) => { + if (command.includes('npm view') && command.includes('--tag=nightly')) { + throw new Error('npm error code E404'); + } + if (command.includes('npm view') && command.includes('versions --json')) + return JSON.stringify(['0.6.0', '0.6.1']); + + return mockExecSync(command); + }; + vi.mocked(execSync).mockImplementation(mockWithNoNightly); + + expect(() => getVersion({ type: 'promote-nightly' })).toThrow( + 'Unable to determine baseline version for nightly', + ); + }); + + it('should throw when no dist-tag exists (patch)', () => { + const mockWithNoLatest = (command) => { + if (command.includes('npm view') && command.includes('--tag=latest')) { + throw new Error('npm error code E404'); + } + if (command.includes('npm view') && command.includes('versions --json')) + return JSON.stringify([]); + + return mockExecSync(command); + }; + vi.mocked(execSync).mockImplementation(mockWithNoLatest); + + expect(() => + getVersion({ type: 'patch', 'patch-from': 'stable' }), + ).toThrow('Unable to determine baseline version for latest'); + }); + + it('should fall back to package.json in true greenfield scenario (all dist-tags missing)', () => { + const mockGreenfield = (command) => { + if ( + command.includes('npm view') && + command.includes('--tag=') && + !command.includes('versions --json') + ) { + throw new Error('npm error code E404'); + } + if (command.includes('npm view') && command.includes('versions --json')) + return JSON.stringify([]); + + return mockExecSync(command); + }; + vi.mocked(execSync).mockImplementation(mockGreenfield); + + const result = getVersion({ type: 'stable' }); + expect(result.releaseVersion).toBe('0.8.0'); + expect(result.npmTag).toBe('latest'); + expect(result.previousReleaseTag).toBe(''); + }); + + it('should handle E404 from versions list in true greenfield scenario', () => { + const mockGreenfieldVersionsE404 = (command) => { + if (command.includes('npm view') && command.includes('versions --json')) + throw new Error('npm error code E404'); + if ( + command.includes('npm view') && + command.includes('--tag=') && + !command.includes('versions --json') + ) { + throw new Error('npm error code E404'); + } + + return mockExecSync(command); + }; + vi.mocked(execSync).mockImplementation(mockGreenfieldVersionsE404); + + const result = getVersion({ type: 'stable' }); + expect(result.releaseVersion).toBe('0.8.0'); + expect(result.npmTag).toBe('latest'); + expect(result.previousReleaseTag).toBe(''); + }); + + it('should derive baseline from versions list when dist-tag is missing but versions exist', () => { + const mockWithVersionsButNoTag = (command) => { + if ( + command.includes('npm view') && + command.includes('--tag=preview') && + !command.includes('versions --json') + ) { + throw new Error('npm error code E404'); + } + if (command.includes('npm view') && command.includes('versions --json')) + return JSON.stringify([ + '0.6.0', + '0.6.1', + '0.7.0-preview.0', + '0.7.0-preview.3', + ]); + + return mockExecSync(command); + }; + vi.mocked(execSync).mockImplementation(mockWithVersionsButNoTag); + + const result = getVersion({ type: 'stable' }); + expect(result.releaseVersion).toBe('0.7.0'); + expect(result.npmTag).toBe('latest'); + expect(result.previousReleaseTag).toBe('v0.6.1'); + }); + + it('should propagate transient NPM errors from dist-tag lookup', () => { + const mockWithTransientError = (command) => { + if ( + command.includes('npm view') && + command.includes('--tag=preview') && + !command.includes('versions --json') + ) { + throw new Error('npm error code ETIMEDOUT'); + } + + return mockExecSync(command); + }; + vi.mocked(execSync).mockImplementation(mockWithTransientError); + + expect(() => getVersion({ type: 'stable' })).toThrow('ETIMEDOUT'); + }); + + it('should fall back to dist-tag when versions list lookup fails transiently', () => { + const mockWithTransientVersionsError = (command) => { + if ( + command.includes('npm view') && + command.includes('versions --json') + ) { + throw new Error('npm error code ECONNRESET'); + } + + return mockExecSync(command); + }; + vi.mocked(execSync).mockImplementation(mockWithTransientVersionsError); + + const result = getVersion({ type: 'stable' }); + expect(result.releaseVersion).toBe('0.7.0'); + expect(result.npmTag).toBe('latest'); + }); + + it('should propagate transient NPM errors from versions list when dist-tag is also missing', () => { + const mockWithBothFailing = (command) => { + if ( + command.includes('npm view') && + command.includes('--tag=preview') && + !command.includes('versions --json') + ) { + throw new Error('npm error code E404'); + } + if ( + command.includes('npm view') && + command.includes('versions --json') + ) { + throw new Error('npm error code ECONNRESET'); + } + + return mockExecSync(command); + }; + vi.mocked(execSync).mockImplementation(mockWithBothFailing); + + expect(() => getVersion({ type: 'stable' })).toThrow('ECONNRESET'); + }); + + it('should derive baseline from latest versions when latest dist-tag is missing', () => { + const mockWithNoLatestTag = (command) => { + if ( + command.includes('npm view') && + command.includes('--tag=latest') && + !command.includes('versions --json') + ) { + throw new Error('npm error code E404'); + } + if (command.includes('npm view') && command.includes('versions --json')) + return JSON.stringify(['0.5.0', '0.6.0', '0.6.1', '0.7.0-preview.0']); + + return mockExecSync(command); + }; + vi.mocked(execSync).mockImplementation(mockWithNoLatestTag); + + const result = getVersion({ type: 'patch', 'patch-from': 'stable' }); + expect(result.releaseVersion).toBe('0.6.2'); + expect(result.npmTag).toBe('latest'); + expect(result.previousReleaseTag).toBe('v0.6.1'); + }); + + it('should fall back to package.json when all matching versions are deprecated (no dist-tag)', () => { + const mockWithAllDeprecated = (command) => { + if ( + command.includes('npm view') && + command.includes('--tag=nightly') && + !command.includes('versions --json') + ) { + throw new Error('npm error code E404'); + } + if (command.includes('npm view') && command.includes('versions --json')) + return JSON.stringify(['0.7.0-nightly.1', '0.7.0-nightly.2']); + if (command.includes('deprecated')) return 'Deprecated'; + return mockExecSync(command); + }; + vi.mocked(execSync).mockImplementation(mockWithAllDeprecated); + + const result = getVersion({ type: 'preview' }); + expect(result.releaseVersion).toBe('0.8.0-preview.0'); + }); + + it('should throw when no preview dist-tag exists (patch)', () => { + const mockWithNoPreview = (command) => { + if ( + command.includes('npm view') && + command.includes('--tag=preview') && + !command.includes('versions --json') + ) { + throw new Error('npm error code E404'); + } + if (command.includes('npm view') && command.includes('versions --json')) + return JSON.stringify([]); + + return mockExecSync(command); + }; + vi.mocked(execSync).mockImplementation(mockWithNoPreview); + + expect(() => + getVersion({ type: 'patch', 'patch-from': 'preview' }), + ).toThrow('Unable to determine baseline version for preview'); + }); + + it('should derive preview from nightly versions when nightly dist-tag is missing', () => { + const mockWithNightliesButNoTag = (command) => { + if ( + command.includes('npm view') && + command.includes('--tag=nightly') && + !command.includes('versions --json') + ) { + throw new Error('npm error code E404'); + } + if (command.includes('npm view') && command.includes('versions --json')) + return JSON.stringify([ + '0.6.0', + '0.6.1', + '0.8.0-nightly.20250916.abcdef', + ]); + + return mockExecSync(command); + }; + vi.mocked(execSync).mockImplementation(mockWithNightliesButNoTag); + + const result = getVersion({ type: 'preview' }); + expect(result.releaseVersion).toBe('0.8.0-preview.0'); + expect(result.npmTag).toBe('preview'); + expect(result.previousReleaseTag).toBe('v0.6.1'); + }); + + it('should reject an invalid stable version derived from preview', () => { + const mockWithInvalidPreview = (command) => { + if (command.includes('npm view') && command.includes('--tag=preview')) + return 'invalid-preview.0'; + if (command.includes('npm view') && command.includes('versions --json')) + return JSON.stringify([]); + + return mockExecSync(command); + }; + vi.mocked(execSync).mockImplementation(mockWithInvalidPreview); + + expect(() => getVersion({ type: 'stable' })).toThrow( + 'Invalid derived from preview dist-tag: invalid', + ); + }); + + it('should reject an invalid preview version derived from nightly', () => { + const mockWithInvalidNightly = (command) => { + if (command.includes('npm view') && command.includes('--tag=nightly')) + return 'invalid-nightly.20250916.abcdef'; + if (command.includes('npm view') && command.includes('versions --json')) + return JSON.stringify([]); + + return mockExecSync(command); + }; + vi.mocked(execSync).mockImplementation(mockWithInvalidNightly); + + expect(() => getVersion({ type: 'preview' })).toThrow( + 'Invalid derived from nightly dist-tag: invalid-preview.0', + ); + }); + + it('should reject a stable release derived below the published latest version', () => { + const mockWithOlderPreviewVersions = (command) => { + if ( + command.includes('npm view') && + command.includes('--tag=preview') && + !command.includes('versions --json') + ) { + throw new Error('npm error code E404'); + } + if (command.includes('npm view') && command.includes('--tag=latest')) + return '0.9.0'; + if (command.includes('npm view') && command.includes('versions --json')) + return JSON.stringify([ + '0.7.0-preview.0', + '0.7.0-preview.1', + '0.9.0', + ]); + + return mockExecSync(command); + }; + vi.mocked(execSync).mockImplementation(mockWithOlderPreviewVersions); + + expect(() => getVersion({ type: 'stable' })).toThrow( + 'Derived stable version 0.7.0 is lower than published latest 0.9.0', + ); + }); }); });