Skip to content

Commit 9962a24

Browse files
authored
fix(version-dispatch): keep prereleases in their rc cycle (#86)
A commit-driven \`major\`/\`minor\`/\`patch\` bump on a package whose current version is itself a prerelease (\`5.0.0-rc.96\`) used to return \`semver.inc\`'s raw output — for \`major\`, that's \`5.0.0\`, i.e. drop the rc and ship the long-awaited stable release. That's almost certainly the wrong policy when a \`refactor!:\` lands on a package that's been sitting in rc for 96 iterations. Detect prerelease state from the current version (presence of \`-\`) and downgrade the bump level to \`prerelease\` so the rc counter advances instead (\`5.0.0-rc.96\` → \`5.0.0-rc.97\`). Stable versions are unchanged. Caught by exodus-hydra#16379's preview comment: @exodus/headless | major | 5.0.0-rc.96 | 5.0.0 ← wrong Now reads: @exodus/headless | major | 5.0.0-rc.96 | 5.0.0-rc.97 ← correct The team must explicitly promote an rc to stable via a separate workflow when they're ready. Both the preview's \`nextVersion\` and \`versionPackagesExplicit\`'s real bump path apply the same downgrade. The \`isMajorBump\` check (consumer-pin walker) keeps the workspace symlink resolving — an rc → next-rc bump doesn't move the major, so no consumer pins are touched. 193/193 tests passing (was 181, +12 across preview.spec and version-packages.spec — covering rc.X → rc.X+1, no consumer pin walk, and the stable-version path staying on the original semver.inc).
1 parent 9a751fd commit 9962a24

8 files changed

Lines changed: 145 additions & 15 deletions

File tree

dist/version-dispatch/index.js

Lines changed: 9 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/version-dispatch/index.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/version/index.js

Lines changed: 25 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/version/index.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/version-dispatch/preview.spec.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
PREVIEW_MARKER,
99
renderPreviewComment,
1010
} from './preview'
11-
import { BUMP_MAJOR, BUMP_MINOR, BUMP_PATCH } from './bumps'
11+
import { BUMP_MAJOR, BUMP_MINOR, BUMP_PATCH, type Bump } from './bumps'
1212

1313
jest.mock('@actions/core', () => ({
1414
info: jest.fn(),
@@ -48,8 +48,15 @@ describe('nextVersion', () => {
4848
expect(nextVersion('1.2.3', BUMP_PATCH)).toBe('1.2.4')
4949
})
5050

51-
it('releases a pre-release to its base version on a patch bump', () => {
52-
expect(nextVersion('1.2.3-rc.4', BUMP_PATCH)).toBe('1.2.3')
51+
it.each<[string, Bump, string]>([
52+
['1.2.3-rc.4', BUMP_MAJOR, '1.2.3-rc.5'],
53+
['1.2.3-rc.4', BUMP_MINOR, '1.2.3-rc.5'],
54+
['1.2.3-rc.4', BUMP_PATCH, '1.2.3-rc.5'],
55+
['5.0.0-rc.96', BUMP_MAJOR, '5.0.0-rc.97'],
56+
['5.0.0-rc.96', BUMP_PATCH, '5.0.0-rc.97'],
57+
['1.0.0-alpha.0', BUMP_MAJOR, '1.0.0-alpha.1'],
58+
])('keeps prerelease bumps in the rc cycle: %s + %s → %s', (current, bump, expected) => {
59+
expect(nextVersion(current, bump)).toBe(expected)
5360
})
5461

5562
it('returns input when the version is unparseable', () => {

src/version-dispatch/preview.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,16 @@ function readVersion({
105105
}
106106

107107
export function nextVersion(current: string, bump: Bump): string {
108-
return semverInc(current, bump as ReleaseType) ?? current
108+
// While a package is in a prerelease cycle (`5.0.0-rc.96`), bump the
109+
// prerelease counter rather than dropping the rc — a `feat!:` commit
110+
// shouldn't accidentally promote a long-lived rc to a stable release.
111+
// The caller can promote to stable via a separate workflow when ready.
112+
const effectiveBump = isPrerelease(current) ? 'prerelease' : (bump as ReleaseType)
113+
return semverInc(current, effectiveBump) ?? current
114+
}
115+
116+
function isPrerelease(version: string): boolean {
117+
return version.includes('-')
109118
}
110119

111120
export function renderPreviewComment(rows: PreviewRow[]): string {

src/version/version-packages.spec.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,66 @@ describe('versionPackagesExplicit', () => {
177177
).rejects.toThrow(/not present in `packages`/)
178178
})
179179

180+
describe('prerelease handling', () => {
181+
it.each([
182+
['5.0.0-rc.96', 'major', '5.0.0-rc.97'],
183+
['5.0.0-rc.96', 'minor', '5.0.0-rc.97'],
184+
['5.0.0-rc.96', 'patch', '5.0.0-rc.97'],
185+
['1.0.0-alpha.0', 'major', '1.0.0-alpha.1'],
186+
['2.0.0-beta.3', 'patch', '2.0.0-beta.4'],
187+
])(
188+
'bumps the rc counter on %s + %s → %s instead of dropping the prerelease',
189+
async (current, bump, expected) => {
190+
const pkgDir = writePackageJson('headless', { name: '@exodus/headless', version: current })
191+
192+
await versionPackagesExplicit({
193+
bumps: { '@exodus/headless': bump },
194+
packages: [pkgDir],
195+
})
196+
197+
const after = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'))
198+
expect(after.version).toBe(expected)
199+
}
200+
)
201+
202+
it('does not walk consumer pins when bumping a prerelease (the rc counter bump is not a major change)', async () => {
203+
const headlessDir = writePackageJson('sdks/headless', {
204+
name: '@exodus/headless',
205+
version: '5.0.0-rc.96',
206+
})
207+
const consumerDir = writePackageJson('apps/wallet', {
208+
name: '@exodus/wallet-app',
209+
version: '1.0.0',
210+
dependencies: { '@exodus/headless': '^5.0.0-rc.0' },
211+
})
212+
213+
mockGetPaths.mockResolvedValue({
214+
'@exodus/headless': headlessDir,
215+
'@exodus/wallet-app': consumerDir,
216+
})
217+
218+
await versionPackagesExplicit({
219+
bumps: { '@exodus/headless': 'major' },
220+
packages: [headlessDir],
221+
})
222+
223+
const consumer = JSON.parse(fs.readFileSync(path.join(consumerDir, 'package.json'), 'utf8'))
224+
expect(consumer.dependencies['@exodus/headless']).toBe('^5.0.0-rc.0')
225+
})
226+
227+
it('uses the regular semver.inc path for stable versions (5.0.0 + major → 6.0.0)', async () => {
228+
const pkgDir = writePackageJson('headless', { name: '@exodus/headless', version: '5.0.0' })
229+
230+
await versionPackagesExplicit({
231+
bumps: { '@exodus/headless': 'major' },
232+
packages: [pkgDir],
233+
})
234+
235+
const after = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'))
236+
expect(after.version).toBe('6.0.0')
237+
})
238+
})
239+
180240
it('throws when semver.inc rejects the bump level', async () => {
181241
const pkgDir = writePackageJson('mab', { name: '@exodus/mab', version: '1.0.0' })
182242

src/version/version-packages.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,12 @@ type ExplicitParams = {
4545
* package.json from the supplied `packages` list,
4646
* 2. compute the next version via `semver.inc(current, bump)` and rewrite
4747
* the `"version"` field in package.json in place (preserving every
48-
* other byte — formatting, trailing newlines, key order),
48+
* other byte — formatting, trailing newlines, key order). When the
49+
* current version is itself a prerelease (`5.0.0-rc.96`), the bump
50+
* is downgraded to `prerelease` so the rc counter advances
51+
* (`5.0.0-rc.97`) — a commit-driven `major` bump shouldn't promote
52+
* a long-lived rc to a stable release; the team must do that via
53+
* a separate workflow.
4954
* 3. on major bumps only, rewrite the bumped package's pin in every
5055
* workspace package.json that uses a semver range. Minor and patch
5156
* bumps don't need a rewrite — the existing caret/tilde range
@@ -107,14 +112,30 @@ export async function versionPackagesExplicit({
107112
throw new Error(`Cannot read version from package.json for ${pkgName}`)
108113
}
109114

110-
const next = semverInc(before.version, bump as ReleaseType)
115+
// While a package is in a prerelease cycle (`5.0.0-rc.96`), any
116+
// commit-driven bump (`major`/`minor`/`patch`) should bump the rc
117+
// counter rather than drop the prerelease. A `feat!:` on a long-lived
118+
// rc shouldn't accidentally promote it to a stable release — the team
119+
// must promote via a separate workflow when they're ready.
120+
const effectiveBump: ReleaseType = isPrerelease(before.version)
121+
? 'prerelease'
122+
: (bump as ReleaseType)
123+
124+
const next = semverInc(before.version, effectiveBump)
111125
if (!next) {
112126
throw new Error(
113-
`semver.inc rejected bump for ${pkgName}: ${before.version} + "${bump}". Valid bumps: major, minor, patch, premajor, preminor, prepatch, prerelease.`
127+
`semver.inc rejected bump for ${pkgName}: ${before.version} + "${effectiveBump}". Valid bumps: major, minor, patch, premajor, preminor, prepatch, prerelease.`
128+
)
129+
}
130+
131+
if (effectiveBump === bump) {
132+
core.info(`Bumping ${pkgName}: ${before.version}${next} (${bump}) in ${pkgDir}`)
133+
} else {
134+
core.info(
135+
`Bumping ${pkgName}: ${before.version}${next} (requested ${bump}; downgraded to ${effectiveBump} because current is a prerelease) in ${pkgDir}`
114136
)
115137
}
116138

117-
core.info(`Bumping ${pkgName}: ${before.version}${next} (${bump}) in ${pkgDir}`)
118139
writeVersionField({ pkgDir, next })
119140

120141
const updatedConsumers = isMajorBump(before.version, next)
@@ -237,6 +258,10 @@ function isMajorBump(oldVersion: string, newVersion: string): boolean {
237258
return oldVersion.split('.')[0] !== newVersion.split('.')[0]
238259
}
239260

261+
function isPrerelease(version: string): boolean {
262+
return version.includes('-')
263+
}
264+
240265
const NON_SEMVER_PREFIXES = ['workspace:', 'npm:', 'file:', 'link:', 'portal:']
241266

242267
function shouldRewriteRange(value: string): boolean {

0 commit comments

Comments
 (0)