From ce2d959be3e9805cfee5fba6f2cecc63cff0f2a2 Mon Sep 17 00:00:00 2001 From: NewtTheWolf Date: Tue, 18 Aug 2026 18:09:07 +0200 Subject: [PATCH 1/2] fix(semver): keep prerelease when comparing versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit semver.coerce() drops the prerelease, so every 1.0.0-beta.N coerced to 1.0.0 and compared equal. persistRelease only bumps latestVersion when compareSemver(new, current) > 0, so a plugin's latestVersion froze at whichever prerelease of a series landed first while later betas kept ingesting as ordinary release rows. Live effect on the postgresql plugin: latestVersion stuck at 1.0.0-beta.5 with beta.6 and beta.7 published. The detail page sorts releases by createdAt and showed beta.7 next to the download button, but /api/plugins/:slug/latest resolves through latestVersion and served beta.5 binaries — same path the desktop client uses to install and update. Same bug ranked a stable 2.0.0 equal to its own 2.0.0-rc.1. compareSemver had no test coverage at all; added a block that fails on both counts without the fix. --- apps/api/src/lib/semver.ts | 7 +++++-- apps/api/tests/lib/semver.test.ts | 34 ++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) 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..74da0f4 100644 --- a/apps/api/tests/lib/semver.test.ts +++ b/apps/api/tests/lib/semver.test.ts @@ -1,5 +1,5 @@ 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 +42,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') From bc9223f68f7ccf0e5c6cfe587bf7be84caab2fcf Mon Sep 17 00:00:00 2001 From: NewtTheWolf Date: Tue, 18 Aug 2026 18:16:32 +0200 Subject: [PATCH 2/2] style: biome formatting for the semver test import --- apps/api/tests/lib/semver.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/api/tests/lib/semver.test.ts b/apps/api/tests/lib/semver.test.ts index 74da0f4..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 { compareSemver, 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', () => {