Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit c6546e3

Browse files
authored
fix(updates): make changelog cache and loading state version-aware (#3167)
1 parent 4e1ce7e commit c6546e3

6 files changed

Lines changed: 211 additions & 42 deletions

File tree

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import { publicProcedure, router } from "@posthog/host-trpc/trpc";
22
import type { GitHubReleasesService } from "@posthog/workspace-server/services/github-releases/github-releases";
33
import { GITHUB_RELEASES_SERVICE } from "@posthog/workspace-server/services/github-releases/identifiers";
4-
import { listReleasesOutput } from "@posthog/workspace-server/services/github-releases/schemas";
4+
import {
5+
listReleasesInput,
6+
listReleasesOutput,
7+
} from "@posthog/workspace-server/services/github-releases/schemas";
58

69
export const githubReleasesRouter = router({
710
list: publicProcedure
11+
.input(listReleasesInput)
812
.output(listReleasesOutput)
9-
.query(({ ctx }) =>
13+
.query(({ ctx, input }) =>
1014
ctx.container
1115
.get<GitHubReleasesService>(GITHUB_RELEASES_SERVICE)
12-
.listReleases(),
16+
.listReleases(input?.expectVersion),
1317
),
1418
});

packages/ui/src/features/updates/UpdateAvailableModal.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,14 @@ export function UpdateAvailableModal() {
6262
const downloadMutation = useMutation(
6363
hostTRPC.updates.download.mutationOptions(),
6464
);
65+
const targetVersion = version ?? availableVersion;
6566
const { data: releasesData, isPending: isPendingReleases } = useQuery({
66-
...hostTRPC.githubReleases.list.queryOptions(),
67+
...hostTRPC.githubReleases.list.queryOptions(
68+
targetVersion ? { expectVersion: targetVersion } : undefined,
69+
),
6770
enabled: isOpen,
6871
});
6972

70-
const targetVersion = version ?? availableVersion;
7173
const percent = Math.round(downloadPercent ?? 0);
7274
const sizeLabel = formatSize(downloadSizeBytes);
7375
const isDownloading = status === "downloading";

packages/ui/src/features/updates/WhatsNewModal.tsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,20 @@ export function WhatsNewModal() {
4242
const isOpen = useWhatsNewStore((state) => state.isOpen);
4343
const close = useWhatsNewStore((state) => state.close);
4444
const hostTRPC = useHostTRPC();
45-
const { data, isLoading, isError } = useQuery({
46-
...hostTRPC.githubReleases.list.queryOptions(),
47-
enabled: isOpen,
48-
});
49-
const { data: currentVersion } = useQuery(
45+
const { data: currentVersion, isError: isVersionError } = useQuery(
5046
hostTRPC.os.getAppVersion.queryOptions(),
5147
);
48+
const {
49+
data,
50+
isPending,
51+
isError: isReleasesError,
52+
} = useQuery({
53+
...hostTRPC.githubReleases.list.queryOptions(
54+
currentVersion ? { expectVersion: currentVersion } : undefined,
55+
),
56+
enabled: isOpen && !!currentVersion,
57+
});
58+
const isError = isVersionError || isReleasesError;
5259

5360
const groups = groupReleases(data?.releases ?? []);
5461

@@ -76,12 +83,12 @@ export function WhatsNewModal() {
7683
</Dialog.Close>
7784
</Flex>
7885

79-
{isLoading ? (
80-
<ChangelogSkeleton />
81-
) : isError ? (
86+
{isError ? (
8287
<Text color="gray" size="2">
8388
Could not load releases. Please try again later.
8489
</Text>
90+
) : isPending ? (
91+
<ChangelogSkeleton />
8592
) : groups.length === 0 ? (
8693
<Text color="gray" size="2">
8794
No releases found.

packages/workspace-server/src/services/github-releases/github-releases.test.ts

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,102 @@ describe("GitHubReleasesService", () => {
6969
});
7070
});
7171

72-
it("caches results within the TTL", async () => {
72+
it.each([
73+
{ expectVersion: undefined, expectedFetches: 1 },
74+
{ expectVersion: "1.2.0", expectedFetches: 1 },
75+
{ expectVersion: "1.3.0", expectedFetches: 2 },
76+
])(
77+
"a fresh cache is a hit for expectVersion $expectVersion only when it contains it ($expectedFetches fetches)",
78+
async ({ expectVersion, expectedFetches }) => {
79+
const service = new GitHubReleasesService();
80+
await service.listReleases();
81+
await service.listReleases(expectVersion);
82+
expect(fetchMock).toHaveBeenCalledTimes(expectedFetches);
83+
},
84+
);
85+
86+
it("caches the refetched list once it contains the expected version", async () => {
7387
const service = new GitHubReleasesService();
7488
await service.listReleases();
89+
90+
fetchMock.mockResolvedValueOnce({
91+
ok: true,
92+
status: 200,
93+
json: async () => [
94+
{
95+
tag_name: "v1.3.0",
96+
name: "v1.3.0",
97+
body: "new",
98+
draft: false,
99+
prerelease: false,
100+
published_at: "2026-06-30T00:00:00Z",
101+
html_url: "https://github.com/PostHog/code/releases/tag/v1.3.0",
102+
},
103+
...sampleReleases,
104+
],
105+
});
106+
const second = await service.listReleases("1.3.0");
107+
expect(second.releases[0].version).toBe("1.3.0");
108+
109+
const third = await service.listReleases("1.3.0");
110+
expect(third).toEqual(second);
111+
expect(fetchMock).toHaveBeenCalledTimes(2);
112+
});
113+
114+
it("dedupes concurrent cache misses into a single fetch", async () => {
115+
const service = new GitHubReleasesService();
116+
const [first, second] = await Promise.all([
117+
service.listReleases(),
118+
service.listReleases("1.3.0"),
119+
]);
120+
121+
expect(first).toEqual(second);
122+
expect(fetchMock).toHaveBeenCalledTimes(1);
123+
});
124+
125+
it("concurrent callers both reject when the shared fetch fails, and inFlight is cleared so subsequent calls retry", async () => {
126+
fetchMock.mockRejectedValueOnce(new Error("network error"));
127+
const service = new GitHubReleasesService();
128+
129+
const [result1, result2] = await Promise.allSettled([
130+
service.listReleases(),
131+
service.listReleases("1.2.0"),
132+
]);
133+
134+
expect(result1.status).toBe("rejected");
135+
expect(result2.status).toBe("rejected");
136+
// inFlight must be cleared so the next call retries rather than hanging
75137
await service.listReleases();
138+
expect(fetchMock).toHaveBeenCalledTimes(2);
139+
});
140+
141+
it("waits out a cooldown before refetching a still-missing version", async () => {
142+
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(0);
143+
const service = new GitHubReleasesService();
144+
await service.listReleases("9.9.9");
145+
await service.listReleases("9.9.9");
76146
expect(fetchMock).toHaveBeenCalledTimes(1);
147+
148+
nowSpy.mockReturnValue(61_000);
149+
await service.listReleases("9.9.9");
150+
expect(fetchMock).toHaveBeenCalledTimes(2);
151+
nowSpy.mockRestore();
152+
});
153+
154+
it("serves stale cache when a version-miss refetch fails, without retrying within the cooldown", async () => {
155+
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(0);
156+
const service = new GitHubReleasesService();
157+
const first = await service.listReleases();
158+
159+
fetchMock.mockResolvedValueOnce({ ok: false, status: 500 });
160+
const second = await service.listReleases("1.3.0");
161+
expect(second).toEqual(first);
162+
expect(fetchMock).toHaveBeenCalledTimes(2);
163+
164+
const third = await service.listReleases("1.3.0");
165+
expect(third).toEqual(first);
166+
expect(fetchMock).toHaveBeenCalledTimes(2);
167+
nowSpy.mockRestore();
77168
});
78169

79170
it("throws on non-ok responses", async () => {

packages/workspace-server/src/services/github-releases/github-releases.ts

Lines changed: 89 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,49 +4,110 @@ import { githubReleasesApiResponse, type ListReleasesOutput } from "./schemas";
44
const RELEASES_URL =
55
"https://api.github.com/repos/PostHog/code/releases?per_page=30";
66
const CACHE_TTL_MS = 10 * 60_000;
7+
const MISSING_VERSION_RETRY_MS = 60_000;
78
const FETCH_TIMEOUT_MS = 10_000;
89

910
@injectable()
1011
export class GitHubReleasesService {
1112
private cache: { fetchedAt: number; data: ListReleasesOutput } | null = null;
13+
private missingVersionRefetchNotBefore = 0;
14+
private inFlight: Promise<ListReleasesOutput> | null = null;
1215

13-
async listReleases(): Promise<ListReleasesOutput> {
14-
if (this.cache && Date.now() - this.cache.fetchedAt < CACHE_TTL_MS) {
15-
return this.cache.data;
16+
async listReleases(expectVersion?: string): Promise<ListReleasesOutput> {
17+
const now = Date.now();
18+
const normalizedVersion = expectVersion?.replace(/^v/, "");
19+
const cached = this.cachedData(normalizedVersion, now);
20+
if (cached !== null) {
21+
return cached;
1622
}
1723

24+
// The fetch is version-agnostic, so any concurrent caller can share it.
25+
let promise = this.inFlight;
26+
if (promise === null) {
27+
promise = this.fetchAndCacheReleases();
28+
this.inFlight = promise;
29+
}
1830
try {
19-
const response = await fetch(RELEASES_URL, {
20-
headers: { Accept: "application/vnd.github+json" },
21-
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
22-
});
23-
if (!response.ok) {
24-
throw new Error(`GitHub releases fetch failed: ${response.status}`);
25-
}
26-
27-
const parsed = githubReleasesApiResponse.parse(await response.json());
28-
const releases = parsed
29-
.filter((release) => !release.draft)
30-
.map((release) => ({
31-
version: release.tag_name.replace(/^v/, ""),
32-
name:
33-
release.name && release.name.length > 0
34-
? release.name
35-
: release.tag_name,
36-
notes: release.body ?? "",
37-
date: release.published_at,
38-
isPrerelease: release.prerelease,
39-
htmlUrl: release.html_url,
40-
}));
41-
42-
const data: ListReleasesOutput = { releases };
43-
this.cache = { fetchedAt: Date.now(), data };
31+
const data = await promise;
32+
this.updateMissingVersionCooldown(normalizedVersion, now);
4433
return data;
4534
} catch (error) {
4635
if (this.cache) {
36+
this.updateMissingVersionCooldown(normalizedVersion, now);
4737
return this.cache.data;
4838
}
4939
throw error;
40+
} finally {
41+
if (this.inFlight === promise) {
42+
this.inFlight = null;
43+
}
44+
}
45+
}
46+
47+
private async fetchAndCacheReleases(): Promise<ListReleasesOutput> {
48+
const response = await fetch(RELEASES_URL, {
49+
headers: { Accept: "application/vnd.github+json" },
50+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
51+
});
52+
if (!response.ok) {
53+
throw new Error(`GitHub releases fetch failed: ${response.status}`);
54+
}
55+
56+
const parsed = githubReleasesApiResponse.parse(await response.json());
57+
const releases = parsed
58+
.filter((release) => !release.draft)
59+
.map((release) => ({
60+
version: release.tag_name.replace(/^v/, ""),
61+
name:
62+
release.name && release.name.length > 0
63+
? release.name
64+
: release.tag_name,
65+
notes: release.body ?? "",
66+
date: release.published_at,
67+
isPrerelease: release.prerelease,
68+
htmlUrl: release.html_url,
69+
}));
70+
71+
const data: ListReleasesOutput = { releases };
72+
this.cache = { fetchedAt: Date.now(), data };
73+
return data;
74+
}
75+
76+
// The cooldown only ever matters within an already-valid TTL window:
77+
// once the cache expires the TTL check short-circuits first, so the
78+
// cooldown naturally resets on the next successful fetch.
79+
private cachedData(
80+
expectVersion: string | undefined,
81+
now: number,
82+
): ListReleasesOutput | null {
83+
if (!this.cache || now - this.cache.fetchedAt >= CACHE_TTL_MS) {
84+
return null;
85+
}
86+
// No version requirement: any fresh cache is fine.
87+
if (expectVersion === undefined) return this.cache.data;
88+
// Version present in cache: serve it.
89+
if (this.cacheContains(expectVersion)) return this.cache.data;
90+
// Version missing but cooldown active: suppress the refetch.
91+
// The cooldown is a single scalar (not keyed per version) — safe because
92+
// cacheContains already short-circuits above when the version is found,
93+
// so the cooldown is only consulted while the version is absent.
94+
return now < this.missingVersionRefetchNotBefore ? this.cache.data : null;
95+
}
96+
97+
private cacheContains(version: string): boolean {
98+
return (
99+
this.cache?.data.releases.some(
100+
(release) => release.version === version,
101+
) ?? false
102+
);
103+
}
104+
105+
private updateMissingVersionCooldown(
106+
expectVersion: string | undefined,
107+
now: number,
108+
): void {
109+
if (expectVersion !== undefined && !this.cacheContains(expectVersion)) {
110+
this.missingVersionRefetchNotBefore = now + MISSING_VERSION_RETRY_MS;
50111
}
51112
}
52113
}

packages/workspace-server/src/services/github-releases/schemas.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ export const releaseItem = z.object({
2121
htmlUrl: z.string(),
2222
});
2323

24+
export const listReleasesInput = z
25+
.object({ expectVersion: z.string().optional() })
26+
.optional();
27+
2428
export const listReleasesOutput = z.object({
2529
releases: z.array(releaseItem),
2630
});

0 commit comments

Comments
 (0)