fix(releases): avoid unnecessary GitHub pagination for LinuxServer updates (#6444)

This commit is contained in:
Thomas authored and GitHub committed 2026-07-23 00:20:54 +02:00
1 parent 7184bc09c1
commit bfba3c9215
5 files changed
+232 -43

No files matched your search

@@ -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}"`,
},
};
};
@@ -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);
};
+14 -10
View File
@@ -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 },
),
),
);
@@ -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);
});
});
@@ -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;
};