fix(release): derive the next patch when the preview baseline lags latest (#16470)

* fix(release): derive the next patch when the preview baseline lags latest (#16466)

Co-authored-by: Qwen-Coder <[email protected]>

* fix(release): derive the stable bump from the rollback-aware latest (#16466)

Co-authored-by: Qwen-Coder <[email protected]>

* refactor(release): share the published-latest bump helper (#16466)

Co-authored-by: Qwen-Coder <[email protected]>

* fix(release): refuse cross-line stable versions from older refs (#16466)

Co-authored-by: Qwen-Coder <[email protected]>

* fix(release): refuse stable releases from refs behind latest (#16466)

Co-authored-by: Qwen-Coder <[email protected]>

Co-authored-by: Qwen-Coder <[email protected]>

* fix(release): refuse cross-line and stale stable derivations (#16466)

Co-authored-by: Qwen-Coder <[email protected]>

* test(release): pin the cross-line guard's strict boundary (#16466)

Co-authored-by: Qwen-Coder <[email protected]>

* fix(release): refuse a derived base above the checked-out ref (#16466)

Co-authored-by: Qwen-Coder <[email protected]>

* fix(release): keep same-line next-patch promotions allowed (#16466)

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-11-07 22:43:17 +08:00
1 parent d0b80d4a99
commit f8256e8222
2 files changed
+506 -29

No files matched your search

+72 -27
View File
@@ -327,6 +327,60 @@ function getNightlyVersion() {
};
}
// A channel base at or below the published stable would repeat a shipped
// version or move the `latest` tag backwards, so target the next patch after
// `latest` instead. Returns the base unchanged when no bump is needed.
// Read through the rollback-aware lookup so a rolled-back dist-tag can never
// become the base.
function bumpPastPublishedLatest(baseVersion, label, target, refBase) {
const latestStable =
getAndVerifyTags('latest', 'v[0-9].[0-9].[0-9]')?.latestVersion ?? '';
// A ref behind the published stable would publish its own content
// under a version it never contained, whether the preview base is ahead of
// latest (a release/0.25 dispatch mid-cycle) or behind it. Check before the
// no-bump early return so both states are caught.
if (
refBase &&
semver.valid(latestStable) &&
semver.lt(refBase, latestStable)
) {
throw new Error(
`Published latest ${latestStable} is ahead of the checked-out package.json version ${refBase}. Refusing a stale or cross-line stable release.`,
);
}
// When the preview base already leads `latest`, no bump is computed below,
// so the bumped-version check cannot fire. Only a base on another release
// line is refused here: a patch ahead on the ref's own line is the ordinary
// next release.
if (
refBase &&
semver.valid(baseVersion) &&
semver.lt(refBase, baseVersion) &&
semver.diff(refBase, baseVersion) !== 'patch'
) {
throw new Error(
`Derived base ${baseVersion} is on a different release line than the checked-out package.json version ${refBase}. Refusing a cross-line stable release.`,
);
}
if (
!semver.valid(latestStable) ||
!semver.valid(baseVersion) ||
semver.lt(latestStable, baseVersion)
) {
return baseVersion;
}
const bumped = semver.inc(latestStable, 'patch');
if (refBase && semver.lt(bumped, refBase)) {
throw new Error(
`Bumping to ${bumped} would publish the checked-out ${refBase} content on an older release line. Refusing a cross-line stable release.`,
);
}
console.error(
`${label} ${baseVersion} is at or below published latest ${latestStable}; bumping ${target} ${bumped}.`,
);
return bumped;
}
function getStableVersion(args) {
const tagResult = getAndVerifyTags('preview', 'v*-preview*');
let releaseVersion;
@@ -337,20 +391,23 @@ function getStableVersion(args) {
} 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.`,
);
}
const refBase = readJson('package.json').version.split('-')[0];
releaseVersion = bumpPastPublishedLatest(
releaseVersion,
'Preview baseline',
'the stable base to at least',
refBase,
);
} else {
const packageJson = readJson('package.json');
releaseVersion = packageJson.version.split('-')[0];
validateVersion(releaseVersion, 'X.Y.Z', 'package.json version');
releaseVersion = bumpPastPublishedLatest(
releaseVersion,
'Package version',
'the stable base to at least',
releaseVersion,
);
}
return {
@@ -375,23 +432,11 @@ function getPreviewVersion(args) {
// When the nightly base is already published as stable, the preview must
// target the next patch — otherwise the scheduled Tuesday release derives
// a version whose channel packages already exist on npm (E403).
// Use the rollback-aware lookup so a rolled-back dist-tag doesn't produce
// a retrograde preview base.
const latestTagResult = getAndVerifyTags('latest', 'v[0-9].[0-9].[0-9]');
const latestStable = latestTagResult?.latestVersion ?? '';
if (
latestStable &&
semver.valid(latestStable) &&
semver.valid(baseVersion)
) {
if (semver.gte(latestStable, baseVersion)) {
const bumped = semver.inc(latestStable, 'patch');
console.error(
`Nightly base ${baseVersion} is at or below published latest ${latestStable}; bumping preview base to ${bumped}.`,
);
baseVersion = bumped;
}
}
baseVersion = bumpPastPublishedLatest(
baseVersion,
'Nightly base',
'preview base to',
);
releaseVersion = baseVersion + '-preview.0';
validateVersion(
releaseVersion,
+434 -2
View File
@@ -664,7 +664,7 @@ describe('getVersion', () => {
);
});
it('should reject a stable release derived below the published latest version', () => {
it('should bump the stable base past the published latest when the preview lags it', () => {
const mockWithOlderPreviewVersions = (command) => {
if (
command.includes('npm view') &&
@@ -685,11 +685,443 @@ describe('getVersion', () => {
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithOlderPreviewVersions);
// The checked-out ref is on the same release line as published latest.
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.9.0' }),
);
const result = getVersion({ type: 'stable' });
expect(result.releaseVersion).toBe('0.9.1');
expect(result.npmTag).toBe('latest');
expect(result.previousReleaseTag).toBe('v0.6.1');
});
it('should keep the preview-derived base when the latest dist-tag is missing', () => {
// First stable release: previews exist but nothing has shipped stable,
// so the rollback-aware latest lookup returns null.
const mockWithMissingLatest = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
)
return '0.7.0-preview.1';
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.7.0-preview.0', '0.7.0-preview.1']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithMissingLatest);
const result = getVersion({ type: 'stable' });
expect(result.releaseVersion).toBe('0.7.0');
expect(result.npmTag).toBe('latest');
});
it('should ignore an invalid latest dist-tag when deriving the stable base', () => {
const mockWithInvalidLatest = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
)
return '0.7.0-preview.1';
if (
command.includes('npm view') &&
command.includes('--tag=latest') &&
!command.includes('versions --json')
)
return 'banana';
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.7.0-preview.0', '0.7.0-preview.1']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithInvalidLatest);
const result = getVersion({ type: 'stable' });
expect(result.releaseVersion).toBe('0.7.0');
expect(result.npmTag).toBe('latest');
});
it('should read the published latest through the rollback-aware lookup', () => {
// A rolled-back `latest` dist-tag must not become the stable base:
// 0.11.0 is the highest published stable even though the tag says
// 0.9.2, and publishing 0.9.3 would move the tag backwards.
const mockWithRolledBackLatest = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
)
return '0.9.0-preview.1';
if (
command.includes('npm view') &&
command.includes('--tag=latest') &&
!command.includes('versions --json')
)
return '0.9.2';
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.9.0-preview.1', '0.9.2', '0.11.0']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithRolledBackLatest);
// The ref is on the 0.11 line; only the dist-tag was rolled back.
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.11.0' }),
);
const result = getVersion({ type: 'stable' });
expect(result.releaseVersion).toBe('0.11.1');
expect(result.npmTag).toBe('latest');
});
it('should refuse a versionless stable release dispatched from a ref behind latest', () => {
// A release/0.25 dispatch resolves against the global dist-tags, which
// have moved on to 0.26. Bumping would publish 0.25 branch content as
// 0.26.9 under `latest`, so the stale dispatch must abort.
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.25.9' }),
);
const mockWithOlderReleaseLine = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
)
return '0.26.3-preview.0';
if (
command.includes('npm view') &&
command.includes('--tag=latest') &&
!command.includes('versions --json')
)
return '0.26.8';
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.26.3-preview.0', '0.26.8']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithOlderReleaseLine);
expect(() => getVersion({ type: 'stable' })).toThrow(
'Derived stable version 0.7.0 is lower than published latest 0.9.0',
'is ahead of the checked-out package.json version',
);
});
it('should refuse an older ref even when the preview base is ahead of latest', () => {
// Mid-cycle the preview tag runs ahead of the published stable. A
// release/0.25 dispatch must still abort instead of publishing its
// content as the future minor.
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.25.9' }),
);
const mockWithAheadPreview = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
)
return '0.27.0-preview.0';
if (
command.includes('npm view') &&
command.includes('--tag=latest') &&
!command.includes('versions --json')
)
return '0.26.9';
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.26.9', '0.27.0-preview.0']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithAheadPreview);
expect(() => getVersion({ type: 'stable' })).toThrow(
'is ahead of the checked-out package.json version',
);
});
it('should refuse a stale ref when the preview base is ahead and no bump happens', () => {
// release/v0.26 at 0.26.8 with `latest` also 0.26.8: the preview tag
// already carries 0.27.0, so no bump is computed and only a direct
// base-vs-ref comparison can refuse publishing 0.26 content as 0.27.0.
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.26.8' }),
);
const mockWithAheadPreviewAtLatest = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
)
return '0.27.0-preview.0';
if (
command.includes('npm view') &&
command.includes('--tag=latest') &&
!command.includes('versions --json')
)
return '0.26.8';
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.26.8', '0.27.0-preview.0']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithAheadPreviewAtLatest);
expect(() => getVersion({ type: 'stable' })).toThrow(
'Derived base 0.27.0 is on a different release line than the checked-out package.json version 0.26.8',
);
});
it('should allow the ordinary next patch when the checked-out ref matches latest', () => {
// main after v0.26.8: package.json 0.26.8, `latest` 0.26.8, and the
// Tuesday preview already targets 0.26.9. A versionless stable dispatch
// promotes that preview; it is not a cross-line release.
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.26.8' }),
);
const mockWithNextPatchPreview = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
)
return '0.26.9-preview.0';
if (
command.includes('npm view') &&
command.includes('--tag=latest') &&
!command.includes('versions --json')
)
return '0.26.8';
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.26.8', '0.26.9-preview.0']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithNextPatchPreview);
const result = getVersion({ type: 'stable' });
expect(result.releaseVersion).toBe('0.26.9');
expect(result.npmTag).toBe('latest');
});
it('should refuse a stable release when the ref is behind latest on the same line', () => {
// release/0.26.5 predates the 0.26.8 stable. Bumping it to 0.26.9
// would publish older content over a newer stable.
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.26.5' }),
);
const mockWithStaleRef = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
)
return '0.26.3-preview.0';
if (
command.includes('npm view') &&
command.includes('--tag=latest') &&
!command.includes('versions --json')
)
return '0.26.8';
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.26.3-preview.0', '0.26.8']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithStaleRef);
expect(() => getVersion({ type: 'stable' })).toThrow(
'is ahead of the checked-out package.json version',
);
});
it('should release a new minor when the checked-out ref is ahead of latest', () => {
// The ordinary new-minor dispatch: the preview tag already carries the
// next minor while the ref's base is ahead of `latest`.
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.27.0' }),
);
const mockWithNextMinorPreview = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
)
return '0.27.0-preview.0';
if (
command.includes('npm view') &&
command.includes('--tag=latest') &&
!command.includes('versions --json')
)
return '0.26.9';
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.26.9', '0.27.0-preview.0']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithNextMinorPreview);
const result = getVersion({ type: 'stable' });
expect(result.releaseVersion).toBe('0.27.0');
expect(result.npmTag).toBe('latest');
});
it('should refuse a ref ahead of latest when the preview base lags it', () => {
// The preview tag still carries 0.26 while the ref is already 0.27.0.
// Bumping the lagging preview base to 0.26.10 would publish the 0.27.0
// content as a 0.26 patch on `latest`.
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.27.0' }),
);
const mockWithLaggingPreview = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
)
return '0.26.0-preview.1';
if (
command.includes('npm view') &&
command.includes('--tag=latest') &&
!command.includes('versions --json')
)
return '0.26.9';
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.26.0-preview.1', '0.26.9']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithLaggingPreview);
expect(() => getVersion({ type: 'stable' })).toThrow(
'would publish the checked-out 0.27.0 content on an older release line',
);
});
it('should allow a bump that lands exactly on the checked-out ref version', () => {
// A ref at 0.9.1 publishing the next patch after latest 0.9.0 is the
// ordinary release; the cross-line guard must stay strictly-below.
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.9.1' }),
);
const mockWithExactRefBump = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
)
return '0.7.0-preview.1';
if (
command.includes('npm view') &&
command.includes('--tag=latest') &&
!command.includes('versions --json')
)
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(mockWithExactRefBump);
const result = getVersion({ type: 'stable' });
expect(result.releaseVersion).toBe('0.9.1');
expect(result.npmTag).toBe('latest');
});
it('should refuse a stale ref when no preview has ever been published', () => {
// The package.json fallback skipped the stale-ref check, so a ref at
// 0.8.0 could select 0.8.x while latest had already moved to 0.9.0.
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.8.0' }),
);
const mockWithNoPreviewRoute = (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.8.0', '0.9.0']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithNoPreviewRoute);
expect(() => getVersion({ type: 'stable' })).toThrow(
'is ahead of the checked-out package.json version',
);
});
it('should bump the stable base when the preview base equals the published latest', () => {
// The common ordering: previews for X.Y.Z exist, then X.Y.Z ships
// stable. The equality arm keeps the derived version unpublished
// even when the existence probes cannot confirm it.
const mockWithEqualLatest = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
)
return '0.9.0-preview.1';
if (
command.includes('npm view') &&
command.includes('--tag=latest') &&
!command.includes('versions --json')
)
return '0.9.0';
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.9.0-preview.1', '0.9.0']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithEqualLatest);
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.9.0' }),
);
const result = getVersion({ type: 'stable' });
expect(result.releaseVersion).toBe('0.9.1');
expect(result.npmTag).toBe('latest');
});
it('should describe the bumped stable base as a candidate, not the released version', () => {
const mockWithTaggedCandidate = (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',
]);
if (
command.includes('npm view @qwen-code/[email protected] version')
)
return '0.9.1';
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithTaggedCandidate);
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.9.0' }),
);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const result = getVersion({ type: 'stable' });
expect(result.releaseVersion).toBe('0.9.2');
expect(errorSpy.mock.calls.flat().join('\n')).toContain(
'bumping the stable base to at least 0.9.1.',
);
errorSpy.mockRestore();
});
});
it('runCli default dispatch prints the version JSON and exits 0', () => {