diff --git a/apps/api/src/lib/semver.ts b/apps/api/src/lib/semver.ts index c9fcb69..bb7b777 100644 --- a/apps/api/src/lib/semver.ts +++ b/apps/api/src/lib/semver.ts @@ -8,9 +8,12 @@ const STRICT_SEMVER_RE = new RegExp(SEMVER_VERSION_PATTERN) // Returns negative if a < b, positive if a > b, 0 if equal or unparseable. // Coerces lax tag formats ("v1.2", "1.2") so legacy release tags still order. +// `includePrerelease` is load-bearing: a plain coerce() drops the prerelease, +// so every 1.0.0-beta.N would compare equal and a plugin's latestVersion would +// stick at whichever prerelease of a series landed first. export function compareSemver(a: string, b: string): number { - const sa = semver.coerce(a) - const sb = semver.coerce(b) + const sa = semver.coerce(a, { includePrerelease: true }) + const sb = semver.coerce(b, { includePrerelease: true }) if (!sa || !sb) return 0 return semver.compare(sa, sb) } diff --git a/apps/api/tests/lib/semver.test.ts b/apps/api/tests/lib/semver.test.ts index 1e7a3b6..05d14a0 100644 --- a/apps/api/tests/lib/semver.test.ts +++ b/apps/api/tests/lib/semver.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from 'bun:test' -import { isStrictSemver, assertStrictSemver, tagToVersion, InvalidVersionError } from '../../src/lib/semver' +import { + compareSemver, + isStrictSemver, + assertStrictSemver, + tagToVersion, + InvalidVersionError, +} from '../../src/lib/semver' describe('isStrictSemver', () => { it('accepts X.Y.Z', () => { @@ -42,6 +48,38 @@ describe('assertStrictSemver', () => { }) }) +describe('compareSemver', () => { + it('orders plain releases', () => { + expect(compareSemver('1.2.4', '1.2.3')).toBeGreaterThan(0) + expect(compareSemver('1.2.3', '1.2.4')).toBeLessThan(0) + expect(compareSemver('1.2.3', '1.2.3')).toBe(0) + expect(compareSemver('0.10.0', '0.2.0')).toBeGreaterThan(0) + }) + + // Regression: coerce() without includePrerelease collapses every + // 1.0.0-beta.N to 1.0.0, so latestVersion froze at the first beta of a + // series while later betas kept ingesting as ordinary releases. + it('orders prereleases of the same version', () => { + expect(compareSemver('1.0.0-beta.7', '1.0.0-beta.5')).toBeGreaterThan(0) + expect(compareSemver('1.0.0-beta.5', '1.0.0-beta.7')).toBeLessThan(0) + expect(compareSemver('1.0.0-beta.10', '1.0.0-beta.9')).toBeGreaterThan(0) + }) + + it('ranks a stable release above its own prerelease', () => { + expect(compareSemver('2.0.0', '2.0.0-rc.1')).toBeGreaterThan(0) + expect(compareSemver('2.0.0-rc.1', '2.0.0')).toBeLessThan(0) + }) + + it('still coerces lax legacy tags', () => { + expect(compareSemver('v1.2', '1.1')).toBeGreaterThan(0) + expect(compareSemver('1.2', '1.2.0')).toBe(0) + }) + + it('returns 0 for unparseable input', () => { + expect(compareSemver('garbage', '1.0.0')).toBe(0) + }) +}) + describe('tagToVersion', () => { it('strips a single leading v', () => { expect(tagToVersion('v1.2.3')).toBe('1.2.3')