From bfba3c921509a6169ce6c4345a74aab7cc0d6235 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 23 Jul 2026 00:20:54 +0200 Subject: [PATCH] fix(releases): avoid unnecessary GitHub pagination for LinuxServer updates (#6444) --- .../request-handler/src/release-providers.ts | 66 ++++---- .../src/test/release-providers.spec.ts | 143 ++++++++++++++++++ packages/widgets/src/releases/component.tsx | 24 +-- .../src/releases/query-options.spec.ts | 25 +++ .../widgets/src/releases/query-options.ts | 17 +++ 5 files changed, 232 insertions(+), 43 deletions(-) create mode 100644 packages/widgets/src/releases/query-options.spec.ts create mode 100644 packages/widgets/src/releases/query-options.ts diff --git a/packages/request-handler/src/release-providers.ts b/packages/request-handler/src/release-providers.ts index b70136631..009c81dce 100644 --- a/packages/request-handler/src/release-providers.ts +++ b/packages/request-handler/src/release-providers.ts @@ -787,11 +787,34 @@ const getLinuxServerIOReleaseAsync = async ( error: { code: "noReleasesFound", message: `Image "${parsed.name}" not found in LinuxServer.io registry` }, }; - // LSIO's /api/v1/images only exposes the single latest version, so a version - // filter can never surface an older release (#4351). When the image has a GitHub - // repository, delegate to the GitHub release provider to fetch the real release - // history and apply the filter, while keeping LSIO's own image metadata. Any - // GitHub failure (no repo, no releases, rate limit) falls back to the LSIO latest. + const regex = compileVersionRegex(versionRegex); + if (versionRegex && !regex) { + return { + success: false, + error: { code: "noMatchingVersion", message: `Invalid regex: /${versionRegex}/` }, + }; + } + + if (!regex || regex.test(release.version)) { + return { + success: true, + data: { + latestRelease: release.version, + latestReleaseAt: release.version_timestamp, + releaseDescription: release.changelog?.shift()?.desc, + projectUrl: release.github_url, + projectDescription: release.description, + isArchived: release.deprecated, + createdAt: release.initial_date, + starsCount: release.stars, + }, + }; + } + + // LSIO's /api/v1/images only exposes the single latest version, so a filter + // that does not match it cannot surface an older release (#4351). Delegate to + // the image's GitHub repository to fetch the real release history while + // keeping LSIO's own image metadata. const githubRepo = parseGithubRepo(release.github_url); if (githubRepo) { const githubResult = await getGithubReleaseAsync(getReleaseProviderDefaultUrl("github"), githubRepo, versionRegex); @@ -808,37 +831,14 @@ const getLinuxServerIOReleaseAsync = async ( }, }; } - } - - const regex = compileVersionRegex(versionRegex); - if (versionRegex && !regex) { - return { - success: false, - error: { code: "noMatchingVersion", message: `Invalid regex: /${versionRegex}/` }, - }; - } - - if (regex && !regex.test(release.version)) { - return { - success: false, - error: { - code: "noMatchingVersion", - message: `Regex /${versionRegex}/ does not match LSIO version "${release.version}"`, - }, - }; + return githubResult; } return { - success: true, - data: { - latestRelease: release.version, - latestReleaseAt: release.version_timestamp, - releaseDescription: release.changelog?.shift()?.desc, - projectUrl: release.github_url, - projectDescription: release.description, - isArchived: release.deprecated, - createdAt: release.initial_date, - starsCount: release.stars, + success: false, + error: { + code: "noMatchingVersion", + message: `Regex /${versionRegex}/ does not match LSIO version "${release.version}"`, }, }; }; diff --git a/packages/request-handler/src/test/release-providers.spec.ts b/packages/request-handler/src/test/release-providers.spec.ts index 67ed3d71c..8eed8d60c 100644 --- a/packages/request-handler/src/test/release-providers.spec.ts +++ b/packages/request-handler/src/test/release-providers.spec.ts @@ -126,6 +126,128 @@ describe("getLatestMatchingReleaseAsync for GitHub Container Registry", () => { }); }); +describe("getLatestMatchingReleaseAsync for LinuxServer.io", () => { + beforeEach(() => { + mockedFetch.mockReset(); + }); + + test("uses the LinuxServer.io latest release without fetching GitHub history", async () => { + mockedFetch.mockResolvedValueOnce(createLinuxServerResponse()); + + const result = await getLatestMatchingReleaseAsync({ + id: "tautulli", + provider: "linuxServerIO", + identifier: "lscr.io/linuxserver/tautulli:latest", + }); + + expect(result).toEqual({ + success: true, + data: { + latestRelease: "v2.17.2-ls237", + latestReleaseAt: new Date("2026-07-17T00:25:42.000Z"), + releaseDescription: "Rebase to Alpine 3.24.", + projectUrl: "https://github.com/linuxserver/docker-tautulli", + projectDescription: "Tautulli image", + isArchived: false, + createdAt: new Date("2015-08-10T00:00:00.000Z"), + starsCount: 934, + }, + }); + expect(mockedFetch).toHaveBeenCalledOnce(); + expectUrl(0, "https://api.linuxserver.io/api/v1/images"); + }); + + test("fetches GitHub history when a version filter requires it", async () => { + mockedFetch.mockImplementation(async (input) => { + const url = new URL(input.toString()); + + if (url.origin === "https://api.linuxserver.io") return createLinuxServerResponse(); + if (url.pathname === "/repos/linuxserver/docker-tautulli/releases") { + return createJsonResponse([ + { + tag_name: "v2.16.0-ls200", + published_at: "2026-01-10T12:00:00.000Z", + html_url: "https://github.com/linuxserver/docker-tautulli/releases/tag/v2.16.0-ls200", + body: "Older supported image", + prerelease: false, + }, + ]); + } + if (url.pathname === "/repos/linuxserver/docker-tautulli") { + return createJsonResponse({ + html_url: "https://github.com/linuxserver/docker-tautulli", + description: "GitHub description", + fork: false, + archived: false, + created_at: "2015-08-10T00:00:00.000Z", + stargazers_count: 934, + open_issues_count: 2, + forks_count: 80, + }); + } + + return createJsonResponse({ message: "Not Found" }, { status: 404, statusText: "Not Found" }); + }); + + const result = await getLatestMatchingReleaseAsync({ + id: "tautulli", + provider: "linuxServerIO", + identifier: "linuxserver/tautulli", + versionRegex: "^v2\\.16\\..+-ls[0-9]+$", + }); + + expect(result).toEqual({ + success: true, + data: expect.objectContaining({ + latestRelease: "v2.16.0-ls200", + projectUrl: "https://github.com/linuxserver/docker-tautulli", + projectDescription: "Tautulli image", + }), + }); + expect(mockedFetch).toHaveBeenCalledTimes(3); + }); + + test("uses the LinuxServer.io latest release when it matches the version filter", async () => { + mockedFetch.mockResolvedValueOnce(createLinuxServerResponse()); + + const result = await getLatestMatchingReleaseAsync({ + id: "tautulli", + provider: "linuxServerIO", + identifier: "linuxserver/tautulli", + versionRegex: "^v2\\.17\\..+-ls[0-9]+$", + }); + + expect(result).toEqual({ + success: true, + data: expect.objectContaining({ latestRelease: "v2.17.2-ls237" }), + }); + expect(mockedFetch).toHaveBeenCalledOnce(); + expectUrl(0, "https://api.linuxserver.io/api/v1/images"); + }); + + test("returns the GitHub error when filtered release history cannot be fetched", async () => { + mockedFetch.mockImplementation(async (input) => { + const url = new URL(input.toString()); + if (url.origin === "https://api.linuxserver.io") return createLinuxServerResponse(); + + return createJsonResponse({ message: "GitHub unavailable" }, { status: 404, statusText: "Not Found" }); + }); + + const result = await getLatestMatchingReleaseAsync({ + id: "tautulli", + provider: "linuxServerIO", + identifier: "linuxserver/tautulli", + versionRegex: "^v2\\.16\\..+-ls[0-9]+$", + }); + + expect(result).toEqual({ + success: false, + error: expect.objectContaining({ code: "unexpected", message: expect.stringContaining("GitHub unavailable") }), + }); + expect(mockedFetch.mock.calls.length).toBeGreaterThan(1); + }); +}); + interface MockGhcrResponsesInput { registryOrigin?: string; repositoryName: string; @@ -185,6 +307,27 @@ const createJsonResponse = (body: unknown, init: JsonResponseInit = {}) => }, }); +const createLinuxServerResponse = () => + createJsonResponse({ + data: { + repositories: { + linuxserver: [ + { + name: "tautulli", + initial_date: "2015-08-10", + github_url: "https://github.com/linuxserver/docker-tautulli", + description: "Tautulli image", + version: "v2.17.2-ls237", + version_timestamp: "2026-07-17 00:25:42+00:00", + stars: 934, + deprecated: false, + changelog: [{ date: "2026-07-05", desc: "Rebase to Alpine 3.24." }], + }, + ], + }, + }, + }); + const expectUrl = (callIndex: number, expectedUrl: string) => { expect(mockedFetch.mock.calls[callIndex]?.[0].toString().split("?")[0]).toBe(expectedUrl); }; diff --git a/packages/widgets/src/releases/component.tsx b/packages/widgets/src/releases/component.tsx index f0e32df7d..ca61d24c3 100644 --- a/packages/widgets/src/releases/component.tsx +++ b/packages/widgets/src/releases/component.tsx @@ -29,6 +29,7 @@ import { MaskedOrNormalImage } from "@homarr/ui"; import type { WidgetComponentProps } from "../definition"; import classes from "./component.module.scss"; import type { ReleasesRepository, ReleasesRepositoryResponse } from "./releases-repository"; +import { getReleasesQueryStaleTimeMs } from "./query-options"; const formatRelativeDate = (value: string): string => { const isMonths = /\d+m/g.test(value); @@ -89,16 +90,19 @@ export default function ReleasesWidget({ options, itemId }: WidgetComponentProps const queryResults = clientApi.useQueries((trpc) => batchedRepositories.flatMap(({ provider, repositories }) => - trpc.widget.releases.getLatest({ - itemId, - repositories: repositories.map((repository) => ({ - id: repository.id, - provider, - identifier: repository.identifier, - versionFilter: repository.versionFilter, - providerUrl: repository.providerUrl, - })), - }), + trpc.widget.releases.getLatest( + { + itemId, + repositories: repositories.map((repository) => ({ + id: repository.id, + provider, + identifier: repository.identifier, + versionFilter: repository.versionFilter, + providerUrl: repository.providerUrl, + })), + }, + { staleTime: getReleasesQueryStaleTimeMs }, + ), ), ); diff --git a/packages/widgets/src/releases/query-options.spec.ts b/packages/widgets/src/releases/query-options.spec.ts new file mode 100644 index 000000000..8f476816f --- /dev/null +++ b/packages/widgets/src/releases/query-options.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "vitest"; + +import { getReleasesQueryStaleTimeMs } from "./query-options"; + +describe("getReleasesQueryStaleTimeMs", () => { + test("keeps successful release batches fresh for four hours", () => { + const staleTime = getReleasesQueryStaleTimeMs({ + state: { data: [{ success: true }, { success: true }] }, + }); + + expect(staleTime).toBe(4 * 60 * 60 * 1000); + }); + + test("keeps a batch stale when a provider returned an error", () => { + const staleTime = getReleasesQueryStaleTimeMs({ + state: { data: [{ success: true }, { success: false }] }, + }); + + expect(staleTime).toBe(0); + }); + + test.each([undefined, [], [{ unexpected: true }]])("keeps missing or invalid data stale", (data) => { + expect(getReleasesQueryStaleTimeMs({ state: { data } })).toBe(0); + }); +}); diff --git a/packages/widgets/src/releases/query-options.ts b/packages/widgets/src/releases/query-options.ts new file mode 100644 index 000000000..164fc8dcc --- /dev/null +++ b/packages/widgets/src/releases/query-options.ts @@ -0,0 +1,17 @@ +export const releasesQuerySuccessfulStaleTimeMs = 4 * 60 * 60 * 1000; + +interface QueryWithData { + state: { + data: unknown; + }; +} + +export const getReleasesQueryStaleTimeMs = ({ state }: QueryWithData) => { + if (!Array.isArray(state.data) || state.data.length === 0) return 0; + + const allRepositoriesSucceeded = state.data.every( + (result) => typeof result === "object" && result !== null && "success" in result && result.success === true, + ); + + return allRepositoriesSucceeded ? releasesQuerySuccessfulStaleTimeMs : 0; +};