From b07a4a0f3e3af384ec477bf5c505a705b003de47 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Wed, 6 May 2026 21:23:14 +0200 Subject: [PATCH] fix(update-checker): hide prereleases on non-prerelease versions (#5645) --- .../src/test/update-checker.spec.ts | 138 +++++++++++++++++ .../request-handler/src/update-checker.ts | 140 ++++++++++++------ 2 files changed, 230 insertions(+), 48 deletions(-) create mode 100644 packages/request-handler/src/test/update-checker.spec.ts diff --git a/packages/request-handler/src/test/update-checker.spec.ts b/packages/request-handler/src/test/update-checker.spec.ts new file mode 100644 index 000000000..4c8c1e94b --- /dev/null +++ b/packages/request-handler/src/test/update-checker.spec.ts @@ -0,0 +1,138 @@ +import type { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods"; +import { Octokit } from "octokit"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { getAvailableUpdatesAsync } from "../update-checker"; + +const mockEnv = vi.hoisted(() => ({ + NO_EXTERNAL_CONNECTION: false, +})); + +vi.mock("@homarr/common/env", () => ({ + env: mockEnv, +})); + +vi.mock("octokit", () => { + const mockOctokit = { + rest: { + repos: { + listReleases: vi.fn(), + }, + }, + }; + return { + Octokit: class { + rest = mockOctokit.rest; + }, + }; +}); + +describe("getAvailableUpdatesAsync", () => { + beforeEach(() => { + mockEnv.NO_EXTERNAL_CONNECTION = false; + }); + + test("should return available non pre-release updates", async () => { + // Arrange + const currentVersion = "v1.2.3"; + const releases = fakeReleasesWithPrereleases(); + const listReleasesSpy = vi.spyOn(new Octokit().rest.repos, "listReleases"); + listReleasesSpy.mockReturnValue(Promise.resolve({ data: releases, status: 200, headers: {}, url: "" })); + + // Act + const result = await getAvailableUpdatesAsync(currentVersion); + + // Assert + expect(result.map((update) => update.tagName)).toEqual(["v1.3.0", "v1.2.4"]); + expect(listReleasesSpy).toHaveBeenCalledWith({ + owner: "homarr-labs", + repo: "homarr", + }); + }); + + test("should return all available updates", async () => { + // Arrange + const currentVersion = "v1.2.4-beta.2"; + const releases = fakeReleasesWithPrereleases(); + const listReleasesSpy = vi.spyOn(new Octokit().rest.repos, "listReleases"); + listReleasesSpy.mockReturnValue(Promise.resolve({ data: releases, status: 200, headers: {}, url: "" })); + + // Act + const result = await getAvailableUpdatesAsync(currentVersion); + + // Assert + expect(result.map((update) => update.tagName)).toEqual([ + "v1.3.1-beta.1", + "v1.3.0", + "v1.3.0-beta.1", + "v1.2.4", + "v1.2.4-beta.3", + ]); + expect(listReleasesSpy).toHaveBeenCalledWith({ + owner: "homarr-labs", + repo: "homarr", + query: undefined, + }); + }); + + test("should skip releases with invalid semver tags", async () => { + // Arrange + const currentVersion = "v1.2.3"; + const releases = fakeReleases(["v1.2.4", false], ["invalid-tag", false], ["v1.3.0", false]); + const listReleasesSpy = vi.spyOn(new Octokit().rest.repos, "listReleases"); + listReleasesSpy.mockReturnValue(Promise.resolve({ data: releases, status: 200, headers: {}, url: "" })); + + // Act + const result = await getAvailableUpdatesAsync(currentVersion); + + // Assert + expect(result.map((update) => update.tagName)).toEqual(["v1.3.0", "v1.2.4"]); + }); + + test("should throw error for invalid current version", async () => { + // Arrange + const currentVersion = "invalid-version"; + + // Act + const act = () => getAvailableUpdatesAsync(currentVersion); + + // Assert + await expect(act()).rejects.toThrow("non semantic current version"); + }); + + test("should return empty array when NO_EXTERNAL_CONNECTION=true", async () => { + // Arrange + mockEnv.NO_EXTERNAL_CONNECTION = true; + const currentVersion = "v1.0.0"; + + // Act + const result = await getAvailableUpdatesAsync(currentVersion); + + // Assert + expect(result).toEqual([]); + }); +}); + +const fakeReleases = (...inputs: [string, boolean][]) => inputs.map(fakeRelease); +const fakeRelease = ([tagName, isPrerelease]: [string, boolean]) => + ({ + tag_name: tagName, + html_url: `https://github.com/homarr-labs/homarr/releases/tag/${tagName}`, + name: tagName, + body_html: undefined, + prerelease: isPrerelease, + }) as RestEndpointMethodTypes["repos"]["listReleases"]["response"]["data"][number]; + +const fakeReleasesWithPrereleases = () => + fakeReleases( + ["v1.1.0", false], + ["v1.2.2", false], + ["v1.2.3", false], + ["v1.2.4-beta.1", true], + ["v1.2.4-beta.2", true], + ["v1.2.4-beta.3", true], + ["v1.2.4", false], + ["v1.3.0-beta.1", true], + ["v1.3.0", false], + ["v1.3.1-beta.1", true], + ); diff --git a/packages/request-handler/src/update-checker.ts b/packages/request-handler/src/update-checker.ts index 755f14d3b..bd09ce89d 100644 --- a/packages/request-handler/src/update-checker.ts +++ b/packages/request-handler/src/update-checker.ts @@ -1,10 +1,11 @@ import dayjs from "dayjs"; import { Octokit } from "octokit"; -import { compareSemVer, isValidSemVer } from "semver-parser"; +import { compareSemVer, isValidSemVer, parseSemVer } from "semver-parser"; import { env } from "@homarr/common/env"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { createLogger } from "@homarr/core/infrastructure/logs"; +import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { createChannelWithLatestAndEvents } from "@homarr/redis"; import { createCachedRequestHandler } from "@homarr/request-handler/lib/cached-request-handler"; @@ -16,58 +17,101 @@ export const updateCheckerRequestHandler = createCachedRequestHandler({ queryKey: "homarr-update-checker", cacheDuration: dayjs.duration(1, "hour"), async requestAsync(_) { - if (env.NO_EXTERNAL_CONNECTION) - return { - availableUpdates: [], - }; - - const octokit = new Octokit({ - request: { - fetch: fetchWithTrustedCertificatesAsync, - }, - }); - const releases = await octokit.rest.repos.listReleases({ - owner: "homarr-labs", - repo: "homarr", - }); - - const currentVersion = (packageJson as { version: string }).version; - const availableReleases = []; - - for (const release of releases.data) { - if (!isValidSemVer(release.tag_name)) { - logger.warn("Unable to parse semantic tag. Update check might not work.", { tagName: release.tag_name }); - continue; - } - - availableReleases.push(release); - } - - const availableNewerReleases = availableReleases - .filter((release) => compareSemVer(release.tag_name, currentVersion) > 0) - .sort((releaseA, releaseB) => compareSemVer(releaseB.tag_name, releaseA.tag_name)); - if (availableNewerReleases.length > 0) { - logger.info( - "Update checker found a new available version", - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - { version: availableReleases[0]!.tag_name, currentVersion }, - ); - } else { - logger.debug("Update checker did not find any available updates", { currentVersion }); - } - return { - availableUpdates: availableNewerReleases.map((release) => ({ - name: release.name, - contentHtml: release.body_html, - url: release.html_url, - tagName: release.tag_name, - })), + availableUpdates: await getAvailableUpdatesAsync(packageJson.version), }; }, createRedisChannel() { return createChannelWithLatestAndEvents<{ - availableUpdates: { name: string | null; contentHtml?: string; url: string; tagName: string }[]; + availableUpdates: Update[]; }>("homarr:update"); }, }); + +interface Update { + name: string | null; + contentHtml?: string; + url: string; + tagName: string; + isPrerelease: boolean; +} + +const isPrereleaseTag = (tagName: string) => { + try { + const parsed = parseSemVer(tagName); + return Boolean(parsed.pre?.length); + } catch { + return false; + } +}; + +export const getAvailableUpdatesAsync = async (currentVersion: string) => { + if (env.NO_EXTERNAL_CONNECTION) return []; + + if (!isValidSemVer(currentVersion)) { + throw new ErrorWithMetadata("Unable to check for updates due to non semantic current version", { + currentVersion, + }); + } + + const octokit = new Octokit({ + request: { + fetch: fetchWithTrustedCertificatesAsync, + }, + }); + + const isCurrentPrerelease = isPrereleaseTag(currentVersion); + + const releases = await octokit.rest.repos.listReleases({ + owner: "homarr-labs", + repo: "homarr", + }); + + const { skippedTags, semanticReleases } = releases.data + .map((release) => ({ + name: release.name, + contentHtml: release.body_html, + url: release.html_url, + tagName: release.tag_name, + isPrerelease: release.prerelease, + })) + .reduce( + (prev, curr) => { + if (!isValidSemVer(curr.tagName)) { + prev.skippedTags.push(curr.tagName); + return prev; + } + + prev.semanticReleases.push(curr); + return prev; + }, + { semanticReleases: [] as Update[], skippedTags: [] as string[] }, + ); + + if (skippedTags.length > 0) { + logger.warn( + "Some releases were skipped during the update check because their tag name is not a valid semantic version", + { + skippedTags: skippedTags.join(","), + }, + ); + } + + const availableUpdates = semanticReleases + .filter((release) => isCurrentPrerelease || !release.isPrerelease) + .filter((release) => compareSemVer(release.tagName, currentVersion) > 0) + .sort((releaseA, releaseB) => compareSemVer(releaseB.tagName, releaseA.tagName)); + + if (availableUpdates.length === 0) { + logger.debug("No available updates found", { currentVersion }); + return []; + } + + logger.info("Found available updates", { + version: availableUpdates[0]?.tagName, + count: availableUpdates.length, + currentVersion, + }); + + return availableUpdates; +};