From 7f48fa2bd5b83bd11326433fab7ca4295c624a5a Mon Sep 17 00:00:00 2001 From: Pim Feltkamp Date: Sun, 26 Apr 2026 16:24:24 +0200 Subject: [PATCH] Fix two version-comparison bugs in `cryptohopper upgrade` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1: alpha.10 < alpha.2 compareVersions used String#localeCompare on the full prerelease string, so "alpha.10" sorts before "alpha.2" alphabetically. SemVer 2.0.0 specifies dot-separated identifiers, with numeric ones compared numerically. Adds a comparePrerelease helper that splits on "." and applies the SemVer rules: - all-digit identifiers compared numerically - mixed/letter identifiers compared lexically - numeric identifiers always have lower precedence than non-numeric - longer-prefix-matching set wins Bug 2: fetchLatestRelease relied on GitHub's created_at order GitHub returns /releases sorted by created_at desc, which usually matches version order — but a hot-fix tag pushed after a newer release inverts that. Now sorts the filtered cli-v* list by version (highest first) using the now-correct compareVersions, so the latest is always the highest version regardless of publication order. Verified with a 9-case matrix covering: alpha.10 vs alpha.2 both directions, equality, release vs prerelease both directions, minor bumps, alpha vs beta vs rc, and longer-prefix prereleases. typecheck and build are clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/upgrade/github-release.ts | 46 +++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/src/upgrade/github-release.ts b/src/upgrade/github-release.ts index 1992f9b..07dc1e7 100644 --- a/src/upgrade/github-release.ts +++ b/src/upgrade/github-release.ts @@ -42,6 +42,11 @@ export async function fetchLatestRelease(repo: string = RELEASE_REPO): Promise !r.draft && r.tag_name.startsWith("cli-v")); if (cliReleases.length === 0) return null; + // Sort by version (highest first). GitHub returns releases by created_at, + // so a hot-fix on an older minor that's published *after* a newer release + // would otherwise appear at index 0 and trick `upgrade` into doing nothing + // (or downgrading). + cliReleases.sort((a, b) => compareVersions(b.tag_name, a.tag_name)); return cliReleases[0]!; } @@ -49,7 +54,7 @@ export function versionFromTag(tag: string): string { return tag.replace(/^cli-v/, ""); } -/** Returns negative if a is older than b. Small-but-deterministic semver. */ +/** Returns negative if a is older than b. SemVer 2.0.0 precedence rules. */ export function compareVersions(a: string, b: string): number { const aRel = versionFromTag(a); const bRel = versionFromTag(b); @@ -64,7 +69,7 @@ export function compareVersions(a: string, b: string): number { if (aPre && !bPre) return -1; if (!aPre && bPre) return 1; if (!aPre && !bPre) return 0; - return aPre!.localeCompare(bPre!); + return comparePrerelease(aPre!, bPre!); } function splitVersion(v: string): [number[], string | null] { @@ -72,3 +77,40 @@ function splitVersion(v: string): [number[], string | null] { const parts = (core ?? "0.0.0").split(".").map((n) => Number(n)).map((n) => (Number.isFinite(n) ? n : 0)); return [parts, pre ?? null]; } + +/** + * Compare two SemVer prerelease strings (the part after the `-`) per the + * SemVer 2.0.0 precedence rules: + * - Identifiers consisting of only digits are compared numerically. + * - Identifiers with letters or hyphens are compared lexically in ASCII. + * - Numeric identifiers always have lower precedence than non-numeric. + * - A larger set of fields has higher precedence than a smaller one. + * + * Crucially, this means `alpha.10` > `alpha.2` (numeric compare on the + * second identifier), which a naive `String#localeCompare` gets wrong. + */ +function comparePrerelease(a: string, b: string): number { + const aIds = a.split("."); + const bIds = b.split("."); + const max = Math.max(aIds.length, bIds.length); + for (let i = 0; i < max; i++) { + const ai = aIds[i]; + const bi = bIds[i]; + if (ai === undefined) return -1; + if (bi === undefined) return 1; + const aNum = /^\d+$/.test(ai); + const bNum = /^\d+$/.test(bi); + if (aNum && bNum) { + const d = Number(ai) - Number(bi); + if (d !== 0) return d; + } else if (aNum) { + return -1; + } else if (bNum) { + return 1; + } else { + const d = ai.localeCompare(bi); + if (d !== 0) return d; + } + } + return 0; +}