Skip to content

XS✔ ◾ fix: don't require or send a GitHub PAT for the public stable release channel#983

Open
tomek-i wants to merge 1 commit into
mainfrom
600-remove-github-pat-requirement-switching-release-builds
Open

XS✔ ◾ fix: don't require or send a GitHub PAT for the public stable release channel#983
tomek-i wants to merge 1 commit into
mainfrom
600-remove-github-pat-requirement-switching-release-builds

Conversation

@tomek-i

@tomek-i tomek-i commented Jul 21, 2026

Copy link
Copy Markdown
Member

Summary

The "Latest Stable" release channel is served from this repo's public GitHub releases and never needs authentication, but configureAutoUpdater() unconditionally attached any saved GitHub token as a Bearer header before branching on channel type — so a saved-but-invalid/expired token was still sent on every stable-channel request (risking spurious 401s on a request that should be anonymous). The "Check for Updates" button was also disabled on the stable channel whenever no token was saved at all, even though the stable channel doesn't need one.

Closes #600

What changed

File Change
src/backend/ipc/release-channel-handlers.ts configureAutoUpdater(): stop unconditionally attaching a saved token before branching on channel type. For the latest (stable, public) channel, explicitly clear any leftover Authorization header instead. For the pr channel, attach the token only after its health has already been confirmed further down in that branch.
src/backend/ipc/release-channel-handlers.test.ts Added a regression test proving a saved-but-invalid token is no longer attached as an auth header when configuring the stable channel.
src/ui/src/components/settings/release-channels/ReleaseChannelSetting.tsx "Check for Updates" button no longer requires hasGitHubToken to be enabled for the stable channel — only PR channels stay gated on isTokenHealthy (per #919).
src/ui/src/components/settings/release-channels/ReleaseChannelSetting.test.tsx Added tests asserting the button stays enabled for the stable channel with no token saved, and with an invalid/expired token saved.

Decisions

  • Decision: Move token attachment for PR channels to occur only after isGitHubTokenHealthy() passes, rather than attaching it unconditionally up front.
    • Why: Keeps a single source of truth for "is this token good enough to use" and guarantees an unhealthy token is never attached anywhere, closing the same class of gap 🐛 Bug - PR releases can be selected/downloaded with invalid GitHub token #919 fixed for listing/selecting/downloading PR releases — this PR closes it for the header-attachment path specifically.
    • Alternatives considered: Leaving the token attached but gating the request instead — rejected, since GitHub's API can react to a bad Authorization header differently than a missing one (e.g. 401 vs. anonymous rate-limit), so removing it entirely is the safer default for a public repo.

Acceptance criteria

  • Reproduce the invalid-token save behavior and confirm the header attachment for the public stable channel — reproduced via a failing regression test (see Testing).
  • Update the update-check/download logic to use unauthenticated requests for the stable/public channel.
  • Improve UI/UX: "Check for Updates" enablement no longer depends on a saved token for the stable channel.
  • Add automated tests covering both the backend header behavior and the frontend button-enabled behavior.
  • Token validation on save (verify format / reject clearly invalid tokens) — out of scope for this PR; the existing token-health check (🐛 Bug - PR releases can be selected/downloaded with invalid GitHub token #919) already surfaces invalid tokens after save via the "GitHub Token Invalid" banner, so this is a UX enhancement better tracked separately rather than bundled into this fix.
  • Document token requirement in settings/help copy — the existing "GitHub Token Invalid" / "GitHub Token Required" banners already scope the requirement to PR releases; no further doc changes were required by this fix.

Testing

  • Unit tests added/updated
  • Manual testing performed (via targeted regression tests below, in lieu of a full app run)
  • Build passes
  • Tests pass
  • Lint / format clean

Ran the project's full configured suite from a clean worktree:

  • npm run build — passes
  • npm rebuild better-sqlite3 --build-from-source && npx vitest run --exclude 'src/ui/**' — 692/692 backend tests pass
  • npm --prefix src/ui install && npm --prefix src/ui test — 261/261 UI tests pass
  • npm run lint — clean (one pre-existing info-level suggestion in an unrelated file, Cloud360LiveView.tsx, not touched by this change)

Bug repro evidence

  • Symptom (pinned): A saved-but-invalid/expired GitHub token is sent as an Authorization: Bearer header on requests to the public "Latest Stable" release channel, and the "Check for Updates" button is disabled for that channel whenever no token is saved — even though the stable channel is a public repo and never needs one.
  • Repro method: Added a regression test (release-channel-handlers.test.ts) that configures the stable channel with a saved token and asserts no Authorization header is attached to the autoUpdater, run against the unpatched code first.
  • Before (unpatched): The added regression test failed — expected 'Bearer saved-but-possibly-invalid-tok…' to be undefined, confirming the token was attached unconditionally.
  • After (patched): The same regression test now passes — Authorization is undefined for the stable channel regardless of any saved token. The full suite (13/13 in this test file) is green.

Follow-up items

  • Token format/liveness validation on save (proactively warning/rejecting an obviously invalid token at the point of entry) is still open per the issue's task list — worth a focused follow-up issue since it's a separate UX concern from the header-attachment bug this PR fixes.

… channel

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
Copilot AI review requested due to automatic review settings July 21, 2026 04:13
@tomek-i tomek-i added the armada Eligible for the ARMADA fleet to pick up label Jul 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

PR Metrics

Thanks for keeping your pull request small.
Thanks for adding tests.

Lines
Product Code 26
Test Code 42
Subtotal 68
Ignored Code -
Total 68

Metrics computed by PR Metrics. Add it to your Azure DevOps and GitHub PRs!

@github-actions github-actions Bot changed the title fix: don't require or send a GitHub PAT for the public stable release channel XS✔ ◾ fix: don't require or send a GitHub PAT for the public stable release channel Jul 21, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes update-check behavior so the public Latest Stable release channel never requires or sends a GitHub PAT, while keeping PR channels gated behind token health.

Changes:

  • Backend: configureAutoUpdater() no longer unconditionally attaches a saved GitHub token; stable channel explicitly clears any Authorization header, PR channel attaches the token only after a healthy-token gate.
  • UI: “Check for Updates” is no longer disabled for the stable channel when no token is saved or the saved token is invalid/expired.
  • Tests: added backend + UI regression coverage for the stable-channel no-token/invalid-token scenarios.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
src/backend/ipc/release-channel-handlers.ts Clears auth headers for stable channel; attaches token only after PR token health check passes.
src/backend/ipc/release-channel-handlers.test.ts Adds regression test ensuring stable channel doesn’t end up with an auth header.
src/ui/src/components/settings/release-channels/ReleaseChannelSetting.tsx Updates button disabled logic so stable updates aren’t token-gated.
src/ui/src/components/settings/release-channels/ReleaseChannelSetting.test.tsx Adds UI tests ensuring stable “Check for Updates” stays enabled without/with invalid token.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

const { autoUpdater } = (await import("electron-updater")) as unknown as {
autoUpdater: { requestHeaders?: Record<string, string> };
};
autoUpdater.requestHeaders = {};
@github-actions

Copy link
Copy Markdown
Contributor

🚀 Pre-release build is available for this PR:
https://github.com/SSWConsulting/SSW.YakShaver.Desktop/releases/tag/0.6.0-beta.983.1784607227

@tomek-i tomek-i added armada:reviewing Claimed by crows-nest; review->merge pipeline running and removed armada Eligible for the ARMADA fleet to pick up labels Jul 21, 2026
@tomek-i

tomek-i commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

🔭 crows-nest: picked up by ARMADA — dispatching to muster for review.

// 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 ?? {};

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Shared autoUpdater.requestHeaders singleton creates cross-channel header staleness/races

Two related gaps in how the fix manages the shared, module-level autoUpdater.requestHeaders object (flagged independently by both review lenses):

  1. Concurrent clear can wipe a PR-channel token mid-flight. configureAutoUpdater({type:"latest"}) now unconditionally clears Authorization from the shared singleton. checkForUpdates()'s manual button and the 10-minute periodic timer both call into this asynchronously and independently. If a periodic-timer PR-channel check is in flight (relying on the Authorization header a previous configureAutoUpdater(pr) call attached) at the same moment a user manually checks updates on the "latest" channel, the "latest" call's header-clear can strip the token out from under the concurrent PR request. This interaction didn't exist before this diff, since the header used to be reattached unconditionally at the top of every call regardless of channel.
  2. Token rotation while already on a PR channel doesn't refresh the header. checkForUpdates()'s PR-channel branch (~line 458, same file) re-verifies token health via isGitHubTokenHealthy() but never itself re-attaches requestHeaders.Authorization — it purely inherits whatever value was set by the last configureAutoUpdater() call. If a user revokes and replaces their token via Settings without switching channels afterward, the health check reports "healthy" for the new token, but the actual outbound request still carries the old, stale token value. This undermines the PR's own stated goal ("only attach the token once confirmed healthy") on the very code path it was trying to make safer.

Suggested fix: give request-header attachment a single, per-request source of truth (e.g. compute/attach the current header value at the point of each autoUpdater.checkForUpdates() call, or centralize all header mutation behind one method both configureAutoUpdater and checkForUpdates call), rather than mutating a long-lived shared object from multiple independent call sites.

flagged by: code-review + codex-rescue (independently, from different angles)

const { autoUpdater } = (await import("electron-updater")) as unknown as {
autoUpdater: { requestHeaders?: Record<string, string> };
};
autoUpdater.requestHeaders = {};

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] New regression test doesn't simulate the actual leftover-header regression scenario

The new test resets autoUpdater.requestHeaders = {} immediately before calling configureAutoUpdater({type:"latest"}), so the destructure-and-clear logic this PR adds (const { Authorization: _unused, ...headersWithoutAuth } = ...) is a no-op in this test — verified empirically by removing the two clearing lines from the source and re-running just this test, which still passed.

The test as written only proves a token present in the token store isn't newly read/attached for the latest branch (the weaker claim — already guaranteed just by deleting the old unconditional getToken() call at the top of the method). It doesn't prove the actual regression scenario described in the PR's own commit message: a leftover Authorization header from a prior PR-channel configureAutoUpdater() call actually gets stripped when switching to latest.

Suggested strengthening: seed autoUpdater.requestHeaders = { Authorization: "Bearer stale-token" } before calling configureAutoUpdater({type:"latest"}), then assert it's cleared afterward.

flagged by: codex-rescue

// 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 ?? {};

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Unused-var alias in destructuring-omit is inconsistent with the codebase's style

const { Authorization: _unused, ...headersWithoutAuth } = autoUpdater.requestHeaders ?? {} uses an explicit _unused alias for the omitted key. Other rest-destructure-to-omit patterns elsewhere in this codebase (e.g. const { hotkeys, ...restOfPatch } = ... in user-settings-handlers.ts, const { execute, ...rest } = ... in language-model-provider.ts) don't use an _-prefixed alias, since Biome's ruleset here doesn't flag rest-destructured bindings as unused. Not a lint failure, just a minor style inconsistency — could drop the alias to const { Authorization, ...headersWithoutAuth } = ... (with an eslint-disable comment if a rule does complain) to match convention.

flagged by: code-review

@tomek-i

tomek-i commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

Muster review — PR #983

Two independent lenses reviewed this diff in parallel (code-review: conventions/correctness; codex-rescue: root-cause/second-opinion). Findings below are consolidated and deduped.

Bottom line: 0 blocking, 1 major, 2 minor, 1 nit — not blocking, but the major finding is worth a look before merge.

Summary by severity

Severity Count
blocking 0
major 1
minor 2
nit 1

Findings

  1. [major] Shared autoUpdater.requestHeaders singleton creates cross-channel header staleness/races (src/backend/ipc/release-channel-handlers.ts:582) — flagged independently by both lenses from different angles:

    • The new "latest"-channel clear can strip a PR-channel's Authorization header out from under a concurrent in-flight request (periodic timer vs. manual check racing on the shared autoUpdater singleton).
    • checkForUpdates()'s PR-channel branch never itself refreshes requestHeaders — it inherits whatever a prior configureAutoUpdater() call left there, so rotating the token while already on a PR channel doesn't take effect until the channel is reconfigured.
    • Both lenses agreeing independently on the shared-singleton problem (via different concrete scenarios) is a strong signal it's worth addressing, even though neither is the exact bug this PR set out to fix.
  2. [minor] New backend regression test resets requestHeaders = {} right before calling configureAutoUpdater, making the new clear-logic a no-op in the test — verified empirically that the test still passes with the clearing lines removed (src/backend/ipc/release-channel-handlers.test.ts:388). Doesn't prove the actual "leftover header from a prior PR-channel call" regression scenario described in the PR body.

  3. [minor→nit] Also in that same test, getChannelMock.mockResolvedValue({ type: "latest" }) is set but never consulted since configureAutoUpdater takes an explicit channel argument and doesn't call this.getChannel() internally — harmless dead setup, not a false-pass risk.

  4. [nit] Authorization: _unused alias in the destructuring-omit (release-channel-handlers.ts:582) is stylistically inconsistent with other rest-destructure-to-omit patterns in this codebase, which don't use an _-prefixed alias.

Lens agreement

The two lenses converged independently on the same underlying architectural concern (shared mutable requestHeaders singleton with no per-request lifecycle) via two different concrete scenarios — that convergence is the strongest signal in this review. They diverged on test-quality specifics but both flagged the same test as under-proving the regression.

Otherwise clean

Both lenses confirmed: the ReleaseChannelType union only has "latest" | "pr" so the simplified UI condition (channel.type === "pr" ? !isTokenHealthy : false) correctly covers all reachable states; hasGitHubToken UI state isn't now dead; the two new frontend tests genuinely exercise the regression; PR-reference comment style matches repo conventions; and no reverse-direction bug (PR channel silently losing auth due to channel-switch ordering) was found.


Posted by armada:muster — 2 lenses (code-review + codex-rescue), not degraded.

@tomek-i tomek-i added armada Eligible for the ARMADA fleet to pick up armada:reviewing Claimed by crows-nest; review->merge pipeline running and removed armada:reviewing Claimed by crows-nest; review->merge pipeline running armada Eligible for the ARMADA fleet to pick up labels Jul 21, 2026
@tomek-i

tomek-i commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

🔭 crows-nest: picked up by ARMADA — dispatching to muster for review.

// 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;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Stale Authorization header can survive when channel.type === "pr" but channel.channel is falsy

configureAutoUpdater() only clears/sets the Authorization header inside the channel.type === "latest" branch (here) and inside the channel.type === "pr" && channel.channel branch further down (~line 627). ReleaseChannel.channel is typed as optional (channel?: string), so a value of { type: "pr" } with a missing/empty channel satisfies neither branch — autoUpdater.requestHeaders is left completely untouched, so any Authorization header attached by a previous, successful PR-channel configuration would persist.

This is exactly the class of bug #600 is about (a stale/invalid token being sent when it shouldn't be). Today the UI always supplies a non-empty channel string when setting a pr-type channel (setChannel({ type: "pr", channel: \beta.${value}` })), so it isn't reachable through the current UI flow — but configureAutoUpdater`'s own contract doesn't defend against it (persisted storage from a migration, manual edit, or a future caller could hit it). Consider hoisting the header-clearing so it runs on any branch that doesn't end up explicitly attaching a fresh token, rather than only inside the two known-good branches.

flagged by: code-review

// 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;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] No synchronization around configureAutoUpdater lets concurrent channel-switch/periodic-check calls interleave and leak headers either direction

autoUpdater (electron-updater) and its requestHeaders are process-wide singleton state, but configureAutoUpdater() is invoked from multiple independent, uncoordinated call sites: app startup (src/backend/index.ts:506, fire-and-forget, not awaited), setChannel() (line ~197), and checkForUpdates()'s latest arm (line ~546) — plus the 10-minute periodic timer that re-invokes checkForUpdates() on its own schedule.

The pr branch does two awaits (isGitHubTokenHealthy() → network call, then tokenStore.getToken()) before attaching Authorization; the latest branch strips it synchronously. If a PR-channel configuration is in-flight (e.g. triggered by the periodic timer) while the user switches to latest (or vice versa), the two calls' effects can interleave on the shared autoUpdater object — e.g. latest strips the header, then the still-in-flight pr call's header-attach completes afterward and re-attaches a token onto an autoUpdater now configured with the public 'github' feed for latest. This isn't addressed by the PR and isn't covered by any test. A fix would need per-invocation isolation (e.g. a single in-flight promise/queue guarding configureAutoUpdater, or a version/generation check before mutating shared state).

flagged by: codex-rescue

const handlers = new ReleaseChannelIPCHandlers();
await handlers.configureAutoUpdater({ type: "latest" });

expect(autoUpdater.requestHeaders?.Authorization).toBeUndefined();

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] New #600 regression test doesn't actually seed a stale Authorization header, so it can't prove the claimed fix

