From 4afe7bd97166f466bbf4dc6d2343d4d25aa67493 Mon Sep 17 00:00:00 2001 From: Qwen Code Autofix Date: Tue, 7 Jul 2026 16:47:54 +0000 Subject: [PATCH 1/6] fix(scripts): handle missing NPM dist-tags gracefully in release versioning (#6476) getAndVerifyTags now returns null instead of throwing when no baseline version exists on NPM. getPreviewVersion and getStableVersion fall back to the package.json base version, matching the pattern already used by getNightlyVersion. This prevents the release workflow from failing when no nightly or preview dist-tag has been published yet. --- scripts/get-release-version.js | 45 ++++++++++++++--------- scripts/tests/get-release-version.test.js | 42 +++++++++++++++++++++ 2 files changed, 70 insertions(+), 17 deletions(-) diff --git a/scripts/get-release-version.js b/scripts/get-release-version.js index 4edef1dada5..e723328bc4f 100644 --- a/scripts/get-release-version.js +++ b/scripts/get-release-version.js @@ -168,7 +168,7 @@ function getAndVerifyTags(npmDistTag, _gitTagPattern) { const baselineVersion = rollbackInfo.baseline; if (!baselineVersion) { - throw new Error(`Unable to determine baseline version for ${npmDistTag}`); + return null; } if (rollbackInfo.isRollback) { @@ -188,8 +188,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 +199,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 +231,17 @@ 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.*/, ''); } else { - releaseVersion = latestPreviewVersion.replace(/-preview.*/, ''); + const packageJson = readJson('package.json'); + releaseVersion = packageJson.version.split('-')[0]; } return { @@ -246,10 +251,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 +261,12 @@ 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'); + releaseVersion = packageJson.version.split('-')[0] + '-preview.0'; } return { @@ -278,7 +283,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 7adf003279a..5890e1bd30b 100644 --- a/scripts/tests/get-release-version.test.js +++ b/scripts/tests/get-release-version.test.js @@ -182,5 +182,47 @@ 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'); + } + // Empty versions list (no nightlies published yet) + 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'); + } + // Empty versions list (no previews published yet) + 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'); + }); }); }); From 0b21686756f663769baa19a33912054f3532aba7 Mon Sep 17 00:00:00 2001 From: Qwen Code Autofix Date: Tue, 7 Jul 2026 20:53:07 +0000 Subject: [PATCH 2/6] fix(scripts): harden release versioning against transient NPM errors and missing dist-tags (#6476) - Distinguish 404 from transient errors in getVersionFromNPM so NPM outages halt the release instead of silently falling back - Consult getAllVersionsFromNPM when dist-tag is missing to derive baseline from published versions rather than returning empty - Add console.error logging when getAndVerifyTags returns null - Validate package.json fallback version in getStableVersion and getPreviewVersion - Add tests for promote-nightly/patch throw paths, true greenfield scenario, versions-list derivation, and transient error propagation --- scripts/get-release-version.js | 64 +++++++++++++-- scripts/tests/get-release-version.test.js | 99 +++++++++++++++++++++++ 2 files changed, 158 insertions(+), 5 deletions(-) diff --git a/scripts/get-release-version.js b/scripts/get-release-version.js index e723328bc4f..4aa0251e273 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') || error.message?.includes('404')) { + return ''; + } + throw error; } } @@ -56,10 +56,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; + 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,6 +213,9 @@ function getAndVerifyTags(npmDistTag, _gitTagPattern) { const baselineVersion = rollbackInfo.baseline; if (!baselineVersion) { + console.error( + `No baseline version found for dist-tag "${npmDistTag}" — returning null`, + ); return null; } @@ -242,6 +290,7 @@ function getStableVersion(args) { } else { const packageJson = readJson('package.json'); releaseVersion = packageJson.version.split('-')[0]; + validateVersion(releaseVersion, 'X.Y.Z', 'package.json version'); } return { @@ -267,6 +316,11 @@ function getPreviewVersion(args) { } else { const packageJson = readJson('package.json'); releaseVersion = packageJson.version.split('-')[0] + '-preview.0'; + validateVersion( + packageJson.version.split('-')[0], + 'X.Y.Z', + 'package.json version', + ); } return { diff --git a/scripts/tests/get-release-version.test.js b/scripts/tests/get-release-version.test.js index 5890e1bd30b..c692c3b7e54 100644 --- a/scripts/tests/get-release-version.test.js +++ b/scripts/tests/get-release-version.test.js @@ -224,5 +224,104 @@ describe('getVersion', () => { 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'); + }); + + it('should propagate transient NPM errors instead of falling back silently', () => { + 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'); + }); }); }); From f78e102096eaa2e340cd1c90fce71011d1a203e2 Mon Sep 17 00:00:00 2001 From: "qwen-code[bot]" Date: Wed, 8 Jul 2026 00:45:33 +0000 Subject: [PATCH 3/6] fix(scripts): propagate transient NPM errors in getAllVersionsFromNPM (#6476) getAllVersionsFromNPM silently swallowed all errors including transient network failures (ETIMEDOUT, ECONNRESET), which became load-bearing now that the missing-dist-tag fallback depends on it. Match the same 404-only pattern already used by getVersionFromNPM. Also fix a DRY violation in getPreviewVersion, correct misleading test comments, and add test coverage for the versions-list error path and latest filter branch. --- scripts/get-release-version.js | 15 ++++---- scripts/tests/get-release-version.test.js | 45 +++++++++++++++++++++-- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/scripts/get-release-version.js b/scripts/get-release-version.js index 4aa0251e273..acc8fd37a43 100644 --- a/scripts/get-release-version.js +++ b/scripts/get-release-version.js @@ -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; } } @@ -315,12 +317,9 @@ function getPreviewVersion(args) { tagResult.latestVersion.replace(/-nightly.*/, '') + '-preview.0'; } else { const packageJson = readJson('package.json'); - releaseVersion = packageJson.version.split('-')[0] + '-preview.0'; - validateVersion( - packageJson.version.split('-')[0], - 'X.Y.Z', - 'package.json version', - ); + const baseVersion = packageJson.version.split('-')[0]; + releaseVersion = baseVersion + '-preview.0'; + validateVersion(baseVersion, 'X.Y.Z', 'package.json version'); } return { diff --git a/scripts/tests/get-release-version.test.js b/scripts/tests/get-release-version.test.js index c692c3b7e54..0949462ad34 100644 --- a/scripts/tests/get-release-version.test.js +++ b/scripts/tests/get-release-version.test.js @@ -189,7 +189,7 @@ describe('getVersion', () => { if (command.includes('npm view') && command.includes('--tag=nightly')) { throw new Error('npm error code E404'); } - // Empty versions list (no nightlies published yet) + // 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']); @@ -210,7 +210,7 @@ describe('getVersion', () => { if (command.includes('npm view') && command.includes('--tag=preview')) { throw new Error('npm error code E404'); } - // Empty versions list (no previews published yet) + // 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']); @@ -305,9 +305,10 @@ describe('getVersion', () => { 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 instead of falling back silently', () => { + it('should propagate transient NPM errors from dist-tag lookup', () => { const mockWithTransientError = (command) => { if ( command.includes('npm view') && @@ -323,5 +324,43 @@ describe('getVersion', () => { 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'); + }); }); }); From 41ad4dc3ea86ea104fa4f00159873629c23a9543 Mon Sep 17 00:00:00 2001 From: Qwen Code Autofix Date: Wed, 8 Jul 2026 05:23:07 +0000 Subject: [PATCH 4/6] fix(scripts): tighten 404 detection and tolerate transient versions-list failures (#6476) - Remove redundant '404' substring check; E404 is the canonical npm error code and bare '404' could false-match unrelated errors (port 4043, E4040) - Catch transient versions-list errors in detectRollbackAndGetBaseline when distTagVersion is already resolved, avoiding hard-blocking a release when rollback detection is merely a safety net - Update and add tests for the new fallback behavior and deprecated-versions path --- scripts/get-release-version.js | 17 ++++++-- scripts/tests/get-release-version.test.js | 47 ++++++++++++++++++++++- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/scripts/get-release-version.js b/scripts/get-release-version.js index acc8fd37a43..15ce88b1088 100644 --- a/scripts/get-release-version.js +++ b/scripts/get-release-version.js @@ -21,7 +21,7 @@ function getVersionFromNPM(distTag) { try { return execSync(command).toString().trim(); } catch (error) { - if (error.message?.includes('E404') || error.message?.includes('404')) { + if (error.message?.includes('E404')) { return ''; } throw error; @@ -34,7 +34,7 @@ function getAllVersionsFromNPM() { const versionsJson = execSync(command).toString().trim(); return JSON.parse(versionsJson); } catch (error) { - if (error.message?.includes('E404') || error.message?.includes('404')) { + if (error.message?.includes('E404')) { return []; } throw error; @@ -60,7 +60,18 @@ function detectRollbackAndGetBaseline(npmDistTag) { const distTagVersion = getVersionFromNPM(npmDistTag); // 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 diff --git a/scripts/tests/get-release-version.test.js b/scripts/tests/get-release-version.test.js index 0949462ad34..106b66846d0 100644 --- a/scripts/tests/get-release-version.test.js +++ b/scripts/tests/get-release-version.test.js @@ -325,7 +325,7 @@ describe('getVersion', () => { expect(() => getVersion({ type: 'stable' })).toThrow('ETIMEDOUT'); }); - it('should propagate transient NPM errors from versions list lookup', () => { + it('should fall back to dist-tag when versions list lookup fails transiently', () => { const mockWithTransientVersionsError = (command) => { if ( command.includes('npm view') && @@ -338,6 +338,31 @@ describe('getVersion', () => { }; 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'); }); @@ -362,5 +387,25 @@ describe('getVersion', () => { 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'); + }); }); }); From 5d2f168b132960680ddd0f0542ce60173f694a92 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 8 Jul 2026 20:02:29 +0800 Subject: [PATCH 5/6] fix(scripts): guard release version fallback edge cases --- scripts/get-release-version.js | 16 ++++ scripts/tests/get-release-version.test.js | 106 ++++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/scripts/get-release-version.js b/scripts/get-release-version.js index 15ce88b1088..96e94a592fd 100644 --- a/scripts/get-release-version.js +++ b/scripts/get-release-version.js @@ -300,6 +300,17 @@ function getStableVersion(args) { 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 { const packageJson = readJson('package.json'); releaseVersion = packageJson.version.split('-')[0]; @@ -326,6 +337,11 @@ function getPreviewVersion(args) { } else if (tagResult) { releaseVersion = 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]; diff --git a/scripts/tests/get-release-version.test.js b/scripts/tests/get-release-version.test.js index 106b66846d0..d7c470e7be7 100644 --- a/scripts/tests/get-release-version.test.js +++ b/scripts/tests/get-release-version.test.js @@ -407,5 +407,111 @@ describe('getVersion', () => { 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', + ); + }); }); }); From 34088b0481dc9af536cc787b291ac0abfda96b79 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 8 Jul 2026 20:03:56 +0800 Subject: [PATCH 6/6] test(scripts): cover greenfield versions list E404 --- scripts/tests/get-release-version.test.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/scripts/tests/get-release-version.test.js b/scripts/tests/get-release-version.test.js index d7c470e7be7..8c0f9eb70b0 100644 --- a/scripts/tests/get-release-version.test.js +++ b/scripts/tests/get-release-version.test.js @@ -281,6 +281,28 @@ describe('getVersion', () => { 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 (