From b7aea4c76d93eb2648d27f8797db2f0e6eaac02b Mon Sep 17 00:00:00 2001 From: stuffbucket <231133237+stuffbucket@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:51:28 -0700 Subject: [PATCH] fix(update): dev build of current release no longer self-reports an upgrade A local/source sidecar is stamped -dev+ (build-sidecar.ts). Within its (correct) stable channel, the published then ranked ABOVE the running build because semver puts a prerelease below its release, so the app perpetually offered an 'upgrade' to the very version it was on. Normalize the running version by stripping only the -dev+ local suffix before the channel comparison; real -beta.N/-rc.N prereleases are left intact so they still see their release as an upgrade. The reported current field keeps the full string for diagnostics. --- src/lib/update-check.ts | 24 +++++++++++++++++++++-- tests/update-check.test.ts | 40 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/lib/update-check.ts b/src/lib/update-check.ts index bbb7972..b179b8a 100644 --- a/src/lib/update-check.ts +++ b/src/lib/update-check.ts @@ -83,18 +83,22 @@ type FetchLike = (url: string, init?: RequestInit) => Promise let fetchImpl: FetchLike = fetch let nowMs: () => number = Date.now +let versionImpl: string = BUILD_VERSION export function __setUpdateCheckDepsForTests(o: { fetch?: FetchLike now?: () => number + currentVersion?: string }): void { if (o.fetch) fetchImpl = o.fetch if (o.now) nowMs = o.now + if (o.currentVersion) versionImpl = o.currentVersion } export function __resetUpdateCheckDepsForTests(): void { fetchImpl = fetch nowMs = Date.now + versionImpl = BUILD_VERSION cache = null } @@ -140,6 +144,21 @@ export function isNewerVersion(a: string, b: string): boolean { return false } +/** + * Strip a local-build suffix (`-dev+`) so a dev binary compares on its + * core version. build-sidecar.ts stamps non-release binaries as + * `-dev+`; without this, a dev build of the current release + * (e.g. `0.4.35-dev+abc`) reads as *older* than the published `0.4.35` — since + * semver ranks a prerelease below its release — and perpetually self-reports + * "update available" for the version it's already running. A real prerelease + * channel (`-beta.N`, `-rc.N`) is left intact: those genuinely precede the + * release and should still see it as an upgrade. + */ +function normalizeCurrent(version: string): string { + const devAt = version.indexOf("-dev+") + return devAt === -1 ? version : version.slice(0, devAt) +} + interface UpdateManifest { channels?: Record } @@ -176,7 +195,7 @@ export function parseManifestVersion( * a real result. `force` bypasses the cache. */ export async function getUpdateStatus(force = false): Promise { - const current = BUILD_VERSION + const current = versionImpl const enabled = isUpdateCheckEnabled() const checkedAt = cache ? new Date(cache.atMs).toISOString() : null @@ -227,7 +246,8 @@ export async function getUpdateStatus(force = false): Promise { const status: UpdateStatus = { current, latest, - update_available: latest !== null && isNewerVersion(latest, current), + update_available: + latest !== null && isNewerVersion(latest, normalizeCurrent(current)), url: DOWNLOAD_URL, enabled: true, checked_at: new Date(atMs).toISOString(), diff --git a/tests/update-check.test.ts b/tests/update-check.test.ts index e300b70..da00c17 100644 --- a/tests/update-check.test.ts +++ b/tests/update-check.test.ts @@ -127,6 +127,46 @@ describe("getUpdateStatus", () => { expect(status.update_available).toBe(false) }) + test("a dev build of the current release is up to date, not an upgrade", async () => { + // build-sidecar.ts stamps local binaries `-dev+`; the + // published release of that same version must not read as an upgrade + // (a prerelease ranks below its release, so the naive compare flips it). + __setUpdateCheckDepsForTests({ + fetch: () => Promise.resolve(manifestJson("0.4.35")), + currentVersion: "0.4.35-dev+abc12345", + }) + + const status = await getUpdateStatus() + + expect(status.current).toBe("0.4.35-dev+abc12345") // full string, for diagnostics + expect(status.latest).toBe("0.4.35") + expect(status.update_available).toBe(false) + }) + + test("a dev build still sees a genuinely newer release", async () => { + __setUpdateCheckDepsForTests({ + fetch: () => Promise.resolve(manifestJson("0.4.36")), + currentVersion: "0.4.35-dev+abc12345", + }) + + const status = await getUpdateStatus() + + expect(status.update_available).toBe(true) + }) + + test("a real prerelease still sees its release as an upgrade", async () => { + // Only the `-dev+` local suffix is normalized; a beta/rc genuinely precedes + // the release and should still be offered the upgrade. + __setUpdateCheckDepsForTests({ + fetch: () => Promise.resolve(manifestJson("0.5.0")), + currentVersion: "0.5.0-beta.2", + }) + + const status = await getUpdateStatus() + + expect(status.update_available).toBe(true) + }) + test("caches within the TTL; force bypasses it", async () => { let clock = 1_000_000 __setUpdateCheckDepsForTests({