Both review lenses independently flagged this. The test sets autoUpdater.requestHeaders = {} (empty) immediately before calling configureAutoUpdater({ type: "latest" }), then asserts Authorization is undefined afterward. Since there was never an Authorization key present to begin with, this test would pass identically even if the destructure-and-strip logic in configureAutoUpdater were deleted outright — it never exercises the 'leftover header from a previous PR-channel configuration' scenario that the PR description and the inline code comment both call out as the actual bug being fixed. It also doesn't add coverage beyond the pre-existing 'no token attached at all for stable' test.

Suggested fix: seed autoUpdater.requestHeaders = { Authorization: 'Bearer stale-pr-token' } before calling configureAutoUpdater({ type: "latest" }), then assert the header is gone afterward — that actually exercises (and would fail without) the strip logic.

flagged by: code-review + codex-rescue

// 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;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Header-clearing only handles the exact key "Authorization"; no defense against a differently-cased or additional leftover header

The destructure const { Authorization: _unused, ...headersWithoutAuth } = autoUpdater.requestHeaders ?? {} is correct today only because this codebase is the sole writer of autoUpdater.requestHeaders and always uses the literal key "Authorization" (confirmed by grep — no other casing is ever written). But this is an implicit assumption with no guard: if electron-updater or a future contributor ever sets a differently-cased key (e.g. lowercase "authorization") or another auth-adjacent header, it would silently survive this strip and still be sent to the public unauthenticated "latest" endpoint — exactly the class of bug this PR is trying to eliminate. Consider clearing requestHeaders wholesale for the "latest" branch (autoUpdater.requestHeaders = {}) rather than selectively deleting one known key, since "latest" never needs any custom headers at all.

