Skip to content
112 changes: 88 additions & 24 deletions scripts/get-release-version.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') || error.message?.includes('404')) {
return '';
}
throw error;
}
}

Expand All @@ -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') || error.message?.includes('404')) {
return [];
}
throw error;
}
}

Expand All @@ -56,10 +58,55 @@ 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();

if (!distTagVersion) {
// Dist-tag is missing — try to derive baseline from published versions
if (allVersions.length === 0) return { baseline: '', isRollback: false };

let matchingVersions;
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
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 };

Expand Down Expand Up @@ -168,7 +215,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) {
Expand All @@ -188,8 +238,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}`,
Expand All @@ -199,8 +249,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;
Expand All @@ -226,17 +281,18 @@ 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.*/, '');
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
} else {
releaseVersion = latestPreviewVersion.replace(/-preview.*/, '');
const packageJson = readJson('package.json');
releaseVersion = packageJson.version.split('-')[0];
validateVersion(releaseVersion, 'X.Y.Z', 'package.json version');
}

return {
Expand All @@ -246,10 +302,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/, '');
Expand All @@ -259,9 +312,14 @@ function getPreviewVersion(args) {
'preview_version_override',
);
releaseVersion = overrideVersion;
} else {
} else if (tagResult) {
releaseVersion =
latestNightlyVersion.replace(/-nightly.*/, '') + '-preview.0';
tagResult.latestVersion.replace(/-nightly.*/, '') + '-preview.0';
} 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 {
Expand All @@ -278,7 +336,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
Expand Down
180 changes: 180 additions & 0 deletions scripts/tests/get-release-version.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,5 +182,185 @@ 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)', () => {
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
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'))
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
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 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');
});
Comment thread
yiliang114 marked this conversation as resolved.
Resolved

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 propagate transient NPM errors from versions list lookup', () => {
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);

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');
});
});
});
Loading