From 5c9b89eb13d1f4f37a1d322d8de9d94514958e56 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 31 Oct 2026 15:44:22 +0100 Subject: [PATCH] fix(updates): let a release candidate's manual check see newer RCs releases/latest only resolves to a stable release, so an RC compared itself against the last stable, found it older, and said up to date while the next RC was live. On a prerelease, read the release list and compare against the newest entry, stable or not. Stable installs keep reading releases/latest. Fixes #1447 --- electron/update-checker.test.ts | 62 +++++++++++++++++++++++++++++++++ electron/update-checker.ts | 51 ++++++++++++++++++++------- 2 files changed, 100 insertions(+), 13 deletions(-) diff --git a/electron/update-checker.test.ts b/electron/update-checker.test.ts index aa7aaf5d34..5a5e62f242 100644 --- a/electron/update-checker.test.ts +++ b/electron/update-checker.test.ts @@ -154,6 +154,68 @@ describe("checkLatestRelease", () => { ); }); + // releases/latest never returns a prerelease, so an RC read it and never saw the next RC (#1447). + describe("on a release candidate", () => { + const release = (tag: string, prerelease: boolean) => ({ + tag_name: tag, + html_url: `https://github.com/getopenscreen/openscreen/releases/tag/${tag}`, + draft: false, + prerelease, + }); + const releases = [ + release("v2.1.2", false), + release("v2.2.0-rc.2", true), + release("v2.2.0-rc.1", true), + ]; + + it("offers the next RC from the release list", async () => { + const fetchLatest = vi.fn().mockResolvedValue(releaseResponse(releases)); + + await expect( + checkLatestRelease({ currentVersion: "2.2.0-rc.1", fetchLatest }), + ).resolves.toEqual({ + kind: "available", + currentVersion: "2.2.0-rc.1", + latestVersion: "2.2.0-rc.2", + releaseUrl: "https://github.com/getopenscreen/openscreen/releases/tag/v2.2.0-rc.2", + }); + expect(fetchLatest).toHaveBeenCalledWith( + "https://api.github.com/repos/getopenscreen/openscreen/releases?per_page=30", + expect.anything(), + ); + }); + + it("offers the stable release that supersedes it", async () => { + const fetchLatest = vi + .fn() + .mockResolvedValue(releaseResponse([release("v2.2.0", false), ...releases])); + + await expect( + checkLatestRelease({ currentVersion: "2.2.0-rc.2", fetchLatest }), + ).resolves.toMatchObject({ kind: "available", latestVersion: "2.2.0" }); + }); + + it("reports current on the newest RC", async () => { + const fetchLatest = vi.fn().mockResolvedValue(releaseResponse(releases)); + + await expect( + checkLatestRelease({ currentVersion: "2.2.0-rc.2", fetchLatest }), + ).resolves.toMatchObject({ kind: "current", latestVersion: "2.2.0-rc.2" }); + }); + + it.each([ + [[]], + [release("v2.2.0-rc.2", true)], + [[{ ...release("v9.9.9", true), draft: true }]], + ])("rejects an empty, non-list or draft response: %o", async (payload) => { + const fetchLatest = vi.fn().mockResolvedValue(releaseResponse(payload)); + + await expect( + checkLatestRelease({ currentVersion: "2.2.0-rc.1", fetchLatest }), + ).rejects.toThrow("invalid GitHub release response"); + }); + }); + it("rejects unsuccessful or malformed GitHub responses", async () => { const unavailable = vi.fn().mockResolvedValue(releaseResponse({}, 503)); await expect( diff --git a/electron/update-checker.ts b/electron/update-checker.ts index beeb1773c7..a1d5b9f777 100644 --- a/electron/update-checker.ts +++ b/electron/update-checker.ts @@ -1,4 +1,5 @@ const LATEST_RELEASE_API = "https://api.github.com/repos/getopenscreen/openscreen/releases/latest"; +const RELEASES_API = "https://api.github.com/repos/getopenscreen/openscreen/releases?per_page=30"; const OFFICIAL_RELEASE_PREFIX = "/getopenscreen/openscreen/releases/tag/"; interface ReleaseResponse { @@ -118,12 +119,47 @@ export type UpdateCheckResult = latestVersion: string; }; +interface Release { + tag_name: string; + html_url: string; +} + +function asRelease(payload: unknown, allowPrerelease: boolean): Release { + const fields = payload as Record; + if ( + typeof payload !== "object" || + payload === null || + typeof fields.tag_name !== "string" || + typeof fields.html_url !== "string" || + fields.draft !== false || + (allowPrerelease ? typeof fields.prerelease !== "boolean" : fields.prerelease !== false) + ) { + throw new Error("invalid GitHub release response"); + } + return payload as Release; +} + +function newestRelease(payload: unknown): Release { + if (!Array.isArray(payload) || payload.length === 0) { + throw new Error("invalid GitHub release response"); + } + return payload + .map((entry) => asRelease(entry, true)) + .reduce((newest, release) => + compareVersions(release.tag_name, newest.tag_name) > 0 ? release : newest, + ); +} + export async function checkLatestRelease(options: { currentVersion: string; fetchLatest: FetchLatestRelease; signal?: AbortSignal; }): Promise { - const response = await options.fetchLatest(LATEST_RELEASE_API, { + const current = parseVersion(options.currentVersion); + // releases/latest only ever resolves to a stable release, so an RC would never be offered the + // next RC (#1447). An RC tester opted into prereleases: compare against them too. + const onPrerelease = current.prerelease.length > 0; + const response = await options.fetchLatest(onPrerelease ? RELEASES_API : LATEST_RELEASE_API, { headers: { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", @@ -133,18 +169,7 @@ export async function checkLatestRelease(options: { if (!response.ok) throw new Error(`GitHub release check failed (${response.status})`); const payload = await response.json(); - if ( - typeof payload !== "object" || - payload === null || - typeof (payload as Record).tag_name !== "string" || - typeof (payload as Record).html_url !== "string" || - (payload as Record).draft !== false || - (payload as Record).prerelease !== false - ) { - throw new Error("invalid GitHub release response"); - } - const release = payload as { tag_name: string; html_url: string }; - const current = parseVersion(options.currentVersion); + const release = onPrerelease ? newestRelease(payload) : asRelease(payload, false); const latest = parseVersion(release.tag_name); const comparison = compareVersions(latest.normalized, current.normalized); if (comparison <= 0) {