flagged by: codex-rescue

// 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;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Destructure-to-omit pattern is a one-off compared to existing idioms in this file

const { Authorization: _unused, ...headersWithoutAuth } = autoUpdater.requestHeaders ?? {}; autoUpdater.requestHeaders = headersWithoutAuth; works correctly, but the rest of this file always builds requestHeaders additively via spread ({ ...autoUpdater.requestHeaders, Authorization: ... }) rather than via destructure-omit. Not a correctness issue, just a minor stylistic inconsistency — a small shared helper (e.g. clearAuthHeader()/buildRequestHeaders(token?)) would read more consistently with the surrounding code and reduce duplication between the two places headers are now built.

flagged by: code-review

@tomek-i

tomek-i commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

muster review — PR #983

Two independent lenses reviewed this diff in parallel: a conventions/correctness code-review pass and a codex-rescue root-cause second opinion. Findings below are consolidated and deduped; both lenses had full context on the diff and the surrounding configureAutoUpdater() / checkForUpdates() logic.

Verdict: 0 blocking, 3 major, 2 minor, 1 nit — no blocking issues, but the regression test needs strengthening before this is airtight.

Summary by severity

Severity Count
blocking 0
major 3
minor 2
nit 1

Major findings (posted inline)

  1. Stale Authorization header can survive when channel.type === "pr" but channel.channel is falsy (src/backend/ipc/release-channel-handlers.ts) — channel is optional on the ReleaseChannel type, so { type: "pr" } with no channel satisfies neither the latest nor the pr && channel.channel branch, leaving any previously-attached Authorization header untouched. Not reachable via the current UI (which always supplies a non-empty channel for pr-type), but the function itself has no defense against it. — code-review
  2. New ♻️ Remove GitHub PAT requirement when switching GitHub Release Builds #600 regression test doesn't actually seed a stale header before asserting it's gone (src/backend/ipc/release-channel-handlers.test.ts) — the test sets requestHeaders = {} right before the call, so it never exercises the "clear a leftover header from a prior PR-channel configuration" path the PR description calls out as the actual bug. It would pass even if the strip logic were deleted. Both lenses independently flagged this one — strong signal it should be fixed before merge (not a blocker, since the "don't attach a fresh token" half is genuinely covered, but the fix's marquee claim isn't proven by the test suite as written).
  3. No synchronization around configureAutoUpdater across its multiple call sites (startup, setChannel, checkForUpdates, periodic timer) — since autoUpdater.requestHeaders is shared singleton state, an in-flight pr-channel configuration (two awaits before attaching the token) racing a latest-channel switch could interleave and leak a token onto the public feed, or vice versa. Pre-existing architectural gap, not introduced by this PR, but directly relevant to the bug class being fixed here and untested. — codex-rescue

