diff --git a/electron/update-checker.test.ts b/electron/update-checker.test.ts index aa7aaf5d3..cf32899db 100644 --- a/electron/update-checker.test.ts +++ b/electron/update-checker.test.ts @@ -165,4 +165,79 @@ describe("checkLatestRelease", () => { checkLatestRelease({ currentVersion: "1.9.0", fetchLatest: malformed }), ).rejects.toThrow("invalid GitHub release response"); }); + + describe("on a prerelease build", () => { + function release(tag: string, flags: { draft?: boolean; prerelease?: boolean } = {}) { + return { + tag_name: tag, + html_url: `https://github.com/getopenscreen/openscreen/releases/tag/${tag}`, + draft: flags.draft ?? false, + prerelease: flags.prerelease ?? false, + }; + } + + it("reads the release list and reports a newer RC", async () => { + const fetchLatest = vi + .fn() + .mockResolvedValue( + releaseResponse([ + release("v2.2.0-rc.2", { prerelease: true }), + release("v2.2.0-rc.1", { prerelease: true }), + release("v2.1.0"), + ]), + ); + + 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=20", + expect.anything(), + ); + }); + + it("reports a newer stable release", async () => { + const fetchLatest = vi + .fn() + .mockResolvedValue( + releaseResponse([release("v2.2.0"), release("v2.2.0-rc.2", { prerelease: true })]), + ); + + await expect( + checkLatestRelease({ currentVersion: "2.2.0-rc.2", fetchLatest }), + ).resolves.toMatchObject({ kind: "available", latestVersion: "2.2.0" }); + }); + + it("ignores drafts", async () => { + const fetchLatest = vi + .fn() + .mockResolvedValue( + releaseResponse([ + release("v2.2.0-rc.3", { draft: true, prerelease: true }), + release("v2.2.0-rc.2", { prerelease: true }), + ]), + ); + + await expect( + checkLatestRelease({ currentVersion: "2.2.0-rc.2", fetchLatest }), + ).resolves.toMatchObject({ kind: "current", latestVersion: "2.2.0-rc.2" }); + }); + + it("leaves stable builds on releases/latest", async () => { + const fetchLatest = vi.fn().mockResolvedValue(releaseResponse(release("v2.1.0"))); + + await expect( + checkLatestRelease({ currentVersion: "2.1.0", fetchLatest }), + ).resolves.toMatchObject({ kind: "current" }); + expect(fetchLatest).toHaveBeenCalledWith( + "https://api.github.com/repos/getopenscreen/openscreen/releases/latest", + expect.anything(), + ); + }); + }); }); diff --git a/electron/update-checker.ts b/electron/update-checker.ts index beeb1773c..d49c75d1d 100644 --- a/electron/update-checker.ts +++ b/electron/update-checker.ts @@ -1,4 +1,7 @@ -const LATEST_RELEASE_API = "https://api.github.com/repos/getopenscreen/openscreen/releases/latest"; +const RELEASES_API = "https://api.github.com/repos/getopenscreen/openscreen/releases"; +const LATEST_RELEASE_API = `${RELEASES_API}/latest`; +// An RC also wants newer RCs, which `releases/latest` never returns. +const RECENT_RELEASES_API = `${RELEASES_API}?per_page=20`; const OFFICIAL_RELEASE_PREFIX = "/getopenscreen/openscreen/releases/tag/"; interface ReleaseResponse { @@ -118,33 +121,72 @@ export type UpdateCheckResult = latestVersion: string; }; +interface Release { + tag_name: string; + html_url: string; +} + +function isRelease(value: unknown): value is Release & { draft: boolean; prerelease: boolean } { + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + return ( + typeof record.tag_name === "string" && + typeof record.html_url === "string" && + typeof record.draft === "boolean" && + typeof record.prerelease === "boolean" + ); +} + +function isValidVersion(value: string): boolean { + try { + parseVersion(value); + return true; + } catch { + return false; + } +} + +function newestRelease(payload: unknown): Release { + if (!Array.isArray(payload)) throw new Error("invalid GitHub release response"); + const candidates = payload.filter( + (release): release is Release => + isRelease(release) && !release.draft && isValidVersion(release.tag_name), + ); + if (candidates.length === 0) throw new Error("invalid GitHub release response"); + return candidates.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, { - headers: { - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", + const current = parseVersion(options.currentVersion); + const onPrerelease = current.prerelease.length > 0; + const response = await options.fetchLatest( + onPrerelease ? RECENT_RELEASES_API : LATEST_RELEASE_API, + { + headers: { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ...(options.signal ? { signal: options.signal } : {}), }, - ...(options.signal ? { signal: options.signal } : {}), - }); + ); 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"); + let release: Release; + if (onPrerelease) { + release = newestRelease(payload); + } else { + if (!isRelease(payload) || payload.draft || payload.prerelease) { + throw new Error("invalid GitHub release response"); + } + release = payload; } - const release = payload as { tag_name: string; html_url: string }; - const current = parseVersion(options.currentVersion); const latest = parseVersion(release.tag_name); const comparison = compareVersions(latest.normalized, current.normalized); if (comparison <= 0) {