authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-15 21:31:24-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-15 21:31:24-07:00
logf9580ff08895380147181e1a771e6bb328dac439
tree4f7305d88b3447aea4d98882d0ac3370d054fd15
parent5e1dd56d2c534d90ba3363f97b1e1e23412507c0
signature Signed by PGP key B5690EEEBB952194

Default nightly desktop builds to the nightly update channel (#2049)

Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>

10 files changed, 207 insertions(+), 30 deletions(-)

apps/desktop/src/appBranding.ts+3-2
......@@ -1,7 +1,8 @@
11import type { DesktopAppBranding, DesktopAppStageLabel } from "@t3tools/contracts";
22
3import { isNightlyDesktopVersion } from "./updateChannels";
4
35const APP_BASE_NAME = "T3 Code";
4const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/;
56
67export function resolveDesktopAppStageLabel(input: {
78 readonly isDevelopment: boolean;
......@@ -11,7 +12,7 @@ export function resolveDesktopAppStageLabel(input: {
1112 return "Dev";
1213 }
1314
14 return NIGHTLY_VERSION_PATTERN.test(input.appVersion) ? "Nightly" : "Alpha";
15 return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha";
1516}
1617
1718export function resolveDesktopAppBranding(input: {
apps/desktop/src/desktopSettings.test.ts+66-3
......@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from "vitest";
77import {
88 DEFAULT_DESKTOP_SETTINGS,
99 readDesktopSettings,
10 resolveDefaultDesktopSettings,
1011 setDesktopServerExposurePreference,
1112 setDesktopUpdateChannelPreference,
1213 writeDesktopSettings,
......@@ -28,7 +29,15 @@ function makeSettingsPath() {
2829
2930describe("desktopSettings", () => {
3031 it("returns defaults when no settings file exists", () => {
31 expect(readDesktopSettings(makeSettingsPath())).toEqual(DEFAULT_DESKTOP_SETTINGS);
32 expect(readDesktopSettings(makeSettingsPath(), "0.0.17")).toEqual(DEFAULT_DESKTOP_SETTINGS);
33 });
34
35 it("defaults packaged nightly builds to the nightly update channel", () => {
36 expect(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1")).toEqual({
37 serverExposureMode: "local-only",
38 updateChannel: "nightly",
39 updateChannelConfiguredByUser: false,
40 });
3241 });
3342
3443 it("persists and reloads the configured server exposure mode", () => {
......@@ -37,11 +46,13 @@ describe("desktopSettings", () => {
3746 writeDesktopSettings(settingsPath, {
3847 serverExposureMode: "network-accessible",
3948 updateChannel: "latest",
49 updateChannelConfiguredByUser: true,
4050 });
4151
42 expect(readDesktopSettings(settingsPath)).toEqual({
52 expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({
4353 serverExposureMode: "network-accessible",
4454 updateChannel: "latest",
55 updateChannelConfiguredByUser: true,
4556 });
4657 });
4758
......@@ -51,12 +62,14 @@ describe("desktopSettings", () => {
5162 {
5263 serverExposureMode: "local-only",
5364 updateChannel: "latest",
65 updateChannelConfiguredByUser: false,
5466 },
5567 "network-accessible",
5668 ),
5769 ).toEqual({
5870 serverExposureMode: "network-accessible",
5971 updateChannel: "latest",
72 updateChannelConfiguredByUser: false,
6073 });
6174 });
6275
......@@ -66,12 +79,14 @@ describe("desktopSettings", () => {
6679 {
6780 serverExposureMode: "local-only",
6881 updateChannel: "latest",
82 updateChannelConfiguredByUser: false,
6983 },
7084 "nightly",
7185 ),
7286 ).toEqual({
7387 serverExposureMode: "local-only",
7488 updateChannel: "nightly",
89 updateChannelConfiguredByUser: true,
7590 });
7691 });
7792
......@@ -79,6 +94,54 @@ describe("desktopSettings", () => {
7994 const settingsPath = makeSettingsPath();
8095 fs.writeFileSync(settingsPath, "{not-json", "utf8");
8196
82 expect(readDesktopSettings(settingsPath)).toEqual(DEFAULT_DESKTOP_SETTINGS);
97 expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual(DEFAULT_DESKTOP_SETTINGS);
98 });
99
100 it("falls back to the nightly channel for legacy nightly settings without an update track", () => {
101 const settingsPath = makeSettingsPath();
102 fs.writeFileSync(settingsPath, JSON.stringify({ serverExposureMode: "local-only" }), "utf8");
103
104 expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({
105 serverExposureMode: "local-only",
106 updateChannel: "nightly",
107 updateChannelConfiguredByUser: false,
108 });
109 });
110
111 it("migrates legacy implicit stable settings to nightly when running a nightly build", () => {
112 const settingsPath = makeSettingsPath();
113 fs.writeFileSync(
114 settingsPath,
115 JSON.stringify({
116 serverExposureMode: "local-only",
117 updateChannel: "latest",
118 }),
119 "utf8",
120 );
121
122 expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({
123 serverExposureMode: "local-only",
124 updateChannel: "nightly",
125 updateChannelConfiguredByUser: false,
126 });
127 });
128
129 it("preserves an explicit stable choice on nightly builds", () => {
130 const settingsPath = makeSettingsPath();
131 fs.writeFileSync(
132 settingsPath,
133 JSON.stringify({
134 serverExposureMode: "local-only",
135 updateChannel: "latest",
136 updateChannelConfiguredByUser: true,
137 }),
138 "utf8",
139 );
140
141 expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({
142 serverExposureMode: "local-only",
143 updateChannel: "latest",
144 updateChannelConfiguredByUser: true,
145 });
83146 });
84147});
apps/desktop/src/desktopSettings.ts+35-10
......@@ -2,16 +2,27 @@ import * as FS from "node:fs";
22import * as Path from "node:path";
33import type { DesktopServerExposureMode, DesktopUpdateChannel } from "@t3tools/contracts";
44
5import { resolveDefaultDesktopUpdateChannel } from "./updateChannels";
6
57export interface DesktopSettings {
68 readonly serverExposureMode: DesktopServerExposureMode;
79 readonly updateChannel: DesktopUpdateChannel;
10 readonly updateChannelConfiguredByUser: boolean;
811}
912
1013export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
1114 serverExposureMode: "local-only",
1215 updateChannel: "latest",
16 updateChannelConfiguredByUser: false,
1317};
1418
19export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings {
20 return {
21 ...DEFAULT_DESKTOP_SETTINGS,
22 updateChannel: resolveDefaultDesktopUpdateChannel(appVersion),
23 };
24}
25
1526export function setDesktopServerExposurePreference(
1627 settings: DesktopSettings,
1728 requestedMode: DesktopServerExposureMode,
......@@ -28,33 +39,47 @@ export function setDesktopUpdateChannelPreference(
2839 settings: DesktopSettings,
2940 requestedChannel: DesktopUpdateChannel,
3041): DesktopSettings {
31 return settings.updateChannel === requestedChannel
32 ? settings
33 : {
34 ...settings,
35 updateChannel: requestedChannel,
36 };
42 return {
43 ...settings,
44 updateChannel: requestedChannel,
45 updateChannelConfiguredByUser: true,
46 };
3747}
3848
39export function readDesktopSettings(settingsPath: string): DesktopSettings {
49export function readDesktopSettings(settingsPath: string, appVersion: string): DesktopSettings {
50 const defaultSettings = resolveDefaultDesktopSettings(appVersion);
51
4052 try {
4153 if (!FS.existsSync(settingsPath)) {
42 return DEFAULT_DESKTOP_SETTINGS;
54 return defaultSettings;
4355 }
4456
4557 const raw = FS.readFileSync(settingsPath, "utf8");
4658 const parsed = JSON.parse(raw) as {
4759 readonly serverExposureMode?: unknown;
4860 readonly updateChannel?: unknown;
61 readonly updateChannelConfiguredByUser?: unknown;
4962 };
63 const parsedUpdateChannel =
64 parsed.updateChannel === "nightly" || parsed.updateChannel === "latest"
65 ? parsed.updateChannel
66 : null;
67 const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined;
68 const updateChannelConfiguredByUser =
69 parsed.updateChannelConfiguredByUser === true ||
70 (isLegacySettings && parsedUpdateChannel === "nightly");
5071
5172 return {
5273 serverExposureMode:
5374 parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only",
54 updateChannel: parsed.updateChannel === "nightly" ? "nightly" : "latest",
75 updateChannel:
76 updateChannelConfiguredByUser && parsedUpdateChannel !== null
77 ? parsedUpdateChannel
78 : defaultSettings.updateChannel,
79 updateChannelConfiguredByUser,
5580 };
5681 } catch {
57 return DEFAULT_DESKTOP_SETTINGS;
82 return defaultSettings;
5883 }
5984}
6085
apps/desktop/src/main.ts+16-5
......@@ -58,6 +58,7 @@ import { showDesktopConfirmDialog } from "./confirmDialog";
5858import { resolveDesktopServerExposure } from "./serverExposure";
5959import { syncShellEnvironment } from "./syncShellEnvironment";
6060import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";
61import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels";
6162import { ServerListeningDetector } from "./serverListeningDetector";
6263import {
6364 createInitialDesktopUpdateState,
......@@ -190,7 +191,7 @@ let desktopLogSink: RotatingFileSink | null = null;
190191let backendLogSink: RotatingFileSink | null = null;
191192let restoreStdIoCapture: (() => void) | null = null;
192193let backendObservabilitySettings = readPersistedBackendObservabilitySettings();
193let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH);
194let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH, app.getVersion());
194195let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode;
195196
196197let destructiveMenuIconCache: Electron.NativeImage | null | undefined;
......@@ -1140,7 +1141,7 @@ function applyAutoUpdaterChannel(channel: DesktopUpdateChannel): void {
11401141 autoUpdater.allowPrerelease = channel === "nightly";
11411142 autoUpdater.allowDowngrade = channel === "nightly";
11421143 console.info(
1143 `[desktop-updater] Using update channel '${channel}' (allowPrerelease=${channel === "nightly"}).`,
1144 `[desktop-updater] Using update channel '${channel}' (allowPrerelease=${channel === "nightly"}, allowDowngrade=${channel === "nightly"}).`,
11441145 );
11451146}
11461147
......@@ -1285,6 +1286,15 @@ function configureAutoUpdater(): void {
12851286 console.info("[desktop-updater] Looking for updates...");
12861287 });
12871288 autoUpdater.on("update-available", (info) => {
1289 if (!doesVersionMatchDesktopUpdateChannel(info.version, updateState.channel)) {
1290 console.info(
1291 `[desktop-updater] Ignoring ${info.version} because it does not match the selected '${updateState.channel}' channel.`,
1292 );
1293 setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, new Date().toISOString()));
1294 lastLoggedDownloadMilestone = -1;
1295 return;
1296 }
1297
12881298 setUpdateState(
12891299 reduceDesktopUpdateStateOnUpdateAvailable(
12901300 updateState,
......@@ -1792,13 +1802,14 @@ function registerIpcHandlers(): void {
17921802 }
17931803
17941804 const nextChannel = rawChannel as DesktopUpdateChannel;
1795 if (nextChannel === desktopSettings.updateChannel) {
1796 return updateState;
1797 }
17981805
17991806 desktopSettings = setDesktopUpdateChannelPreference(desktopSettings, nextChannel);
18001807 writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings);
18011808
1809 if (nextChannel === updateState.channel) {
1810 return updateState;
1811 }
1812
18021813 const enabled = shouldEnableAutoUpdates();
18031814 setUpdateState(createBaseUpdateState(nextChannel, enabled));
18041815
apps/desktop/src/updateChannels.test.ts created+41
......@@ -0,0 +1,41 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 doesVersionMatchDesktopUpdateChannel,
5 isNightlyDesktopVersion,
6 resolveDefaultDesktopUpdateChannel,
7} from "./updateChannels";
8
9describe("isNightlyDesktopVersion", () => {
10 it("detects packaged nightly versions", () => {
11 expect(isNightlyDesktopVersion("0.0.17-nightly.20260415.1")).toBe(true);
12 });
13
14 it("does not flag stable versions as nightly", () => {
15 expect(isNightlyDesktopVersion("0.0.17")).toBe(false);
16 });
17});
18
19describe("resolveDefaultDesktopUpdateChannel", () => {
20 it("defaults stable builds to latest", () => {
21 expect(resolveDefaultDesktopUpdateChannel("0.0.17")).toBe("latest");
22 });
23
24 it("defaults nightly builds to nightly", () => {
25 expect(resolveDefaultDesktopUpdateChannel("0.0.17-nightly.20260415.1")).toBe("nightly");
26 });
27});
28
29describe("doesVersionMatchDesktopUpdateChannel", () => {
30 it("accepts nightly releases on the nightly channel", () => {
31 expect(doesVersionMatchDesktopUpdateChannel("0.0.17-nightly.20260416.1", "nightly")).toBe(true);
32 });
33
34 it("rejects stable releases on the nightly channel", () => {
35 expect(doesVersionMatchDesktopUpdateChannel("0.0.17", "nightly")).toBe(false);
36 });
37
38 it("rejects nightly releases on the stable channel", () => {
39 expect(doesVersionMatchDesktopUpdateChannel("0.0.17-nightly.20260416.1", "latest")).toBe(false);
40 });
41});
apps/desktop/src/updateChannels.ts created+18
......@@ -0,0 +1,18 @@
1import type { DesktopUpdateChannel } from "@t3tools/contracts";
2
3const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/;
4
5export function isNightlyDesktopVersion(version: string): boolean {
6 return NIGHTLY_VERSION_PATTERN.test(version);
7}
8
9export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel {
10 return isNightlyDesktopVersion(appVersion) ? "nightly" : "latest";
11}
12
13export function doesVersionMatchDesktopUpdateChannel(
14 version: string,
15 channel: DesktopUpdateChannel,
16): boolean {
17 return resolveDefaultDesktopUpdateChannel(version) === channel;
18}
docs/release.md+1-1
......@@ -37,7 +37,7 @@ This document covers the unified release workflow for stable and nightly desktop
3737 - tag format: `nightly-vX.Y.Z-nightly.YYYYMMDD.<run_number>`
3838 - release name includes the short commit SHA
3939 - `make_latest` is always `false`
40- Uses the current `apps/desktop/package.json` semver core (`X.Y.Z`) as the nightly base, then appends a nightly prerelease suffix.
40- Uses the next stable patch version as the nightly base. For example, `0.0.17` produces nightlies on `0.0.18-nightly.*`.
4141- Publishes Electron auto-update metadata to the dedicated `nightly` updater channel, so desktop users can opt into that track independently from stable.
4242- Publishes the CLI package (`apps/server`, npm package `t3`) to the `nightly` npm dist-tag using the same nightly version.
4343- Does not commit version bumps back to `main`.
scripts/release-smoke.ts+3-3
......@@ -127,17 +127,17 @@ try {
127127 );
128128 assertContains(
129129 nightlyReleaseMetadata,
130 "version=9.9.9-nightly.20260413.321",
130 "version=9.9.10-nightly.20260413.321",
131131 "Expected nightly metadata to contain the derived nightly version.",
132132 );
133133 assertContains(
134134 nightlyReleaseMetadata,
135 "tag=nightly-v9.9.9-nightly.20260413.321",
135 "tag=nightly-v9.9.10-nightly.20260413.321",
136136 "Expected nightly metadata to contain the derived nightly tag.",
137137 );
138138 assertContains(
139139 nightlyReleaseMetadata,
140 "name=T3 Code Nightly 9.9.9-nightly.20260413.321 (abcdef123456)",
140 "name=T3 Code Nightly 9.9.10-nightly.20260413.321 (abcdef123456)",
141141 "Expected nightly metadata to include the short commit SHA in the release name.",
142142 );
143143
scripts/resolve-nightly-release.test.ts+12-5
......@@ -3,6 +3,7 @@ import { assert, it } from "@effect/vitest";
33import {
44 resolveNightlyBaseVersion,
55 resolveNightlyReleaseMetadata,
6 resolveNightlyTargetVersion,
67} from "./resolve-nightly-release.ts";
78
89it("strips prerelease and build metadata when deriving the nightly base version", () => {
......@@ -11,14 +12,20 @@ it("strips prerelease and build metadata when deriving the nightly base version"
1112 assert.equal(resolveNightlyBaseVersion("1.2.3-beta.4+build.9"), "1.2.3");
1213});
1314
15it("bumps the patch version before deriving nightly prerelease versions", () => {
16 assert.equal(resolveNightlyTargetVersion("0.0.17"), "0.0.18");
17 assert.equal(resolveNightlyTargetVersion("9.9.9-smoke.0"), "9.9.10");
18 assert.equal(resolveNightlyTargetVersion("1.2.3-beta.4+build.9"), "1.2.4");
19});
20
1421it("derives nightly metadata including the short commit sha in the release name", () => {
1522 assert.deepStrictEqual(
16 resolveNightlyReleaseMetadata("9.9.9", "20260413", 321, "abcdef1234567890"),
23 resolveNightlyReleaseMetadata("9.9.10", "20260413", 321, "abcdef1234567890"),
1724 {
18 baseVersion: "9.9.9",
19 version: "9.9.9-nightly.20260413.321",
20 tag: "nightly-v9.9.9-nightly.20260413.321",
21 name: "T3 Code Nightly 9.9.9-nightly.20260413.321 (abcdef123456)",
25 baseVersion: "9.9.10",
26 version: "9.9.10-nightly.20260413.321",
27 tag: "nightly-v9.9.10-nightly.20260413.321",
28 name: "T3 Code Nightly 9.9.10-nightly.20260413.321 (abcdef123456)",
2229 shortSha: "abcdef123456",
2330 },
2431 );
scripts/resolve-nightly-release.ts+12-1
......@@ -32,6 +32,17 @@ const decodeDesktopPackageJson = Schema.decodeUnknownEffect(
3232
3333export const resolveNightlyBaseVersion = (version: string) => version.replace(/[-+].*$/, "");
3434
35export const resolveNightlyTargetVersion = (version: string) => {
36 const stableCore = resolveNightlyBaseVersion(version);
37 const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(stableCore);
38 if (!match) {
39 throw new Error(`Invalid desktop package version '${version}'.`);
40 }
41
42 const [, major, minor, patch] = match;
43 return `${major}.${minor}.${Number(patch) + 1}`;
44};
45
3546export const resolveNightlyReleaseMetadata = (
3647 baseVersion: string,
3748 date: string,
......@@ -59,7 +70,7 @@ const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(function* (
5970 const packageJson = yield* fs
6071 .readFileString(packageJsonPath)
6172 .pipe(Effect.flatMap(decodeDesktopPackageJson));
62 return resolveNightlyBaseVersion(packageJson.version);
73 return resolveNightlyTargetVersion(packageJson.version);
6374});
6475
6576const writeOutput = Effect.fn("writeOutput")(function* (