fix(updater): proper semver comparison for pre-release versions (#313)

Fixes the beta auto-update bug where the app was offering downgrades
(e.g., showing v2.7.1 as "new version available" when on v2.7.2-beta.6).

Changes:
- version-manager.ts: New parseVersion() function that separates base
  version from pre-release suffix. Updated compareVersions() to handle
  pre-release versions correctly (alpha < beta < rc < stable).
- app-updater.ts: Import and use compareVersions() for proper version
  comparison instead of simple string inequality.
- Added comprehensive unit tests for version comparison logic.

Pre-release ordering:
- 2.7.1 < 2.7.2-alpha.1 < 2.7.2-beta.1 < 2.7.2-rc.1 < 2.7.2 (stable)
- 2.7.2-beta.6 < 2.7.2-beta.7
This commit is contained in:
Michael Ludlow
2025-12-26 11:48:32 -05:00
committed by GitHub
parent 92c6f2786d
commit e1b0f743bf
3 changed files with 178 additions and 11 deletions
@@ -0,0 +1,101 @@
/**
* Tests for version-manager.ts
*
* Tests the compareVersions function with various version formats
* including pre-release versions (alpha, beta, rc).
*/
import { describe, test, expect } from 'vitest';
import { compareVersions } from '../updater/version-manager';
describe('compareVersions', () => {
describe('basic version comparison', () => {
test('equal versions return 0', () => {
expect(compareVersions('1.0.0', '1.0.0')).toBe(0);
expect(compareVersions('2.7.2', '2.7.2')).toBe(0);
});
test('newer major version returns 1', () => {
expect(compareVersions('2.0.0', '1.0.0')).toBe(1);
expect(compareVersions('3.0.0', '2.7.2')).toBe(1);
});
test('older major version returns -1', () => {
expect(compareVersions('1.0.0', '2.0.0')).toBe(-1);
});
test('newer minor version returns 1', () => {
expect(compareVersions('2.8.0', '2.7.2')).toBe(1);
});
test('older minor version returns -1', () => {
expect(compareVersions('2.6.0', '2.7.2')).toBe(-1);
});
test('newer patch version returns 1', () => {
expect(compareVersions('2.7.3', '2.7.2')).toBe(1);
});
test('older patch version returns -1', () => {
expect(compareVersions('2.7.1', '2.7.2')).toBe(-1);
});
});
describe('pre-release version comparison', () => {
test('stable is newer than same-version beta', () => {
expect(compareVersions('2.7.2', '2.7.2-beta.6')).toBe(1);
expect(compareVersions('2.7.2-beta.6', '2.7.2')).toBe(-1);
});
test('stable is newer than same-version alpha', () => {
expect(compareVersions('2.7.2', '2.7.2-alpha.1')).toBe(1);
});
test('beta is newer than alpha of same version', () => {
expect(compareVersions('2.7.2-beta.1', '2.7.2-alpha.1')).toBe(1);
});
test('rc is newer than beta of same version', () => {
expect(compareVersions('2.7.2-rc.1', '2.7.2-beta.6')).toBe(1);
});
test('higher beta number is newer', () => {
expect(compareVersions('2.7.2-beta.7', '2.7.2-beta.6')).toBe(1);
expect(compareVersions('2.7.2-beta.6', '2.7.2-beta.7')).toBe(-1);
});
test('equal pre-release versions return 0', () => {
expect(compareVersions('2.7.2-beta.6', '2.7.2-beta.6')).toBe(0);
});
});
describe('cross-version pre-release comparison', () => {
test('beta of newer version is newer than stable of older version', () => {
// 2.7.2-beta.1 > 2.7.1 (stable)
expect(compareVersions('2.7.2-beta.1', '2.7.1')).toBe(1);
});
test('stable of older version is older than beta of newer version', () => {
// 2.7.1 (stable) < 2.7.2-beta.6
expect(compareVersions('2.7.1', '2.7.2-beta.6')).toBe(-1);
});
// THIS IS THE BUG WE'RE FIXING:
// When on 2.7.2-beta.6, the updater was offering 2.7.1 as an "update"
test('stable 2.7.1 is NOT newer than beta 2.7.2-beta.6', () => {
expect(compareVersions('2.7.1', '2.7.2-beta.6')).toBe(-1);
});
});
describe('edge cases', () => {
test('handles versions with missing parts', () => {
expect(compareVersions('2.7', '2.7.0')).toBe(0);
expect(compareVersions('2', '2.0.0')).toBe(0);
});
test('handles pre-release without number', () => {
// "beta" without a number should be treated as beta.0
expect(compareVersions('2.7.2-beta', '2.7.2-beta.1')).toBe(-1);
});
});
});
+10 -2
View File
@@ -22,6 +22,7 @@ import { app } from 'electron';
import type { BrowserWindow } from 'electron';
import { IPC_CHANNELS } from '../shared/constants';
import type { AppUpdateInfo } from '../shared/types';
import { compareVersions } from './updater/version-manager';
// Debug mode - DEBUG_UPDATER=true or development mode
const DEBUG_UPDATER = process.env.DEBUG_UPDATER === 'true' || process.env.NODE_ENV === 'development';
@@ -197,9 +198,16 @@ export async function checkForUpdates(): Promise<AppUpdateInfo | null> {
return null;
}
const updateAvailable = result.updateInfo.version !== autoUpdater.currentVersion.version;
const currentVersion = autoUpdater.currentVersion.version;
const latestVersion = result.updateInfo.version;
// Use proper semver comparison to detect if update is actually newer
// This prevents offering downgrades (e.g., v2.7.1 when on v2.7.2-beta.6)
const isNewer = compareVersions(latestVersion, currentVersion) > 0;
console.warn(`[app-updater] Version comparison: ${latestVersion} vs ${currentVersion} -> ${isNewer ? 'UPDATE' : 'NO UPDATE'}`);
if (!updateAvailable) {
if (!isNewer) {
return null;
}
@@ -92,20 +92,78 @@ export function parseVersionFromTag(tag: string): string {
}
/**
* Compare semantic versions
* Parse a version string into its components
* Handles versions like "2.7.2", "2.7.2-beta.6", "2.7.2-alpha.1"
*
* @returns { base: number[], prerelease: { type: string, num: number } | null }
*/
function parseVersion(version: string): {
base: number[];
prerelease: { type: string; num: number } | null
} {
// Split into base version and prerelease suffix
// e.g., "2.7.2-beta.6" -> ["2.7.2", "beta.6"]
const [baseStr, prereleaseStr] = version.split('-');
// Parse base version numbers
const base = baseStr.split('.').map(n => parseInt(n, 10) || 0);
// Parse prerelease if present
let prerelease: { type: string; num: number } | null = null;
if (prereleaseStr) {
// Handle formats like "beta.6", "alpha.1", "rc.2"
const match = prereleaseStr.match(/^([a-zA-Z]+)\.?(\d*)$/);
if (match) {
prerelease = {
type: match[1].toLowerCase(),
num: parseInt(match[2], 10) || 0
};
}
}
return { base, prerelease };
}
/**
* Compare semantic versions with proper pre-release support
* Returns: 1 if a > b, -1 if a < b, 0 if equal
*
* Pre-release ordering:
* - alpha < beta < rc < stable (no prerelease)
* - 2.7.2-beta.1 < 2.7.2-beta.2 < 2.7.2 (stable)
* - 2.7.1 < 2.7.2-beta.1 < 2.7.2
*/
export function compareVersions(a: string, b: string): number {
const partsA = a.split('.').map(Number);
const partsB = b.split('.').map(Number);
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
const numA = partsA[i] || 0;
const numB = partsB[i] || 0;
const parsedA = parseVersion(a);
const parsedB = parseVersion(b);
// Compare base versions first
const maxLen = Math.max(parsedA.base.length, parsedB.base.length);
for (let i = 0; i < maxLen; i++) {
const numA = parsedA.base[i] || 0;
const numB = parsedB.base[i] || 0;
if (numA > numB) return 1;
if (numA < numB) return -1;
}
// Base versions are equal, compare prereleases
// No prerelease = stable = higher than any prerelease of same base
if (!parsedA.prerelease && !parsedB.prerelease) return 0;
if (!parsedA.prerelease && parsedB.prerelease) return 1; // a is stable, b is prerelease
if (parsedA.prerelease && !parsedB.prerelease) return -1; // a is prerelease, b is stable
// Both have prereleases - compare type then number
const prereleaseOrder: Record<string, number> = { alpha: 0, beta: 1, rc: 2 };
const typeA = prereleaseOrder[parsedA.prerelease!.type] ?? 1;
const typeB = prereleaseOrder[parsedB.prerelease!.type] ?? 1;
if (typeA > typeB) return 1;
if (typeA < typeB) return -1;
// Same prerelease type, compare numbers
if (parsedA.prerelease!.num > parsedB.prerelease!.num) return 1;
if (parsedA.prerelease!.num < parsedB.prerelease!.num) return -1;
return 0;
}