Minor / nit

  • Header-clearing only strips the exact key "Authorization"; no defense against a differently-cased or additional leftover header (release-channel-handlers.ts). — codex-rescue
  • Destructure-to-omit pattern is a stylistic one-off vs. the rest of the file's additive-spread idiom for building requestHeaders. — code-review
  • (Not posted inline — file outside this PR's diff) src/backend/index.ts:506 calls configureAutoUpdater(channel, true) without await, immediately followed by autoUpdater.checkForUpdatesAndNotify(). Currently safe only because the latest branch's mutations are synchronous before any await; a future edit adding an early async check to that branch would silently break this ordering. Pre-existing, not introduced by this PR, flagged for awareness — codex-rescue.

Lens agreement

The two lenses converged independently on the same weak spot (finding #2 — the test doesn't prove the "clear leftover header" claim), which is the strongest signal in this review. They diverged in focus otherwise: code-review concentrated on branch-coverage/type-safety gaps in the diff itself, codex-rescue went wider into concurrency/singleton-state risk across the whole configureAutoUpdater call graph. No direct disagreements between the lenses.

Bottom line

No blocking issues — the core fix (don't attach a token for the stable channel; only attach for pr after health-check) is correct and closes the behavior described in #600. The 3 major findings are worth addressing before merge, particularly #2 (strengthen the regression test so it actually proves the leftover-header-clearing behavior it claims to cover) since a weak test here undermines the PR's central evidence. #1 and #3 are real but lower-urgency edge cases not reachable through today's UI flow.

Findings posted as inline review comments above. This review does not approve/request-changes or merge — that's for the fleet's gating step.

@tomek-i tomek-i added armada Eligible for the ARMADA fleet to pick up and removed armada:reviewing Claimed by crows-nest; review->merge pipeline running labels Jul 21, 2026
@tomek-i

tomek-i commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

🔭 crows-nest: review complete — 0 blocking findings (3 major, 2 minor, 1 nit; see inline comments). autoMerge is off, so this PR is ready and awaiting a human merge decision.

@tomek-i tomek-i added the armada:reviewing Claimed by crows-nest; review->merge pipeline running label Jul 21, 2026
@tomek-i

tomek-i commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

🔭 crows-nest: ready-PR pipeline started — review → address → re-validate → gated merge.

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 () => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] New regression test doesn't verify clearing of a pre-existing stale Authorization header

The new test sets autoUpdater.requestHeaders = {} (empty) before calling configureAutoUpdater({ type: "latest" }), then asserts no Authorization header appears. This correctly pins the original bug (unconditional token attach before branching), but it would still pass even if the header-stripping destructure (const { Authorization: _unused, ...headersWithoutAuth } = autoUpdater.requestHeaders ?? {}) were deleted entirely — starting from an empty object there's nothing to strip. It doesn't exercise the scenario the added code comment explicitly claims to guard: "Explicitly clear any Authorization header a previous PR-channel configuration may have left behind."

Suggested fix: add/extend a test that first sets autoUpdater.requestHeaders = { Authorization: 'Bearer stale-pr-token', 'X-Some-Other-Header': 'keep-me' } (simulating state left over from a prior PR-channel configuration) before calling configureAutoUpdater({ type: 'latest' }), then assert Authorization is gone and the unrelated header survives.

flagged by: code-review + codex-rescue (independent agreement)

// 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();

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Redundant token fetch in PR branch of configureAutoUpdater

isGitHubTokenHealthy() (called just above, gating this branch) already reads the token from this.tokenStore.getToken() internally to perform its health check, and then this line reads it again from the same store just to attach the Authorization header. Not a correctness bug — the store is a simple local read with no intervening async work that could change the value — but it's an avoidable double read. Consider having isGitHubTokenHealthy() return the token it already fetched (or threading it through) to avoid the duplicate call.

flagged by: codex-rescue

@tomek-i

tomek-i commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

Muster review — PR #983

Two independent lenses reviewed this diff in parallel: code-review (conventions/correctness) and codex-rescue (root-cause second opinion). Both lenses independently traced the full configureAutoUpdater() method (not just the diff hunks) and agree the fix is sound.

Summary

Severity Count
blocking 0
major 0
minor 2
nit 0 (merged into the minor above)

No blocking findings. Both lenses converged on the same conclusion: the core fix correctly stops attaching a saved token before branching on channel type, correctly strips any leftover Authorization header for the public latest channel, and correctly defers token attachment on the pr channel until after isGitHubTokenHealthy() confirms it — closing the gap #600 describes. The frontend button fix matches: hasGitHubToken is still legitimately used elsewhere (for the "no token" banner), not dead code. codex-rescue additionally traced every autoUpdater.requestHeaders/channel/setFeedURL write site in the file to rule out a channel-switch state-leak and found the latest branch's header-clearing is reachable from every path that matters (setChannel() and checkForUpdates()'s stable fallback).

Findings (both minor, non-blocking)

  1. [minor] Regression test doesn't prove the header-clearing logic works (src/backend/ipc/release-channel-handlers.test.ts:376) — flagged independently by both lenses (strongest signal). The new test starts from autoUpdater.requestHeaders = {} and asserts Authorization stays undefined, which pins the original "unconditional attach" bug but would still pass even if the new destructure-based stripping line were deleted — it never simulates a stale header left over from a prior PR-channel configuration and confirms it's actually removed. Suggested fix: seed requestHeaders with a stale Authorization (plus an unrelated header) before switching to latest, and assert Authorization is gone while the other header survives.
  2. [minor] Redundant token fetch in the pr branch (src/backend/ipc/release-channel-handlers.ts:625) — flagged by codex-rescue. isGitHubTokenHealthy() already reads the token internally to perform its health check; the code then reads it again from the token store just to attach the header. Harmless (simple local read, no intervening async), but avoidable — consider threading the already-fetched token through instead of a second store read.

Also noted (not filed as a PR finding — pre-existing/out of scope)

codex-rescue additionally observed that checkForUpdates()'s PR-channel download path (unmodified by this PR) sets autoUpdater.channel/setFeedURL directly without going through configureAutoUpdater(), so it never refreshes requestHeaders — meaning a rotated token could still be stale on that specific download request. This is pre-existing behavior outside this PR's diff and stated scope (public stable-channel token stripping), not something this PR introduced or worsened — worth a separate follow-up issue if the team wants to close that gap, but it does not block this PR.

Bottom line

0 blocking, 0 major, 2 minor — no blocking findings, ready pending optional test-coverage strengthening. The two lenses agreed throughout; no unresolved disagreement between them.


Posted by armada:muster — inline comments above are correlated to specific lines; this comment is the consolidated summary. Acting on findings is shipwright's job; merge-gating is crows-nest's.

@tomek-i tomek-i removed the armada:reviewing Claimed by crows-nest; review->merge pipeline running label Jul 21, 2026
@tomek-i

tomek-i commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

✅ crows-nest: reviewed — 2 findings (0 blocking, 0 major, 2 minor). Advisory only — awaiting human merge (auto-merge off).

@tomek-i tomek-i added the armada:reviewing Claimed by crows-nest; review->merge pipeline running label Jul 21, 2026
@tomek-i

tomek-i commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

🔭 crows-nest: ready-PR pipeline started — review → address → re-validate → gated merge.

const { autoUpdater } = (await import("electron-updater")) as unknown as {
autoUpdater: { requestHeaders?: Record<string, string> };
};
autoUpdater.requestHeaders = {};

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] New #600 regression test doesn't seed a leftover Authorization header, so it doesn't prove the strip

The test sets autoUpdater.requestHeaders = {} (already empty) immediately before calling configureAutoUpdater({ type: "latest" }), then asserts Authorization is undefined. Since there was never an Authorization key present to begin with, this only proves the latest branch doesn't add a header — it doesn't exercise the actual regression the PR's own comment describes ("clear any Authorization header a previous PR-channel configuration may have left behind"). getTokenMock.mockResolvedValue(...) is also a red herring here since the latest" branch never calls tokenStore.getToken()`.

Suggested fix: seed autoUpdater.requestHeaders = { Authorization: "Bearer stale-token" } (simulating a prior PR-channel config) before calling configureAutoUpdater for latest, then assert it's stripped — ideally also with a sibling header present to prove only Authorization is removed.

flagged independently by: code-review + codex-rescue

@tomek-i

tomek-i commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

[minor] checkForUpdates()'s PR-channel download path never sets Authorization itself — relies on state left by a prior configureAutoUpdater() call (src/backend/ipc/release-channel-handlers.ts:461, outside this PR's diff context so posted here instead of inline)

