fix(releases): avoid unnecessary GitHub pagination for LinuxServer updates (#6444)
This commit is contained in:
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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in new issue
Block a user