diff --git a/src/backend/ipc/release-channel-handlers.test.ts b/src/backend/ipc/release-channel-handlers.test.ts index 0465993c..a972efb1 100644 --- a/src/backend/ipc/release-channel-handlers.test.ts +++ b/src/backend/ipc/release-channel-handlers.test.ts @@ -372,4 +372,24 @@ describe("ReleaseChannelIPCHandlers — PR releases require a healthy GitHub tok expect(verifyGitHubTokenMock).not.toHaveBeenCalled(); expect(result).toEqual({ available: false, currentVersion: "1.2.3" }); }); + + it("does not attach a saved (possibly invalid/expired) token as an auth header when switching to the latest stable (public) channel (#600)", async () => { + // The stable channel is served from a public GitHub repo and must not require — or send — a + // GitHub token at all. Before the fix, configureAutoUpdater() unconditionally attached any + // saved token as a Bearer header before branching on channel type, so an invalid/expired token + // (which the user is never prompted to fix, since the stable channel doesn't gate on health) + // was still sent to the public GitHub releases provider. + getChannelMock.mockResolvedValue({ type: "latest" }); + getTokenMock.mockResolvedValue("saved-but-possibly-invalid-token"); + + const { autoUpdater } = (await import("electron-updater")) as unknown as { + autoUpdater: { requestHeaders?: Record }; + }; + autoUpdater.requestHeaders = {}; + + const handlers = new ReleaseChannelIPCHandlers(); + await handlers.configureAutoUpdater({ type: "latest" }); + + expect(autoUpdater.requestHeaders?.Authorization).toBeUndefined(); + }); }); diff --git a/src/backend/ipc/release-channel-handlers.ts b/src/backend/ipc/release-channel-handlers.ts index 6ce4ad70..a6c75135 100644 --- a/src/backend/ipc/release-channel-handlers.ts +++ b/src/backend/ipc/release-channel-handlers.ts @@ -571,16 +571,17 @@ export class ReleaseChannelIPCHandlers { return; } - const githubToken = await this.tokenStore.getToken(); - if (githubToken) { - autoUpdater.requestHeaders = { - ...autoUpdater.requestHeaders, - Authorization: `Bearer ${githubToken}`, - }; - } - // Configure autoUpdater based on channel if (channel.type === "latest") { + // #600 — the stable channel is served from this repo's *public* GitHub releases, which never + // need authentication. Don't attach a saved token here at all: a saved-but-invalid/expired + // token (which this channel never gates on, unlike PR channels below) would otherwise be sent + // as a Bearer header on every request to the public provider, risking spurious 401s instead of + // the clean unauthenticated request GitHub expects for public repos. Explicitly clear any + // Authorization header a previous PR-channel configuration may have left behind. + const { Authorization: _unused, ...headersWithoutAuth } = autoUpdater.requestHeaders ?? {}; + autoUpdater.requestHeaders = headersWithoutAuth; + autoUpdater.channel = "latest"; autoUpdater.allowPrerelease = false; autoUpdater.allowDowngrade = false; @@ -618,6 +619,17 @@ export class ReleaseChannelIPCHandlers { return; } + // Only attach the token once it's confirmed healthy above (#600) — PR release assets are + // downloaded via a "generic" feed pointed at this repo's own GitHub releases, which needs + // authentication headers to avoid anonymous rate limits during the download. + const githubToken = await this.tokenStore.getToken(); + if (githubToken) { + autoUpdater.requestHeaders = { + ...autoUpdater.requestHeaders, + Authorization: `Bearer ${githubToken}`, + }; + } + // For PR channels, we need to find the latest release tag first const prNumber = this.extractPRNumberFromChannel(channel.channel); if (prNumber) { diff --git a/src/ui/src/components/settings/release-channels/ReleaseChannelSetting.test.tsx b/src/ui/src/components/settings/release-channels/ReleaseChannelSetting.test.tsx index bc4d789e..c137295c 100644 --- a/src/ui/src/components/settings/release-channels/ReleaseChannelSetting.test.tsx +++ b/src/ui/src/components/settings/release-channels/ReleaseChannelSetting.test.tsx @@ -153,6 +153,28 @@ describe("ReleaseChannelSetting (#423)", () => { }); }); + it("keeps 'Check for Updates' enabled for the Latest Stable channel with no GitHub token saved (#600)", async () => { + hasToken.mockResolvedValue(false); + verifyToken.mockResolvedValue({ isValid: false }); + + render(); + await screen.findByText("1.2.3"); + + const button = await screen.findByRole("button", { name: /check for updates/i }); + await waitFor(() => expect(button).toBeEnabled()); + }); + + it("keeps 'Check for Updates' enabled for the Latest Stable channel with an invalid/expired GitHub token saved (#600)", async () => { + hasToken.mockResolvedValue(true); + verifyToken.mockResolvedValue({ isValid: false, error: "Invalid or expired token" }); + + render(); + await screen.findByText("1.2.3"); + + const button = await screen.findByRole("button", { name: /check for updates/i }); + await waitFor(() => expect(button).toBeEnabled()); + }); + it("keeps the version card's bump label consistent with the toast when the update check resolves before loadCurrentVersion", async () => { // loadCurrentVersion() never resolves (simulates it not having finished yet), so // `currentVersion` state starts empty; the version card's label must still match diff --git a/src/ui/src/components/settings/release-channels/ReleaseChannelSetting.tsx b/src/ui/src/components/settings/release-channels/ReleaseChannelSetting.tsx index b98711b8..1aa29b9b 100644 --- a/src/ui/src/components/settings/release-channels/ReleaseChannelSetting.tsx +++ b/src/ui/src/components/settings/release-channels/ReleaseChannelSetting.tsx @@ -370,7 +370,12 @@ export function ReleaseChannelSetting({ isActive }: ReleaseChannelSettingProps)