This branch calls autoUpdater.setFeedURL({ provider: "generic", ... }) and autoUpdater.checkForUpdates() without touching autoUpdater.requestHeaders. It doesn't itself guarantee the Bearer header reflects the current token — that's only written inside configureAutoUpdater's pr-branch (line 627). The normal UI flow happens to work because handleCheckUpdates() calls releaseChannel.set(channel) (running configureAutoUpdater, which freshly attaches the header) immediately before checkUpdates(). But the periodic timer (or any caller that invokes checkForUpdates() without a preceding configureAutoUpdater call after a token rotation) could download with a stale/absent Authorization header. This coupling predates this PR, but the PR moves the attach point deeper into a now-gated branch without adding coverage for this cross-method dependency — worth a follow-up test or a comment noting the invariant.

flagged by: codex-rescue

@tomek-i

tomek-i commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

[nit] Unused destructured binding pattern is new to this file (src/backend/ipc/release-channel-handlers.ts:582)

const { Authorization: _unused, ...headersWithoutAuth } = autoUpdater.requestHeaders ?? {}; intentionally discards Authorization via an unused binding — a correct and standard way to omit a key (verified for undefined, empty object, and objects with sibling headers). Just flagging it's the first use of this pattern in the file; confirm lint (no-unused-vars / ignoreRestSiblings) stays clean — CI is green so this is informational only.

