From c4778ef914a907387ecb79782ddea0a844980bb3 Mon Sep 17 00:00:00 2001 From: armada-lookout Date: Tue, 21 Jul 2026 04:12:57 +0000 Subject: [PATCH] fix: don't require or send a GitHub PAT for the public stable release channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stable ("latest") release channel is served from this repo's public GitHub releases and never needs authentication, but configureAutoUpdater() unconditionally attached any saved token as a Bearer header before branching on channel type — so a saved-but-invalid/expired token (which this channel never health-gates, unlike PR channels) was still sent on every request to the public provider. The "Check for Updates" button was also disabled on the stable channel whenever no token was saved at all. Move the token attachment into the PR-channel branch, only after the token is confirmed healthy, and explicitly clear any leftover Authorization header when configuring the stable channel. Stop gating the "Check for Updates" button's enabled state on hasGitHubToken for the stable channel. Closes #600 --- .../ipc/release-channel-handlers.test.ts | 20 +++++++++++++ src/backend/ipc/release-channel-handlers.ts | 28 +++++++++++++------ .../ReleaseChannelSetting.test.tsx | 22 +++++++++++++++ .../ReleaseChannelSetting.tsx | 7 ++++- 4 files changed, 68 insertions(+), 9 deletions(-) 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)