From 62d5f655f7b6eee6b8b97cd5ca38967d69edc678 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 8 Nov 2026 04:52:17 +0000 Subject: [PATCH 1/4] test(desktop): reproduce stable updates showing a nightly's release notes (#32827) These tests reproduce #32827. On the stable channel the updater runs without the full changelog, so when the offered stable release is missing from the GitHub releases feed, electron-updater falls back to the feed's first entry (a nightly) and returns its note as a plain string, which the update state then shows under the stable version. The notes come from electron-updater's own computeReleaseNotes over a small Atom feed, using the fullChangelog value the service configured. Co-Authored-By: Claude Opus 5.5 --- .../src/updates/DesktopUpdates.test.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 3b45d80bbc6e2..645986ab83f7c 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -1,5 +1,8 @@ +import * as NodeModule from "node:module"; + import { assert, describe, it } from "@effect/vitest"; import { DESKTOP_UPDATE_RESTART_MARKER_FILE } from "@t3tools/contracts"; +import { computeReleaseNotes } from "electron-updater/out/providers/GitHubProvider.js"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -22,6 +25,43 @@ import * as DesktopUpdatePolicy from "./DesktopUpdatePolicy.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts"; +type ReleaseFeed = Parameters[2]; +type RunningVersion = Parameters[0]; + +// electron-updater's own dependencies, which apps/desktop does not declare. +const requireFromUpdater = NodeModule.createRequire( + NodeModule.createRequire(import.meta.url).resolve("electron-updater"), +); +const { parseXml } = requireFromUpdater("builder-util-runtime") as { + parseXml: (xml: string) => ReleaseFeed; +}; +const semver = requireFromUpdater("semver") as { parse: (version: string) => RunningVersion }; + +const releaseFeed = (...versions: ReadonlyArray) => + parseXml( + `${versions + .map( + (version) => + `v${version}<ul><li>${version} change</li></ul>`, + ) + .join("")}`, + ); + +/** The notes electron-updater's GitHub provider reports when /releases/latest names `offeredVersion`. */ +const stableReleaseNotes = ( + feed: ReleaseFeed, + offeredVersion: string, + runningVersion: string, + fullChangelog: boolean, +) => { + const entries = feed.getElements("entry"); + const latestRelease = + entries.find((entry) => + entry.element("link").attribute("href").endsWith(`/tag/v${offeredVersion}`), + ) ?? feed.element("entry"); + return computeReleaseNotes(semver.parse(runningVersion), fullChangelog, feed, latestRelease); +}; + describe("DesktopUpdates", () => { it.effect("updates Linux .deb installs and leaves other non-AppImage installs off", () => Effect.gen(function* () { @@ -109,6 +149,50 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("shows no notes for a Stable release missing from the releases feed", () => { + const harness = makeHarness(); + const feed = releaseFeed("1.2.5-nightly.20261102.1", "1.2.5-nightly.20261101.1"); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + harness.emit("update-available", { + version: "1.2.4", + releaseNotes: stableReleaseNotes(feed, "1.2.4", "1.2.3", harness.fullChangelog()), + }); + yield* flushCallbacks; + + const state = yield* updates.getState; + assert.equal(state.availableVersion, "1.2.4"); + assert.deepEqual(state.releaseNotes, [], "Stable release shows a Nightly's notes"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("shows a Stable release's own notes from the releases feed", () => { + const harness = makeHarness(); + const feed = releaseFeed("1.2.5-nightly.20261102.1", "1.2.4", "1.2.4-nightly.20261030.1"); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + harness.emit("update-available", { + version: "1.2.4", + releaseNotes: stableReleaseNotes(feed, "1.2.4", "1.2.3", harness.fullChangelog()), + }); + yield* flushCallbacks; + + assert.deepEqual((yield* updates.getState).releaseNotes, [ + { version: "1.2.4", items: ["1.2.4 change"], totalItems: 1 }, + ]); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("preserves a queued installer when the feed has no update", () => { const harness = makeHarness(); From f12e156b26adc74bbc358fe2061bb5090809c793 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 8 Nov 2026 04:55:38 +0000 Subject: [PATCH 2/4] fix(desktop): stable updates no longer show a nightly's release notes On the Stable channel the updater ran without the full changelog. When the offered stable release was missing from the first page of the GitHub releases feed, electron-updater fell back to the feed's newest entry, often a nightly, and returned its note untagged, so the update button showed it under the stable version. Request the full changelog on every channel. electron-updater then tags each note with its release's version, and the existing channel filter drops the nightlies, leaving the offered stable release's notes (plus any skipped stable releases in between) or none. Fixes pingdotgg/t3code#32827 AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/updates/DesktopUpdates.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 14688e5258067..3eef06373575d 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -520,12 +520,7 @@ export const make = Effect.gen(function* () { // An older build can lack migrations this one ran. Only a guarded track switch's own check // may offer one. yield* electronUpdater.setAllowDowngrade(false); - yield* electronUpdater.setFullChangelog(allowsPrerelease); - yield* logUpdaterInfo("using update channel", { - channel, - allowPrerelease: allowsPrerelease, - fullChangelog: allowsPrerelease, - }); + yield* logUpdaterInfo("using update channel", { channel, allowPrerelease: allowsPrerelease }); }); const checkForUpdates = Effect.fn("desktop.updates.checkForUpdates")(function* ( @@ -1159,6 +1154,9 @@ export const make = Effect.gen(function* () { yield* electronUpdater.setAutoDownload(false); yield* electronUpdater.setAutoInstallOnAppQuit(false); + // Without it, a release missing from the feed's first page gets the newest entry's notes, + // untagged. Tagged notes let normalizeDesktopUpdateReleaseNotes drop other channels. + yield* electronUpdater.setFullChangelog(true); yield* applyAutoUpdaterChannel(settings.updateChannel); yield* electronUpdater.setDisableDifferentialDownload( isArm64HostRunningIntelBuild(environment.runtimeInfo), From e45ed8b398ab71a70837e4b8013e2903159ff5e6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 8 Nov 2026 05:04:47 +0000 Subject: [PATCH 3/4] fix(desktop): keep preview builds out of stable update notes With the full changelog on Stable, preview prereleases inside the update range read as Stable and their "do not install" warning showed as a release note group. Preview builds are never offered as updates, so drop them on every channel. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/updates/releaseNotes.test.ts | 1 + apps/desktop/src/updates/releaseNotes.ts | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 6f05b1e9e94ab..eacc19fa820e6 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -188,6 +188,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { [ { version: "0.0.42", note: "- Stable change" }, { version: "0.0.42-nightly.20260915.1710", note: "- Nightly change" }, + { version: "0.0.42-preview.20260914.1683", note: "- Maintainer test build" }, ], "0.0.42", "latest", diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index a319898222092..9a7657d0d2b83 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -1,6 +1,6 @@ import type { DesktopUpdateChannel, DesktopUpdateReleaseNote } from "@t3tools/contracts"; -import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; +import { cliReleaseChannelOf, desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; interface ElectronReleaseNoteInfo { readonly version: string; @@ -184,7 +184,12 @@ export function normalizeDesktopUpdateReleaseNotes( : Array.isArray(releaseNotes) ? releaseNotes.filter(isElectronReleaseNoteInfo) : [] - ).filter((entry) => desktopUpdateChannelOf(entry.version) === channel); + ).filter( + // Preview builds read as Stable but are never offered, and their feed notes are a warning. + (entry) => + desktopUpdateChannelOf(entry.version) === channel && + cliReleaseChannelOf(entry.version) !== "preview", + ); const normalizedNotes = rawNotes.flatMap((entry) => { const note = extractReleaseNoteItems(entry.note); From 1fa62a9b4e1f2fca00a873a0b1d88b10a6357508 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 19:47:08 +0000 Subject: [PATCH 4/4] fix(desktop): stable notes survive a switch down from Nightly Always requesting the full changelog dropped a Stable release's notes when it is offered below the running Nightly, because electron-updater's full changelog only lists versions above the running one. Stable is back on the single release-notes string, kept only when the feed entry it came from (its releaseName) names the offered version. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- .../src/updates/DesktopUpdates.test.ts | 91 +++++++++++-------- apps/desktop/src/updates/DesktopUpdates.ts | 18 ++-- apps/desktop/src/updates/releaseNotes.test.ts | 1 - apps/desktop/src/updates/releaseNotes.ts | 25 +++-- 4 files changed, 83 insertions(+), 52 deletions(-) diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 645986ab83f7c..604fbeda3c9f1 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -37,29 +37,43 @@ const { parseXml } = requireFromUpdater("builder-util-runtime") as { }; const semver = requireFromUpdater("semver") as { parse: (version: string) => RunningVersion }; +const releaseTitle = (version: string) => + version.includes("-nightly.") + ? `T3 Code Nightly ${version} (0123456789ab)` + : `T3 Code v${version}`; + const releaseFeed = (...versions: ReadonlyArray) => parseXml( `${versions .map( (version) => - `v${version}<ul><li>${version} change</li></ul>`, + `${releaseTitle(version)}<ul><li>${version} change</li></ul>`, ) .join("")}`, ); -/** The notes electron-updater's GitHub provider reports when /releases/latest names `offeredVersion`. */ -const stableReleaseNotes = ( +/** The update info electron-updater's GitHub provider reports when /releases/latest names `version`. */ +const stableUpdateInfo = ( feed: ReleaseFeed, - offeredVersion: string, + version: string, runningVersion: string, fullChangelog: boolean, ) => { - const entries = feed.getElements("entry"); const latestRelease = - entries.find((entry) => - entry.element("link").attribute("href").endsWith(`/tag/v${offeredVersion}`), - ) ?? feed.element("entry"); - return computeReleaseNotes(semver.parse(runningVersion), fullChangelog, feed, latestRelease); + feed + .getElements("entry") + .find((entry) => entry.element("link").attribute("href").endsWith(`/tag/v${version}`)) ?? + feed.element("entry"); + return { + version, + releaseName: latestRelease.elementValueOrEmpty("title"), + releaseNotes: computeReleaseNotes( + semver.parse(runningVersion), + fullChangelog, + feed, + latestRelease, + ), + }; }; describe("DesktopUpdates", () => { @@ -149,46 +163,47 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); - it.effect("shows no notes for a Stable release missing from the releases feed", () => { + it.effect.each([ + { + name: "shows no notes for a Stable release missing from the releases feed", + runningVersion: "1.2.3", + feedVersions: ["1.2.5-nightly.20261102.1", "1.2.5-nightly.20261101.1"], + expected: [], + }, + { + name: "shows a Stable release's own notes from the releases feed", + runningVersion: "1.2.3", + feedVersions: ["1.2.5-nightly.20261102.1", "1.2.4", "1.2.4-nightly.20261030.1"], + expected: [{ version: "1.2.4", items: ["1.2.4 change"], totalItems: 1 }], + }, + { + name: "shows a Stable release's notes when switching down from a newer Nightly", + runningVersion: "1.2.5-nightly.20261027.1", + feedVersions: ["1.2.5-nightly.20261102.1", "1.2.4"], + expected: [{ version: "1.2.4", items: ["1.2.4 change"], totalItems: 1 }], + }, + ])("$name", ({ runningVersion, feedVersions, expected }) => { const harness = makeHarness(); - const feed = releaseFeed("1.2.5-nightly.20261102.1", "1.2.5-nightly.20261101.1"); return Effect.scoped( Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; yield* updates.configure; - harness.emit("update-available", { - version: "1.2.4", - releaseNotes: stableReleaseNotes(feed, "1.2.4", "1.2.3", harness.fullChangelog()), - }); + harness.emit( + "update-available", + stableUpdateInfo( + releaseFeed(...feedVersions), + "1.2.4", + runningVersion, + harness.fullChangelog(), + ), + ); yield* flushCallbacks; const state = yield* updates.getState; assert.equal(state.availableVersion, "1.2.4"); - assert.deepEqual(state.releaseNotes, [], "Stable release shows a Nightly's notes"); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - - it.effect("shows a Stable release's own notes from the releases feed", () => { - const harness = makeHarness(); - const feed = releaseFeed("1.2.5-nightly.20261102.1", "1.2.4", "1.2.4-nightly.20261030.1"); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - harness.emit("update-available", { - version: "1.2.4", - releaseNotes: stableReleaseNotes(feed, "1.2.4", "1.2.3", harness.fullChangelog()), - }); - yield* flushCallbacks; - - assert.deepEqual((yield* updates.getState).releaseNotes, [ - { version: "1.2.4", items: ["1.2.4 change"], totalItems: 1 }, - ]); + assert.deepEqual(state.releaseNotes, expected); }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 3eef06373575d..36492ca7b9063 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -36,7 +36,10 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopUpdatePolicy from "./DesktopUpdatePolicy.ts"; -import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; +import { + normalizeDesktopUpdateReleaseNotes, + releaseNotesOfOfferedVersion, +} from "./releaseNotes.ts"; import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { @@ -72,6 +75,7 @@ const UpdateInfo = Schema.Struct({ version: Schema.String, // Malformed notes must not block update-state decoding; normalizeDesktopUpdateReleaseNotes validates separately. releaseNotes: Schema.optional(Schema.Unknown), + releaseName: Schema.optional(Schema.NullOr(Schema.String)), releaseDate: Schema.optional(Schema.String), }); @@ -520,7 +524,12 @@ export const make = Effect.gen(function* () { // An older build can lack migrations this one ran. Only a guarded track switch's own check // may offer one. yield* electronUpdater.setAllowDowngrade(false); - yield* logUpdaterInfo("using update channel", { channel, allowPrerelease: allowsPrerelease }); + yield* electronUpdater.setFullChangelog(allowsPrerelease); + yield* logUpdaterInfo("using update channel", { + channel, + allowPrerelease: allowsPrerelease, + fullChangelog: allowsPrerelease, + }); }); const checkForUpdates = Effect.fn("desktop.updates.checkForUpdates")(function* ( @@ -934,7 +943,7 @@ export const make = Effect.gen(function* () { const checkedAt = yield* currentIsoTimestamp; const { releaseNotes, omittedReleaseCount } = normalizeDesktopUpdateReleaseNotes( - info.releaseNotes, + releaseNotesOfOfferedVersion(info), info.version, state.channel, info.releaseDate, @@ -1154,9 +1163,6 @@ export const make = Effect.gen(function* () { yield* electronUpdater.setAutoDownload(false); yield* electronUpdater.setAutoInstallOnAppQuit(false); - // Without it, a release missing from the feed's first page gets the newest entry's notes, - // untagged. Tagged notes let normalizeDesktopUpdateReleaseNotes drop other channels. - yield* electronUpdater.setFullChangelog(true); yield* applyAutoUpdaterChannel(settings.updateChannel); yield* electronUpdater.setDisableDifferentialDownload( isArm64HostRunningIntelBuild(environment.runtimeInfo), diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index eacc19fa820e6..6f05b1e9e94ab 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -188,7 +188,6 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { [ { version: "0.0.42", note: "- Stable change" }, { version: "0.0.42-nightly.20260915.1710", note: "- Nightly change" }, - { version: "0.0.42-preview.20260914.1683", note: "- Maintainer test build" }, ], "0.0.42", "latest", diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 9a7657d0d2b83..715c1767e6a04 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -1,6 +1,6 @@ import type { DesktopUpdateChannel, DesktopUpdateReleaseNote } from "@t3tools/contracts"; -import { cliReleaseChannelOf, desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; +import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; interface ElectronReleaseNoteInfo { readonly version: string; @@ -171,6 +171,22 @@ interface NormalizedDesktopUpdateReleaseNotes { readonly omittedReleaseCount: number; } +const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +/** + * Untagged notes come from the feed entry named by releaseName, which is the newest entry when the + * offered release is not on the feed's first page. Keep them only when that entry is the offered one. + */ +export function releaseNotesOfOfferedVersion(info: { + readonly version: string; + readonly releaseNotes?: unknown; + readonly releaseName?: string | null | undefined; +}): unknown { + if (typeof info.releaseNotes !== "string" || !info.releaseName) return info.releaseNotes; + const namesVersion = new RegExp(`(?:^|[^\\w.-])v?${escapeRegExp(info.version)}(?![\\w.-])`); + return namesVersion.test(info.releaseName) ? info.releaseNotes : undefined; +} + /** fullChangelog includes all greater semvers, so preview cuts sort above nightlies of the same base version. */ export function normalizeDesktopUpdateReleaseNotes( releaseNotes: unknown, @@ -184,12 +200,7 @@ export function normalizeDesktopUpdateReleaseNotes( : Array.isArray(releaseNotes) ? releaseNotes.filter(isElectronReleaseNoteInfo) : [] - ).filter( - // Preview builds read as Stable but are never offered, and their feed notes are a warning. - (entry) => - desktopUpdateChannelOf(entry.version) === channel && - cliReleaseChannelOf(entry.version) !== "preview", - ); + ).filter((entry) => desktopUpdateChannelOf(entry.version) === channel); const normalizedNotes = rawNotes.flatMap((entry) => { const note = extractReleaseNoteItems(entry.note);