flagged by: code-review

@tomek-i

tomek-i commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

[nit] requestHeaders is shared, unlocked, global mutable state across concurrent configureAutoUpdater/checkForUpdates invocations (src/backend/ipc/release-channel-handlers.ts)

autoUpdater.requestHeaders (and .channel, feed URL, etc.) are mutated in place on the module-level electron-updater singleton with no mutex. The periodic timer, app-startup configureAutoUpdater(..., true), and a manual channel switch can all race in principle. This PR adds one more field (Authorization) to that same pattern. Pre-existing architecture, not a regression introduced by this PR — noting for awareness, not blocking.

flagged by: codex-rescue

@tomek-i

tomek-i commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

🔭 muster review — PR #983

Bottom line: no blocking findings — review advisory only.

Two independent lenses reviewed this diff in parallel (code-review + codex-rescue), never seeing each other's notes.

Severity Count
blocking 0
major 0
minor 2
nit 2

Findings:

  1. [minor] release-channel-handlers.test.ts — the new ♻️ Remove GitHub PAT requirement when switching GitHub Release Builds #600 regression test doesn't seed a leftover Authorization header before asserting it's stripped, so it doesn't actually exercise the regression it's meant to guard against. Flagged independently by both lenses — strong signal, worth fixing before merge.
  2. [minor] release-channel-handlers.tscheckForUpdates()'s PR-channel download path never sets Authorization itself; it silently depends on state a prior configureAutoUpdater() call left on the shared autoUpdater singleton. Pre-existing coupling, not introduced by this PR, but worth a follow-up.
  3. [nit] New destructure-to-omit-a-key pattern is the first use in this file (verified correct for undefined/empty/sibling-header cases).
  4. [nit] autoUpdater.requestHeaders/config is shared mutable state across concurrent timer/manual invocations — pre-existing, not a regression.

Lens agreement: both lenses independently converged on the same core gap (finding #1) — that's the strongest thing in this review. No disagreement between the lenses otherwise.

Fix itself is sound: the header-stripping logic was verified against undefined/empty/populated requestHeaders, and no other in-repo code path re-attaches a stale token to the stable channel. Only the test coverage for the stated regression scenario is weaker than the PR's own description implies.

This review is advisory only — muster does not approve/request-changes/merge. autoMerge is currently off for this repo, so this PR proceeds to "awaiting human merge" once re-validated.

@tomek-i tomek-i removed the armada:reviewing Claimed by crows-nest; review->merge pipeline running label Jul 21, 2026
@tomek-i

tomek-i commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

✅ crows-nest: reviewed, no blocking findings, CI green — awaiting human merge (auto-merge off).

@yaqi-lyu yaqi-lyu self-assigned this Jul 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

armada Eligible for the ARMADA fleet to pick up

Projects

None yet

Development

Successfully merging this pull request may close these issues.

♻️ Remove GitHub PAT requirement when switching GitHub Release Builds

3 participants