authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-14 18:18:37-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-14 18:18:37-07:00
log409ff90a1401c0b8e2bef05d73b3be57ff9bc669
treed74b22eaea5ac7a32e6913438ff3d87a9f9651de
parent0d280262f6c9dcaf529376b2007dc7447f86fa3c
signature Signed by PGP key B5690EEEBB952194

Nightly release channel (#2012)

Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>

49 files changed, 1221 insertions(+), 149 deletions(-)

.github/workflows/ci.yml+3
...@@ -92,5 +92,8 @@ jobs:...@@ -92,5 +92,8 @@ jobs:
92 with:92 with:
93 node-version-file: package.json93 node-version-file: package.json
9494
95 - name: Install dependencies
96 run: bun install --frozen-lockfile
97
95 - name: Exercise release-only workflow steps98 - name: Exercise release-only workflow steps
96 run: node scripts/release-smoke.ts99 run: node scripts/release-smoke.ts
.github/workflows/release.yml+119-39
...@@ -1,14 +1,24 @@...@@ -1,14 +1,24 @@
1name: Release Desktop1name: Release
22
3on:3on:
4 push:4 push:
5 tags:5 tags:
6 - "v*.*.*"6 - "v*.*.*"
7 schedule:
8 - cron: "0 9 * * *"
7 workflow_dispatch:9 workflow_dispatch:
8 inputs:10 inputs:
11 channel:
12 description: "Release channel"
13 required: false
14 default: stable
15 type: choice
16 options:
17 - stable
18 - nightly
9 version:19 version:
10 description: "Release version (for example 1.2.3 or v1.2.3)"20 description: "Release version (for example 1.2.3 or v1.2.3)"
11 required: true21 required: false
12 type: string22 type: string
1323
14permissions:24permissions:
...@@ -21,40 +31,21 @@ jobs:...@@ -21,40 +31,21 @@ jobs:
21 runs-on: ubuntu-24.0431 runs-on: ubuntu-24.04
22 timeout-minutes: 1032 timeout-minutes: 10
23 outputs:33 outputs:
34 release_channel: ${{ steps.release_meta.outputs.release_channel }}
24 version: ${{ steps.release_meta.outputs.version }}35 version: ${{ steps.release_meta.outputs.version }}
25 tag: ${{ steps.release_meta.outputs.tag }}36 tag: ${{ steps.release_meta.outputs.tag }}
37 release_name: ${{ steps.release_meta.outputs.name }}
38 short_sha: ${{ steps.release_meta.outputs.short_sha }}
39 previous_tag: ${{ steps.previous_tag.outputs.previous_tag }}
40 cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }}
26 is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }}41 is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }}
27 make_latest: ${{ steps.release_meta.outputs.make_latest }}42 make_latest: ${{ steps.release_meta.outputs.make_latest }}
28 ref: ${{ github.sha }}43 ref: ${{ github.sha }}
29 steps:44 steps:
30 - name: Checkout45 - name: Checkout
31 uses: actions/checkout@v646 uses: actions/checkout@v6
3247 with:
33 - id: release_meta48 fetch-depth: 0
34 name: Resolve release version
35 shell: bash
36 run: |
37 if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
38 raw="${{ github.event.inputs.version }}"
39 else
40 raw="${GITHUB_REF_NAME}"
41 fi
42
43 version="${raw#v}"
44 if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
45 echo "Invalid release version: $raw" >&2
46 exit 1
47 fi
48
49 echo "version=$version" >> "$GITHUB_OUTPUT"
50 echo "tag=v$version" >> "$GITHUB_OUTPUT"
51 if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
52 echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
53 echo "make_latest=true" >> "$GITHUB_OUTPUT"
54 else
55 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
56 echo "make_latest=false" >> "$GITHUB_OUTPUT"
57 fi
5849
59 - name: Setup Bun50 - name: Setup Bun
60 uses: oven-sh/setup-bun@v251 uses: oven-sh/setup-bun@v2
...@@ -69,6 +60,60 @@ jobs:...@@ -69,6 +60,60 @@ jobs:
69 - name: Install dependencies60 - name: Install dependencies
70 run: bun install --frozen-lockfile61 run: bun install --frozen-lockfile
7162
63 - id: release_meta
64 name: Resolve release version
65 shell: bash
66 env:
67 DISPATCH_CHANNEL: ${{ github.event.inputs.channel }}
68 DISPATCH_VERSION: ${{ github.event.inputs.version }}
69 NIGHTLY_DATE: ${{ github.run_started_at }}
70 NIGHTLY_SHA: ${{ github.sha }}
71 NIGHTLY_RUN_NUMBER: ${{ github.run_number }}
72 run: |
73 if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then
74 nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)"
75
76 node scripts/resolve-nightly-release.ts \
77 --date "$nightly_date" \
78 --run-number "$NIGHTLY_RUN_NUMBER" \
79 --sha "$NIGHTLY_SHA" \
80 --github-output
81
82 echo "release_channel=nightly" >> "$GITHUB_OUTPUT"
83 echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT"
84 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
85 echo "make_latest=false" >> "$GITHUB_OUTPUT"
86 else
87 if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
88 raw="${DISPATCH_VERSION}"
89 if [[ -z "$raw" ]]; then
90 echo "workflow_dispatch stable releases require the version input." >&2
91 exit 1
92 fi
93 else
94 raw="${GITHUB_REF_NAME}"
95 fi
96
97 version="${raw#v}"
98 if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
99 echo "Invalid release version: $raw" >&2
100 exit 1
101 fi
102
103 echo "release_channel=stable" >> "$GITHUB_OUTPUT"
104 echo "version=$version" >> "$GITHUB_OUTPUT"
105 echo "tag=v$version" >> "$GITHUB_OUTPUT"
106 echo "name=T3 Code v$version" >> "$GITHUB_OUTPUT"
107 echo "cli_dist_tag=latest" >> "$GITHUB_OUTPUT"
108 if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
109 echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
110 echo "make_latest=true" >> "$GITHUB_OUTPUT"
111 else
112 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
113 echo "make_latest=false" >> "$GITHUB_OUTPUT"
114 fi
115 fi
116
72 - name: Lint117 - name: Lint
73 run: bun run lint118 run: bun run lint
74119
...@@ -78,6 +123,14 @@ jobs:...@@ -78,6 +123,14 @@ jobs:
78 - name: Test123 - name: Test
79 run: bun run test124 run: bun run test
80125
126 - id: previous_tag
127 name: Resolve previous release tag
128 run: |
129 node scripts/resolve-previous-release-tag.ts \
130 --channel "${{ steps.release_meta.outputs.release_channel }}" \
131 --current-tag "${{ steps.release_meta.outputs.tag }}" \
132 --github-output
133
81 build:134 build:
82 name: Build ${{ matrix.label }}135 name: Build ${{ matrix.label }}
83 needs: preflight136 needs: preflight
...@@ -206,16 +259,17 @@ jobs:...@@ -206,16 +259,17 @@ jobs:
206 "release/*.AppImage" \259 "release/*.AppImage" \
207 "release/*.exe" \260 "release/*.exe" \
208 "release/*.blockmap" \261 "release/*.blockmap" \
209 "release/latest*.yml"; do262 "release/*.yml"; do
210 for file in $pattern; do263 for file in $pattern; do
211 cp "$file" release-publish/264 cp "$file" release-publish/
212 done265 done
213 done266 done
214267
215 if [[ "${{ matrix.platform }}" == "mac" && "${{ matrix.arch }}" != "arm64" ]]; then268 if [[ "${{ matrix.platform }}" == "mac" && "${{ matrix.arch }}" != "arm64" ]]; then
216 if [[ -f release-publish/latest-mac.yml ]]; then269 shopt -s nullglob
217 mv release-publish/latest-mac.yml "release-publish/latest-mac-${{ matrix.arch }}.yml"270 for manifest in release-publish/*-mac.yml; do
218 fi271 mv "$manifest" "${manifest%.yml}-${{ matrix.arch }}.yml"
272 done
219 fi273 fi
220274
221 - name: Upload build artifacts275 - name: Upload build artifacts
...@@ -257,7 +311,7 @@ jobs:...@@ -257,7 +311,7 @@ jobs:
257 run: bun run build --filter=@t3tools/web --filter=t3311 run: bun run build --filter=@t3tools/web --filter=t3
258312
259 - name: Publish CLI package313 - name: Publish CLI package
260 run: node apps/server/scripts/cli.ts publish --tag latest --app-version "${{ needs.preflight.outputs.version }}" --verbose314 run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose
261315
262 release:316 release:
263 name: Publish GitHub Release317 name: Publish GitHub Release
...@@ -284,17 +338,42 @@ jobs:...@@ -284,17 +338,42 @@ jobs:
284338
285 - name: Merge macOS updater manifests339 - name: Merge macOS updater manifests
286 run: |340 run: |
287 node scripts/merge-mac-update-manifests.ts \341 shopt -s nullglob
288 release-assets/latest-mac.yml \342 for x64_manifest in release-assets/*-mac-x64.yml; do
289 release-assets/latest-mac-x64.yml343 arm64_manifest="${x64_manifest%-x64.yml}.yml"
290 rm -f release-assets/latest-mac-x64.yml344 if [[ -f "$arm64_manifest" ]]; then
345 node scripts/merge-mac-update-manifests.ts "$arm64_manifest" "$x64_manifest"
346 rm -f "$x64_manifest"
347 fi
348 done
291349
292 - name: Publish release350 - name: Publish release
351 if: needs.preflight.outputs.previous_tag != ''
352 uses: softprops/action-gh-release@v2
353 with:
354 tag_name: ${{ needs.preflight.outputs.tag }}
355 target_commitish: ${{ needs.preflight.outputs.ref }}
356 name: ${{ needs.preflight.outputs.release_name }}
357 generate_release_notes: true
358 previous_tag: ${{ needs.preflight.outputs.previous_tag }}
359 prerelease: ${{ needs.preflight.outputs.is_prerelease }}
360 make_latest: ${{ needs.preflight.outputs.make_latest }}
361 files: |
362 release-assets/*.dmg
363 release-assets/*.zip
364 release-assets/*.AppImage
365 release-assets/*.exe
366 release-assets/*.blockmap
367 release-assets/*.yml
368 fail_on_unmatched_files: true
369
370 - name: Publish first release
371 if: needs.preflight.outputs.previous_tag == ''
293 uses: softprops/action-gh-release@v2372 uses: softprops/action-gh-release@v2
294 with:373 with:
295 tag_name: ${{ needs.preflight.outputs.tag }}374 tag_name: ${{ needs.preflight.outputs.tag }}
296 target_commitish: ${{ needs.preflight.outputs.ref }}375 target_commitish: ${{ needs.preflight.outputs.ref }}
297 name: T3 Code v${{ needs.preflight.outputs.version }}376 name: ${{ needs.preflight.outputs.release_name }}
298 generate_release_notes: true377 generate_release_notes: true
299 prerelease: ${{ needs.preflight.outputs.is_prerelease }}378 prerelease: ${{ needs.preflight.outputs.is_prerelease }}
300 make_latest: ${{ needs.preflight.outputs.make_latest }}379 make_latest: ${{ needs.preflight.outputs.make_latest }}
...@@ -304,11 +383,12 @@ jobs:...@@ -304,11 +383,12 @@ jobs:
304 release-assets/*.AppImage383 release-assets/*.AppImage
305 release-assets/*.exe384 release-assets/*.exe
306 release-assets/*.blockmap385 release-assets/*.blockmap
307 release-assets/latest*.yml386 release-assets/*.yml
308 fail_on_unmatched_files: true387 fail_on_unmatched_files: true
309388
310 finalize:389 finalize:
311 name: Finalize release390 name: Finalize release
391 if: needs.preflight.outputs.release_channel == 'stable'
312 needs: [preflight, release]392 needs: [preflight, release]
313 runs-on: ubuntu-24.04393 runs-on: ubuntu-24.04
314 timeout-minutes: 10394 timeout-minutes: 10
.oxfmtrc.json+2-1
...@@ -10,7 +10,8 @@...@@ -10,7 +10,8 @@
10 "*.tsbuildinfo",10 "*.tsbuildinfo",
11 "**/routeTree.gen.ts",11 "**/routeTree.gen.ts",
12 "apps/web/public/mockServiceWorker.js",12 "apps/web/public/mockServiceWorker.js",
13 "apps/web/src/lib/vendor/qrcodegen.ts"13 "apps/web/src/lib/vendor/qrcodegen.ts",
14 "*.icon/**"
14 ],15 ],
15 "sortPackageJson": {}16 "sortPackageJson": {}
16}17}
apps/desktop/scripts/electron-launcher.mjs+67-1
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 cpSync,6 cpSync,
7 existsSync,7 existsSync,
8 mkdirSync,8 mkdirSync,
9 mkdtempSync,
9 readFileSync,10 readFileSync,
10 readdirSync,11 readdirSync,
11 rmSync,12 rmSync,
...@@ -23,6 +24,9 @@ const LAUNCHER_VERSION = 1;...@@ -23,6 +24,9 @@ const LAUNCHER_VERSION = 1;
2324
24const __dirname = dirname(fileURLToPath(import.meta.url));25const __dirname = dirname(fileURLToPath(import.meta.url));
25export const desktopDir = resolve(__dirname, "..");26export const desktopDir = resolve(__dirname, "..");
27const repoRoot = resolve(desktopDir, "..", "..");
28const defaultIconPath = join(desktopDir, "resources", "icon.icns");
29const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png");
2630
27function setPlistString(plistPath, key, value) {31function setPlistString(plistPath, key, value) {
28 const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], {32 const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], {
...@@ -43,6 +47,68 @@ function setPlistString(plistPath, key, value) {...@@ -43,6 +47,68 @@ function setPlistString(plistPath, key, value) {
43 throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim());47 throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim());
44}48}
4549
50function runChecked(command, args) {
51 const result = spawnSync(command, args, { encoding: "utf8" });
52 if (result.status === 0) {
53 return;
54 }
55
56 const details = [result.stdout, result.stderr].filter(Boolean).join("\n");
57 throw new Error(`Failed to run ${command} ${args.join(" ")}: ${details}`.trim());
58}
59
60function ensureDevelopmentIconIcns(runtimeDir) {
61 const generatedIconPath = join(runtimeDir, "icon-dev.icns");
62 mkdirSync(runtimeDir, { recursive: true });
63
64 if (!existsSync(developmentMacIconPngPath)) {
65 return defaultIconPath;
66 }
67
68 const sourceMtimeMs = statSync(developmentMacIconPngPath).mtimeMs;
69 if (existsSync(generatedIconPath) && statSync(generatedIconPath).mtimeMs >= sourceMtimeMs) {
70 return generatedIconPath;
71 }
72
73 const iconsetRoot = mkdtempSync(join(runtimeDir, "dev-iconset-"));
74 const iconsetDir = join(iconsetRoot, "icon.iconset");
75 mkdirSync(iconsetDir, { recursive: true });
76
77 try {
78 for (const size of [16, 32, 128, 256, 512]) {
79 runChecked("sips", [
80 "-z",
81 String(size),
82 String(size),
83 developmentMacIconPngPath,
84 "--out",
85 join(iconsetDir, `icon_${size}x${size}.png`),
86 ]);
87
88 const retinaSize = size * 2;
89 runChecked("sips", [
90 "-z",
91 String(retinaSize),
92 String(retinaSize),
93 developmentMacIconPngPath,
94 "--out",
95 join(iconsetDir, `icon_${size}x${size}@2x.png`),
96 ]);
97 }
98
99 runChecked("iconutil", ["-c", "icns", iconsetDir, "-o", generatedIconPath]);
100 return generatedIconPath;
101 } catch (error) {
102 console.warn(
103 "[desktop-launcher] Failed to generate dev macOS icon, falling back to default icon.",
104 error,
105 );
106 return defaultIconPath;
107 } finally {
108 rmSync(iconsetRoot, { recursive: true, force: true });
109 }
110}
111
46function patchMainBundleInfoPlist(appBundlePath, iconPath) {112function patchMainBundleInfoPlist(appBundlePath, iconPath) {
47 const infoPlistPath = join(appBundlePath, "Contents", "Info.plist");113 const infoPlistPath = join(appBundlePath, "Contents", "Info.plist");
48 setPlistString(infoPlistPath, "CFBundleDisplayName", APP_DISPLAY_NAME);114 setPlistString(infoPlistPath, "CFBundleDisplayName", APP_DISPLAY_NAME);
...@@ -102,7 +168,7 @@ function buildMacLauncher(electronBinaryPath) {...@@ -102,7 +168,7 @@ function buildMacLauncher(electronBinaryPath) {
102 const runtimeDir = join(desktopDir, ".electron-runtime");168 const runtimeDir = join(desktopDir, ".electron-runtime");
103 const targetAppBundlePath = join(runtimeDir, `${APP_DISPLAY_NAME}.app`);169 const targetAppBundlePath = join(runtimeDir, `${APP_DISPLAY_NAME}.app`);
104 const targetBinaryPath = join(targetAppBundlePath, "Contents", "MacOS", "Electron");170 const targetBinaryPath = join(targetAppBundlePath, "Contents", "MacOS", "Electron");
105 const iconPath = join(desktopDir, "resources", "icon.icns");171 const iconPath = isDevelopment ? ensureDevelopmentIconIcns(runtimeDir) : defaultIconPath;
106 const metadataPath = join(runtimeDir, "metadata.json");172 const metadataPath = join(runtimeDir, "metadata.json");
107173
108 mkdirSync(runtimeDir, { recursive: true });174 mkdirSync(runtimeDir, { recursive: true });
apps/desktop/src/appBranding.test.ts created+47
...@@ -0,0 +1,47 @@
1import { describe, expect, it } from "vitest";
2
3import { resolveDesktopAppBranding, resolveDesktopAppStageLabel } from "./appBranding";
4
5describe("resolveDesktopAppStageLabel", () => {
6 it("uses Dev in desktop development", () => {
7 expect(
8 resolveDesktopAppStageLabel({
9 isDevelopment: true,
10 appVersion: "0.0.17-nightly.20260414.1",
11 }),
12 ).toBe("Dev");
13 });
14
15 it("uses Nightly for packaged nightly builds", () => {
16 expect(
17 resolveDesktopAppStageLabel({
18 isDevelopment: false,
19 appVersion: "0.0.17-nightly.20260414.1",
20 }),
21 ).toBe("Nightly");
22 });
23
24 it("uses Alpha for packaged stable builds", () => {
25 expect(
26 resolveDesktopAppStageLabel({
27 isDevelopment: false,
28 appVersion: "0.0.17",
29 }),
30 ).toBe("Alpha");
31 });
32});
33
34describe("resolveDesktopAppBranding", () => {
35 it("returns a complete desktop branding payload", () => {
36 expect(
37 resolveDesktopAppBranding({
38 isDevelopment: false,
39 appVersion: "0.0.17-nightly.20260414.1",
40 }),
41 ).toEqual({
42 baseName: "T3 Code",
43 stageLabel: "Nightly",
44 displayName: "T3 Code (Nightly)",
45 });
46 });
47});
apps/desktop/src/appBranding.ts created+27
...@@ -0,0 +1,27 @@
1import type { DesktopAppBranding, DesktopAppStageLabel } from "@t3tools/contracts";
2
3const APP_BASE_NAME = "T3 Code";
4const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/;
5
6export function resolveDesktopAppStageLabel(input: {
7 readonly isDevelopment: boolean;
8 readonly appVersion: string;
9}): DesktopAppStageLabel {
10 if (input.isDevelopment) {
11 return "Dev";
12 }
13
14 return NIGHTLY_VERSION_PATTERN.test(input.appVersion) ? "Nightly" : "Alpha";
15}
16
17export function resolveDesktopAppBranding(input: {
18 readonly isDevelopment: boolean;
19 readonly appVersion: string;
20}): DesktopAppBranding {
21 const stageLabel = resolveDesktopAppStageLabel(input);
22 return {
23 baseName: APP_BASE_NAME,
24 stageLabel,
25 displayName: `${APP_BASE_NAME} (${stageLabel})`,
26 };
27}
apps/desktop/src/desktopSettings.test.ts+20
...@@ -8,6 +8,7 @@ import {...@@ -8,6 +8,7 @@ import {
8 DEFAULT_DESKTOP_SETTINGS,8 DEFAULT_DESKTOP_SETTINGS,
9 readDesktopSettings,9 readDesktopSettings,
10 setDesktopServerExposurePreference,10 setDesktopServerExposurePreference,
11 setDesktopUpdateChannelPreference,
11 writeDesktopSettings,12 writeDesktopSettings,
12} from "./desktopSettings";13} from "./desktopSettings";
1314
...@@ -35,10 +36,12 @@ describe("desktopSettings", () => {...@@ -35,10 +36,12 @@ describe("desktopSettings", () => {
3536
36 writeDesktopSettings(settingsPath, {37 writeDesktopSettings(settingsPath, {
37 serverExposureMode: "network-accessible",38 serverExposureMode: "network-accessible",
39 updateChannel: "latest",
38 });40 });
3941
40 expect(readDesktopSettings(settingsPath)).toEqual({42 expect(readDesktopSettings(settingsPath)).toEqual({
41 serverExposureMode: "network-accessible",43 serverExposureMode: "network-accessible",
44 updateChannel: "latest",
42 });45 });
43 });46 });
4447
...@@ -47,11 +50,28 @@ describe("desktopSettings", () => {...@@ -47,11 +50,28 @@ describe("desktopSettings", () => {
47 setDesktopServerExposurePreference(50 setDesktopServerExposurePreference(
48 {51 {
49 serverExposureMode: "local-only",52 serverExposureMode: "local-only",
53 updateChannel: "latest",
50 },54 },
51 "network-accessible",55 "network-accessible",
52 ),56 ),
53 ).toEqual({57 ).toEqual({
54 serverExposureMode: "network-accessible",58 serverExposureMode: "network-accessible",
59 updateChannel: "latest",
60 });
61 });
62
63 it("persists the requested nightly update channel", () => {
64 expect(
65 setDesktopUpdateChannelPreference(
66 {
67 serverExposureMode: "local-only",
68 updateChannel: "latest",
69 },
70 "nightly",
71 ),
72 ).toEqual({
73 serverExposureMode: "local-only",
74 updateChannel: "nightly",
55 });75 });
56 });76 });
5777
apps/desktop/src/desktopSettings.ts+17-1
...@@ -1,13 +1,15 @@...@@ -1,13 +1,15 @@
1import * as FS from "node:fs";1import * as FS from "node:fs";
2import * as Path from "node:path";2import * as Path from "node:path";
3import type { DesktopServerExposureMode } from "@t3tools/contracts";3import type { DesktopServerExposureMode, DesktopUpdateChannel } from "@t3tools/contracts";
44
5export interface DesktopSettings {5export interface DesktopSettings {
6 readonly serverExposureMode: DesktopServerExposureMode;6 readonly serverExposureMode: DesktopServerExposureMode;
7 readonly updateChannel: DesktopUpdateChannel;
7}8}
89
9export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {10export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
10 serverExposureMode: "local-only",11 serverExposureMode: "local-only",
12 updateChannel: "latest",
11};13};
1214
13export function setDesktopServerExposurePreference(15export function setDesktopServerExposurePreference(
...@@ -22,6 +24,18 @@ export function setDesktopServerExposurePreference(...@@ -22,6 +24,18 @@ export function setDesktopServerExposurePreference(
22 };24 };
23}25}
2426
27export function setDesktopUpdateChannelPreference(
28 settings: DesktopSettings,
29 requestedChannel: DesktopUpdateChannel,
30): DesktopSettings {
31 return settings.updateChannel === requestedChannel
32 ? settings
33 : {
34 ...settings,
35 updateChannel: requestedChannel,
36 };
37}
38
25export function readDesktopSettings(settingsPath: string): DesktopSettings {39export function readDesktopSettings(settingsPath: string): DesktopSettings {
26 try {40 try {
27 if (!FS.existsSync(settingsPath)) {41 if (!FS.existsSync(settingsPath)) {
...@@ -31,11 +45,13 @@ export function readDesktopSettings(settingsPath: string): DesktopSettings {...@@ -31,11 +45,13 @@ export function readDesktopSettings(settingsPath: string): DesktopSettings {
31 const raw = FS.readFileSync(settingsPath, "utf8");45 const raw = FS.readFileSync(settingsPath, "utf8");
32 const parsed = JSON.parse(raw) as {46 const parsed = JSON.parse(raw) as {
33 readonly serverExposureMode?: unknown;47 readonly serverExposureMode?: unknown;
48 readonly updateChannel?: unknown;
34 };49 };
3550
36 return {51 return {
37 serverExposureMode:52 serverExposureMode:
38 parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only",53 parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only",
54 updateChannel: parsed.updateChannel === "nightly" ? "nightly" : "latest",
39 };55 };
40 } catch {56 } catch {
41 return DEFAULT_DESKTOP_SETTINGS;57 return DEFAULT_DESKTOP_SETTINGS;
apps/desktop/src/main.ts+91-13
...@@ -22,8 +22,10 @@ import type { MenuItemConstructorOptions } from "electron";...@@ -22,8 +22,10 @@ import type { MenuItemConstructorOptions } from "electron";
22import type {22import type {
23 ClientSettings,23 ClientSettings,
24 DesktopTheme,24 DesktopTheme,
25 DesktopAppBranding,
25 DesktopServerExposureMode,26 DesktopServerExposureMode,
26 DesktopServerExposureState,27 DesktopServerExposureState,
28 DesktopUpdateChannel,
27 PersistedSavedEnvironmentRecord,29 PersistedSavedEnvironmentRecord,
28 DesktopUpdateActionResult,30 DesktopUpdateActionResult,
29 DesktopUpdateCheckResult,31 DesktopUpdateCheckResult,
...@@ -39,6 +41,7 @@ import {...@@ -39,6 +41,7 @@ import {
39 DEFAULT_DESKTOP_SETTINGS,41 DEFAULT_DESKTOP_SETTINGS,
40 readDesktopSettings,42 readDesktopSettings,
41 setDesktopServerExposurePreference,43 setDesktopServerExposurePreference,
44 setDesktopUpdateChannelPreference,
42 writeDesktopSettings,45 writeDesktopSettings,
43} from "./desktopSettings";46} from "./desktopSettings";
44import {47import {
...@@ -69,6 +72,7 @@ import {...@@ -69,6 +72,7 @@ import {
69 reduceDesktopUpdateStateOnUpdateAvailable,72 reduceDesktopUpdateStateOnUpdateAvailable,
70} from "./updateMachine";73} from "./updateMachine";
71import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch";74import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch";
75import { resolveDesktopAppBranding } from "./appBranding";
7276
73syncShellEnvironment();77syncShellEnvironment();
7478
...@@ -80,9 +84,11 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";...@@ -80,9 +84,11 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
80const MENU_ACTION_CHANNEL = "desktop:menu-action";84const MENU_ACTION_CHANNEL = "desktop:menu-action";
81const UPDATE_STATE_CHANNEL = "desktop:update-state";85const UPDATE_STATE_CHANNEL = "desktop:update-state";
82const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";86const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
87const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";
83const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";88const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
84const UPDATE_INSTALL_CHANNEL = "desktop:update-install";89const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
85const UPDATE_CHECK_CHANNEL = "desktop:update-check";90const UPDATE_CHECK_CHANNEL = "desktop:update-check";
91const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
86const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";92const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
87const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";93const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";
88const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings";94const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings";
...@@ -101,7 +107,11 @@ const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments...@@ -101,7 +107,11 @@ const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments
101const DESKTOP_SCHEME = "t3";107const DESKTOP_SCHEME = "t3";
102const ROOT_DIR = Path.resolve(__dirname, "../../..");108const ROOT_DIR = Path.resolve(__dirname, "../../..");
103const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);109const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
104const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";110const desktopAppBranding: DesktopAppBranding = resolveDesktopAppBranding({
111 isDevelopment,
112 appVersion: app.getVersion(),
113});
114const APP_DISPLAY_NAME = desktopAppBranding.displayName;
105const APP_USER_MODEL_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code";115const APP_USER_MODEL_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code";
106const LINUX_DESKTOP_ENTRY_NAME = isDevelopment ? "t3code-dev.desktop" : "t3code.desktop";116const LINUX_DESKTOP_ENTRY_NAME = isDevelopment ? "t3code-dev.desktop" : "t3code.desktop";
107const LINUX_WM_CLASS = isDevelopment ? "t3code-dev" : "t3code";117const LINUX_WM_CLASS = isDevelopment ? "t3code-dev" : "t3code";
...@@ -116,8 +126,6 @@ const APP_RUN_ID = Crypto.randomBytes(6).toString("hex");...@@ -116,8 +126,6 @@ const APP_RUN_ID = Crypto.randomBytes(6).toString("hex");
116const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json");126const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json");
117const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000;127const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000;
118const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;128const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;
119const DESKTOP_UPDATE_CHANNEL = "latest";
120const DESKTOP_UPDATE_ALLOW_PRERELEASE = false;
121const DESKTOP_LOOPBACK_HOST = "127.0.0.1";129const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
122const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const;130const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const;
123const TITLEBAR_HEIGHT = 40;131const TITLEBAR_HEIGHT = 40;
...@@ -167,7 +175,11 @@ const desktopRuntimeInfo = resolveDesktopRuntimeInfo({...@@ -167,7 +175,11 @@ const desktopRuntimeInfo = resolveDesktopRuntimeInfo({
167 runningUnderArm64Translation: app.runningUnderARM64Translation === true,175 runningUnderArm64Translation: app.runningUnderARM64Translation === true,
168});176});
169const initialUpdateState = (): DesktopUpdateState =>177const initialUpdateState = (): DesktopUpdateState =>
170 createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo);178 createInitialDesktopUpdateState(
179 app.getVersion(),
180 desktopRuntimeInfo,
181 desktopSettings.updateChannel,
182 );
171183
172function logTimestamp(): string {184function logTimestamp(): string {
173 return new Date().toISOString();185 return new Date().toISOString();
...@@ -975,6 +987,18 @@ function resolveResourcePath(fileName: string): string | null {...@@ -975,6 +987,18 @@ function resolveResourcePath(fileName: string): string | null {
975}987}
976988
977function resolveIconPath(ext: "ico" | "icns" | "png"): string | null {989function resolveIconPath(ext: "ico" | "icns" | "png"): string | null {
990 if (isDevelopment && process.platform === "darwin" && ext === "png") {
991 const developmentDockIconPath = Path.join(
992 ROOT_DIR,
993 "assets",
994 "dev",
995 "blueprint-macos-1024.png",
996 );
997 if (FS.existsSync(developmentDockIconPath)) {
998 return developmentDockIconPath;
999 }
1000 }
1001
978 return resolveResourcePath(`icon.${ext}`);1002 return resolveResourcePath(`icon.${ext}`);
979}1003}
9801004
...@@ -1074,6 +1098,26 @@ function setUpdateState(patch: Partial<DesktopUpdateState>): void {...@@ -1074,6 +1098,26 @@ function setUpdateState(patch: Partial<DesktopUpdateState>): void {
1074 emitUpdateState();1098 emitUpdateState();
1075}1099}
10761100
1101function createBaseUpdateState(
1102 channel: DesktopUpdateChannel,
1103 enabled: boolean,
1104): DesktopUpdateState {
1105 return {
1106 ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo, channel),
1107 enabled,
1108 status: enabled ? "idle" : "disabled",
1109 };
1110}
1111
1112function applyAutoUpdaterChannel(channel: DesktopUpdateChannel): void {
1113 autoUpdater.channel = channel;
1114 autoUpdater.allowPrerelease = channel === "nightly";
1115 autoUpdater.allowDowngrade = channel === "nightly";
1116 console.info(
1117 `[desktop-updater] Using update channel '${channel}' (allowPrerelease=${channel === "nightly"}).`,
1118 );
1119}
1120
1077function shouldEnableAutoUpdates(): boolean {1121function shouldEnableAutoUpdates(): boolean {
1078 const hasUpdateFeedConfig =1122 const hasUpdateFeedConfig =
1079 readAppUpdateYml() !== null || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES);1123 readAppUpdateYml() !== null || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES);
...@@ -1193,11 +1237,7 @@ function configureAutoUpdater(): void {...@@ -1193,11 +1237,7 @@ function configureAutoUpdater(): void {
1193 }1237 }
11941238
1195 const enabled = shouldEnableAutoUpdates();1239 const enabled = shouldEnableAutoUpdates();
1196 setUpdateState({1240 setUpdateState(createBaseUpdateState(desktopSettings.updateChannel, enabled));
1197 ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo),
1198 enabled,
1199 status: enabled ? "idle" : "disabled",
1200 });
1201 if (!enabled) {1241 if (!enabled) {
1202 return;1242 return;
1203 }1243 }
...@@ -1205,10 +1245,7 @@ function configureAutoUpdater(): void {...@@ -1205,10 +1245,7 @@ function configureAutoUpdater(): void {
12051245
1206 autoUpdater.autoDownload = false;1246 autoUpdater.autoDownload = false;
1207 autoUpdater.autoInstallOnAppQuit = false;1247 autoUpdater.autoInstallOnAppQuit = false;
1208 // Keep alpha branding, but force all installs onto the stable update track.1248 applyAutoUpdaterChannel(desktopSettings.updateChannel);
1209 autoUpdater.channel = DESKTOP_UPDATE_CHANNEL;
1210 autoUpdater.allowPrerelease = DESKTOP_UPDATE_ALLOW_PRERELEASE;
1211 autoUpdater.allowDowngrade = false;
1212 autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo);1249 autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo);
1213 let lastLoggedDownloadMilestone = -1;1250 let lastLoggedDownloadMilestone = -1;
12141251
...@@ -1486,6 +1523,11 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> {...@@ -1486,6 +1523,11 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> {
1486}1523}
14871524
1488function registerIpcHandlers(): void {1525function registerIpcHandlers(): void {
1526 ipcMain.removeAllListeners(GET_APP_BRANDING_CHANNEL);
1527 ipcMain.on(GET_APP_BRANDING_CHANNEL, (event) => {
1528 event.returnValue = desktopAppBranding;
1529 });
1530
1489 ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);1531 ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);
1490 ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => {1532 ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => {
1491 event.returnValue = {1533 event.returnValue = {
...@@ -1713,6 +1755,42 @@ function registerIpcHandlers(): void {...@@ -1713,6 +1755,42 @@ function registerIpcHandlers(): void {
1713 ipcMain.removeHandler(UPDATE_GET_STATE_CHANNEL);1755 ipcMain.removeHandler(UPDATE_GET_STATE_CHANNEL);
1714 ipcMain.handle(UPDATE_GET_STATE_CHANNEL, async () => updateState);1756 ipcMain.handle(UPDATE_GET_STATE_CHANNEL, async () => updateState);
17151757
1758 ipcMain.removeHandler(UPDATE_SET_CHANNEL_CHANNEL);
1759 ipcMain.handle(UPDATE_SET_CHANNEL_CHANNEL, async (_event, rawChannel: unknown) => {
1760 if (rawChannel !== "latest" && rawChannel !== "nightly") {
1761 throw new Error("Invalid desktop update channel input.");
1762 }
1763 if (updateCheckInFlight || updateDownloadInFlight || updateInstallInFlight) {
1764 throw new Error("Cannot change update tracks while an update action is in progress.");
1765 }
1766
1767 const nextChannel = rawChannel as DesktopUpdateChannel;
1768 if (nextChannel === desktopSettings.updateChannel) {
1769 return updateState;
1770 }
1771
1772 desktopSettings = setDesktopUpdateChannelPreference(desktopSettings, nextChannel);
1773 writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings);
1774
1775 const enabled = shouldEnableAutoUpdates();
1776 setUpdateState(createBaseUpdateState(nextChannel, enabled));
1777
1778 if (!enabled || !updaterConfigured) {
1779 return updateState;
1780 }
1781
1782 applyAutoUpdaterChannel(nextChannel);
1783 const allowDowngrade = autoUpdater.allowDowngrade;
1784 // An explicit channel switch should allow the immediate nightly->stable rollback path.
1785 autoUpdater.allowDowngrade = true;
1786 try {
1787 await checkForUpdates("channel-change");
1788 } finally {
1789 autoUpdater.allowDowngrade = allowDowngrade;
1790 }
1791 return updateState;
1792 });
1793
1716 ipcMain.removeHandler(UPDATE_DOWNLOAD_CHANNEL);1794 ipcMain.removeHandler(UPDATE_DOWNLOAD_CHANNEL);
1717 ipcMain.handle(UPDATE_DOWNLOAD_CHANNEL, async () => {1795 ipcMain.handle(UPDATE_DOWNLOAD_CHANNEL, async () => {
1718 const result = await downloadAvailableUpdate();1796 const result = await downloadAvailableUpdate();
apps/desktop/src/preload.ts+10
...@@ -9,9 +9,11 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";...@@ -9,9 +9,11 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
9const MENU_ACTION_CHANNEL = "desktop:menu-action";9const MENU_ACTION_CHANNEL = "desktop:menu-action";
10const UPDATE_STATE_CHANNEL = "desktop:update-state";10const UPDATE_STATE_CHANNEL = "desktop:update-state";
11const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";11const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
12const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";
12const UPDATE_CHECK_CHANNEL = "desktop:update-check";13const UPDATE_CHECK_CHANNEL = "desktop:update-check";
13const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";14const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
14const UPDATE_INSTALL_CHANNEL = "desktop:update-install";15const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
16const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
15const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";17const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
16const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";18const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";
17const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings";19const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings";
...@@ -24,6 +26,13 @@ const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";...@@ -24,6 +26,13 @@ const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";
24const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";26const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";
2527
26contextBridge.exposeInMainWorld("desktopBridge", {28contextBridge.exposeInMainWorld("desktopBridge", {
29 getAppBranding: () => {
30 const result = ipcRenderer.sendSync(GET_APP_BRANDING_CHANNEL);
31 if (typeof result !== "object" || result === null) {
32 return null;
33 }
34 return result as ReturnType<DesktopBridge["getAppBranding"]>;
35 },
27 getLocalEnvironmentBootstrap: () => {36 getLocalEnvironmentBootstrap: () => {
28 const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);37 const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);
29 if (typeof result !== "object" || result === null) {38 if (typeof result !== "object" || result === null) {
...@@ -61,6 +70,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -61,6 +70,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
61 };70 };
62 },71 },
63 getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL),72 getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL),
73 setUpdateChannel: (channel) => ipcRenderer.invoke(UPDATE_SET_CHANNEL_CHANNEL, channel),
64 checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL),74 checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL),
65 downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL),75 downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL),
66 installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL),76 installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL),
apps/desktop/src/updateMachine.test.ts+7-6
...@@ -23,7 +23,7 @@ describe("updateMachine", () => {...@@ -23,7 +23,7 @@ describe("updateMachine", () => {
23 it("clears transient errors when a check starts", () => {23 it("clears transient errors when a check starts", () => {
24 const state = reduceDesktopUpdateStateOnCheckStart(24 const state = reduceDesktopUpdateStateOnCheckStart(
25 {25 {
26 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),26 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
27 enabled: true,27 enabled: true,
28 status: "error",28 status: "error",
29 message: "network",29 message: "network",
...@@ -42,7 +42,7 @@ describe("updateMachine", () => {...@@ -42,7 +42,7 @@ describe("updateMachine", () => {
42 it("records a check failure without exposing an action", () => {42 it("records a check failure without exposing an action", () => {
43 const state = reduceDesktopUpdateStateOnCheckFailure(43 const state = reduceDesktopUpdateStateOnCheckFailure(
44 {44 {
45 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),45 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
46 enabled: true,46 enabled: true,
47 status: "checking",47 status: "checking",
48 },48 },
...@@ -58,7 +58,7 @@ describe("updateMachine", () => {...@@ -58,7 +58,7 @@ describe("updateMachine", () => {
58 it("preserves available version on download failure for retry", () => {58 it("preserves available version on download failure for retry", () => {
59 const state = reduceDesktopUpdateStateOnDownloadFailure(59 const state = reduceDesktopUpdateStateOnDownloadFailure(
60 {60 {
61 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),61 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
62 enabled: true,62 enabled: true,
63 status: "downloading",63 status: "downloading",
64 availableVersion: "1.1.0",64 availableVersion: "1.1.0",
...@@ -76,7 +76,7 @@ describe("updateMachine", () => {...@@ -76,7 +76,7 @@ describe("updateMachine", () => {
76 it("transitions to downloaded and then preserves install retry state", () => {76 it("transitions to downloaded and then preserves install retry state", () => {
77 const downloaded = reduceDesktopUpdateStateOnDownloadComplete(77 const downloaded = reduceDesktopUpdateStateOnDownloadComplete(
78 {78 {
79 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),79 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
80 enabled: true,80 enabled: true,
81 status: "downloading",81 status: "downloading",
82 availableVersion: "1.1.0",82 availableVersion: "1.1.0",
...@@ -98,7 +98,7 @@ describe("updateMachine", () => {...@@ -98,7 +98,7 @@ describe("updateMachine", () => {
98 it("clears stale download state when no update is available", () => {98 it("clears stale download state when no update is available", () => {
99 const state = reduceDesktopUpdateStateOnNoUpdate(99 const state = reduceDesktopUpdateStateOnNoUpdate(
100 {100 {
101 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),101 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
102 enabled: true,102 enabled: true,
103 status: "error",103 status: "error",
104 availableVersion: "1.1.0",104 availableVersion: "1.1.0",
...@@ -120,7 +120,7 @@ describe("updateMachine", () => {...@@ -120,7 +120,7 @@ describe("updateMachine", () => {
120 it("tracks available, download start, and progress cleanly", () => {120 it("tracks available, download start, and progress cleanly", () => {
121 const available = reduceDesktopUpdateStateOnUpdateAvailable(121 const available = reduceDesktopUpdateStateOnUpdateAvailable(
122 {122 {
123 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),123 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
124 enabled: true,124 enabled: true,
125 status: "checking",125 status: "checking",
126 },126 },
...@@ -131,6 +131,7 @@ describe("updateMachine", () => {...@@ -131,6 +131,7 @@ describe("updateMachine", () => {
131 const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5);131 const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5);
132132
133 expect(available.status).toBe("available");133 expect(available.status).toBe("available");
134 expect(available.channel).toBe("latest");
134 expect(downloading.status).toBe("downloading");135 expect(downloading.status).toBe("downloading");
135 expect(downloading.downloadPercent).toBe(0);136 expect(downloading.downloadPercent).toBe(0);
136 expect(progress.downloadPercent).toBe(55.5);137 expect(progress.downloadPercent).toBe(55.5);
apps/desktop/src/updateMachine.ts+7-1
...@@ -1,14 +1,20 @@...@@ -1,14 +1,20 @@
1import type { DesktopRuntimeInfo, DesktopUpdateState } from "@t3tools/contracts";1import type {
2 DesktopRuntimeInfo,
3 DesktopUpdateChannel,
4 DesktopUpdateState,
5} from "@t3tools/contracts";
26
3import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState";7import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState";
48
5export function createInitialDesktopUpdateState(9export function createInitialDesktopUpdateState(
6 currentVersion: string,10 currentVersion: string,
7 runtimeInfo: DesktopRuntimeInfo,11 runtimeInfo: DesktopRuntimeInfo,
12 channel: DesktopUpdateChannel,
8): DesktopUpdateState {13): DesktopUpdateState {
9 return {14 return {
10 enabled: false,15 enabled: false,
11 status: "disabled",16 status: "disabled",
17 channel,
12 currentVersion,18 currentVersion,
13 hostArch: runtimeInfo.hostArch,19 hostArch: runtimeInfo.hostArch,
14 appArch: runtimeInfo.appArch,20 appArch: runtimeInfo.appArch,
apps/desktop/src/updateState.test.ts+1
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11const baseState: DesktopUpdateState = {11const baseState: DesktopUpdateState = {
12 enabled: true,12 enabled: true,
13 status: "idle",13 status: "idle",
14 channel: "latest",
14 currentVersion: "1.0.0",15 currentVersion: "1.0.0",
15 hostArch: "x64",16 hostArch: "x64",
16 appArch: "x64",17 appArch: "x64",
apps/web/src/branding.test.ts created+37
...@@ -0,0 +1,37 @@
1import { afterEach, describe, expect, it, vi } from "vitest";
2
3const originalWindow = globalThis.window;
4
5afterEach(() => {
6 vi.resetModules();
7
8 if (originalWindow === undefined) {
9 Reflect.deleteProperty(globalThis, "window");
10 return;
11 }
12
13 globalThis.window = originalWindow;
14});
15
16describe("branding", () => {
17 it("uses injected desktop branding when available", async () => {
18 Object.defineProperty(globalThis, "window", {
19 configurable: true,
20 value: {
21 desktopBridge: {
22 getAppBranding: () => ({
23 baseName: "T3 Code",
24 stageLabel: "Nightly",
25 displayName: "T3 Code (Nightly)",
26 }),
27 },
28 },
29 });
30
31 const branding = await import("./branding");
32
33 expect(branding.APP_BASE_NAME).toBe("T3 Code");
34 expect(branding.APP_STAGE_LABEL).toBe("Nightly");
35 expect(branding.APP_DISPLAY_NAME).toBe("T3 Code (Nightly)");
36 });
37});
apps/web/src/branding.ts+17-3
...@@ -1,4 +1,18 @@...@@ -1,4 +1,18 @@
1export const APP_BASE_NAME = "T3 Code";1import type { DesktopAppBranding } from "@t3tools/contracts";
2export const APP_STAGE_LABEL = import.meta.env.DEV ? "Dev" : "Alpha";2
3export const APP_DISPLAY_NAME = `${APP_BASE_NAME} (${APP_STAGE_LABEL})`;3function readInjectedDesktopAppBranding(): DesktopAppBranding | null {
4 if (typeof window === "undefined") {
5 return null;
6 }
7
8 return window.desktopBridge?.getAppBranding?.() ?? null;
9}
10
11const injectedDesktopAppBranding = readInjectedDesktopAppBranding();
12
13export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "T3 Code";
14export const APP_STAGE_LABEL =
15 injectedDesktopAppBranding?.stageLabel ?? (import.meta.env.DEV ? "Dev" : "Alpha");
16export const APP_DISPLAY_NAME =
17 injectedDesktopAppBranding?.displayName ?? `${APP_BASE_NAME} (${APP_STAGE_LABEL})`;
4export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0";18export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0";
apps/web/src/components/desktopUpdate.logic.test.ts+1
...@@ -17,6 +17,7 @@ import {...@@ -17,6 +17,7 @@ import {
17const baseState: DesktopUpdateState = {17const baseState: DesktopUpdateState = {
18 enabled: true,18 enabled: true,
19 status: "idle",19 status: "idle",
20 channel: "latest",
20 currentVersion: "1.0.0",21 currentVersion: "1.0.0",
21 hostArch: "x64",22 hostArch: "x64",
22 appArch: "x64",23 appArch: "x64",
apps/web/src/components/settings/SettingsPanels.browser.tsx+10
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 DEFAULT_SERVER_SETTINGS,7 DEFAULT_SERVER_SETTINGS,
8 EnvironmentId,8 EnvironmentId,
9 type DesktopBridge,9 type DesktopBridge,
10 type DesktopUpdateChannel,
10 type DesktopUpdateState,11 type DesktopUpdateState,
11 type LocalApi,12 type LocalApi,
12 type ServerConfig,13 type ServerConfig,
...@@ -258,10 +259,12 @@ function makeClientSession(input: {...@@ -258,10 +259,12 @@ function makeClientSession(input: {
258const createDesktopBridgeStub = (overrides?: {259const createDesktopBridgeStub = (overrides?: {
259 readonly serverExposureState?: Awaited<ReturnType<DesktopBridge["getServerExposureState"]>>;260 readonly serverExposureState?: Awaited<ReturnType<DesktopBridge["getServerExposureState"]>>;
260 readonly setServerExposureMode?: DesktopBridge["setServerExposureMode"];261 readonly setServerExposureMode?: DesktopBridge["setServerExposureMode"];
262 readonly setUpdateChannel?: DesktopBridge["setUpdateChannel"];
261}): DesktopBridge => {263}): DesktopBridge => {
262 const idleUpdateState: DesktopUpdateState = {264 const idleUpdateState: DesktopUpdateState = {
263 enabled: false,265 enabled: false,
264 status: "idle",266 status: "idle",
267 channel: "latest",
265 currentVersion: "0.0.0-test",268 currentVersion: "0.0.0-test",
266 hostArch: "arm64",269 hostArch: "arm64",
267 appArch: "arm64",270 appArch: "arm64",
...@@ -276,6 +279,7 @@ const createDesktopBridgeStub = (overrides?: {...@@ -276,6 +279,7 @@ const createDesktopBridgeStub = (overrides?: {
276 };279 };
277280
278 return {281 return {
282 getAppBranding: vi.fn().mockReturnValue(null),
279 getLocalEnvironmentBootstrap: () => ({283 getLocalEnvironmentBootstrap: () => ({
280 label: "Local environment",284 label: "Local environment",
281 httpBaseUrl: "http://127.0.0.1:3773",285 httpBaseUrl: "http://127.0.0.1:3773",
...@@ -310,6 +314,12 @@ const createDesktopBridgeStub = (overrides?: {...@@ -310,6 +314,12 @@ const createDesktopBridgeStub = (overrides?: {
310 openExternal: vi.fn().mockResolvedValue(true),314 openExternal: vi.fn().mockResolvedValue(true),
311 onMenuAction: () => () => {},315 onMenuAction: () => () => {},
312 getUpdateState: vi.fn().mockResolvedValue(idleUpdateState),316 getUpdateState: vi.fn().mockResolvedValue(idleUpdateState),
317 setUpdateChannel:
318 overrides?.setUpdateChannel ??
319 vi.fn().mockImplementation(async (channel: DesktopUpdateChannel) => ({
320 ...idleUpdateState,
321 channel,
322 })),
313 checkForUpdate: vi.fn().mockResolvedValue({ checked: false, state: idleUpdateState }),323 checkForUpdate: vi.fn().mockResolvedValue({ checked: false, state: idleUpdateState }),
314 downloadUpdate: vi324 downloadUpdate: vi
315 .fn()325 .fn()
apps/web/src/components/settings/SettingsPanels.tsx+88-21
...@@ -12,6 +12,7 @@ import { useQueryClient } from "@tanstack/react-query";...@@ -12,6 +12,7 @@ import { useQueryClient } from "@tanstack/react-query";
12import { type ReactNode, useCallback, useMemo, useRef, useState } from "react";12import { type ReactNode, useCallback, useMemo, useRef, useState } from "react";
13import {13import {
14 PROVIDER_DISPLAY_NAMES,14 PROVIDER_DISPLAY_NAMES,
15 type DesktopUpdateChannel,
15 type ScopedThreadRef,16 type ScopedThreadRef,
16 type ProviderKind,17 type ProviderKind,
17 type ServerProvider,18 type ServerProvider,
...@@ -232,8 +233,42 @@ function AboutVersionTitle() {...@@ -232,8 +233,42 @@ function AboutVersionTitle() {
232function AboutVersionSection() {233function AboutVersionSection() {
233 const queryClient = useQueryClient();234 const queryClient = useQueryClient();
234 const updateStateQuery = useDesktopUpdateState();235 const updateStateQuery = useDesktopUpdateState();
236 const [isChangingUpdateChannel, setIsChangingUpdateChannel] = useState(false);
235237
236 const updateState = updateStateQuery.data ?? null;238 const updateState = updateStateQuery.data ?? null;
239 const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge);
240 const selectedUpdateChannel = updateState?.channel ?? "latest";
241
242 const handleUpdateChannelChange = useCallback(
243 (channel: DesktopUpdateChannel) => {
244 const bridge = window.desktopBridge;
245 if (
246 !bridge ||
247 typeof bridge.setUpdateChannel !== "function" ||
248 channel === selectedUpdateChannel
249 ) {
250 return;
251 }
252
253 setIsChangingUpdateChannel(true);
254 void bridge
255 .setUpdateChannel(channel)
256 .then((state) => {
257 setDesktopUpdateStateQueryData(queryClient, state);
258 })
259 .catch((error: unknown) => {
260 toastManager.add({
261 type: "error",
262 title: "Could not change update track",
263 description: error instanceof Error ? error.message : "Update track change failed.",
264 });
265 })
266 .finally(() => {
267 setIsChangingUpdateChannel(false);
268 });
269 },
270 [queryClient, selectedUpdateChannel],
271 );
237272
238 const handleButtonClick = useCallback(() => {273 const handleButtonClick = useCallback(() => {
239 const bridge = window.desktopBridge;274 const bridge = window.desktopBridge;
...@@ -323,27 +358,59 @@ function AboutVersionSection() {...@@ -323,27 +358,59 @@ function AboutVersionSection() {
323 : "Current version of the application.";358 : "Current version of the application.";
324359
325 return (360 return (
326 <SettingsRow361 <>
327 title={<AboutVersionTitle />}362 <SettingsRow
328 description={description}363 title={<AboutVersionTitle />}
329 control={364 description={description}
330 <Tooltip>365 control={
331 <TooltipTrigger366 <Tooltip>
332 render={367 <TooltipTrigger
333 <Button368 render={
334 size="xs"369 <Button
335 variant={action === "install" ? "default" : "outline"}370 size="xs"
336 disabled={buttonDisabled}371 variant={action === "install" ? "default" : "outline"}
337 onClick={handleButtonClick}372 disabled={buttonDisabled}
338 >373 onClick={handleButtonClick}
339 {buttonLabel}374 >
340 </Button>375 {buttonLabel}
341 }376 </Button>
342 />377 }
343 {buttonTooltip ? <TooltipPopup>{buttonTooltip}</TooltipPopup> : null}378 />
344 </Tooltip>379 {buttonTooltip ? <TooltipPopup>{buttonTooltip}</TooltipPopup> : null}
345 }380 </Tooltip>
346 />381 }
382 />
383 <SettingsRow
384 title="Update track"
385 description="Stable follows full releases. Nightly follows the nightly desktop channel and can switch back to stable immediately."
386 control={
387 <Select
388 value={selectedUpdateChannel}
389 onValueChange={(value) => {
390 handleUpdateChannelChange(value as DesktopUpdateChannel);
391 }}
392 >
393 <SelectTrigger
394 className="w-full sm:w-40"
395 aria-label="Update track"
396 disabled={!hasDesktopBridge || isChangingUpdateChannel}
397 >
398 <SelectValue>
399 {selectedUpdateChannel === "nightly" ? "Nightly" : "Stable"}
400 </SelectValue>
401 </SelectTrigger>
402 <SelectPopup align="end" alignItemWithTrigger={false}>
403 <SelectItem hideIndicator value="latest">
404 Stable
405 </SelectItem>
406 <SelectItem hideIndicator value="nightly">
407 Nightly
408 </SelectItem>
409 </SelectPopup>
410 </Select>
411 }
412 />
413 </>
347 );414 );
348}415}
349416
apps/web/src/lib/desktopUpdateReactQuery.test.ts+1
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10const baseState: DesktopUpdateState = {10const baseState: DesktopUpdateState = {
11 enabled: true,11 enabled: true,
12 status: "idle",12 status: "idle",
13 channel: "latest",
13 currentVersion: "1.0.0",14 currentVersion: "1.0.0",
14 hostArch: "x64",15 hostArch: "x64",
15 appArch: "x64",16 appArch: "x64",
apps/web/src/localApi.test.ts+4
...@@ -157,6 +157,7 @@ function createLocalStorageStub(): Storage {...@@ -157,6 +157,7 @@ function createLocalStorageStub(): Storage {
157157
158function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge {158function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge {
159 return {159 return {
160 getAppBranding: () => null,
160 getLocalEnvironmentBootstrap: () => null,161 getLocalEnvironmentBootstrap: () => null,
161 getClientSettings: async () => null,162 getClientSettings: async () => null,
162 setClientSettings: async () => undefined,163 setClientSettings: async () => undefined,
...@@ -184,6 +185,9 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg...@@ -184,6 +185,9 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg
184 getUpdateState: async () => {185 getUpdateState: async () => {
185 throw new Error("getUpdateState not implemented in test");186 throw new Error("getUpdateState not implemented in test");
186 },187 },
188 setUpdateChannel: async () => {
189 throw new Error("setUpdateChannel not implemented in test");
190 },
187 checkForUpdate: async () => {191 checkForUpdate: async () => {
188 throw new Error("checkForUpdate not implemented in test");192 throw new Error("checkForUpdate not implemented in test");
189 },193 },
assets/dev/blueprint-icon-composer.icon/Assets/T3.svg created+3
...@@ -0,0 +1,3 @@
1<svg width="77" height="47" viewBox="0 0 77 47" fill="none" xmlns="http://www.w3.org/2000/svg">
2<path d="M14.56 45.5V8.58H1.69082e-06V-3.09944e-06H39.65V8.58H25.09V45.5H14.56ZM57.8454 46.28C54.6821 46.28 51.5404 45.8683 48.4204 45.045C45.3004 44.1783 42.6571 42.965 40.4904 41.405L44.5854 33.345C46.3188 34.6017 48.3338 35.5983 50.6304 36.335C52.9271 37.0717 55.2454 37.44 57.5854 37.44C60.2288 37.44 62.3088 36.92 63.8254 35.88C65.3421 34.84 66.1004 33.41 66.1004 31.59C66.1004 29.8567 65.4288 28.4917 64.0854 27.495C62.7421 26.4983 60.5754 26 57.5854 26H52.7754V19.045L65.4504 4.68L66.6204 8.45H42.7654V-3.09944e-06H74.6154V6.825L62.0054 21.19L56.6754 18.135H59.7304C65.3204 18.135 69.5454 19.3917 72.4054 21.905C75.2654 24.4183 76.6954 27.6467 76.6954 31.59C76.6954 34.1467 76.0238 36.5517 74.6804 38.805C73.3371 41.015 71.2788 42.8133 68.5054 44.2C65.7321 45.5867 62.1788 46.28 57.8454 46.28Z" fill="white"/>
3</svg>
assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg created
Binary files /dev/null and b/assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg differ
assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png created
Binary files /dev/null and b/assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png differ
assets/dev/blueprint-icon-composer.icon/icon.json created+47
...@@ -0,0 +1,47 @@
1{
2 "fill": {
3 "solid": "display-p3:0.00000,0.00000,0.00000,1.00000"
4 },
5 "groups": [
6 {
7 "layers": [
8 {
9 "blend-mode": "normal",
10 "fill": "automatic",
11 "glass": true,
12 "hidden": false,
13 "image-name": "gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png",
14 "name": "gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871",
15 "position": {
16 "scale": 1.05,
17 "translation-in-points": [0, 0]
18 }
19 },
20 {
21 "image-name": "T3.svg",
22 "name": "T3",
23 "position": {
24 "scale": 10,
25 "translation-in-points": [0, 0]
26 }
27 },
28 {
29 "hidden": false,
30 "image-name": "Texturelabs_Paper_381XL.jpg",
31 "name": "Texturelabs_Paper_381XL"
32 }
33 ],
34 "shadow": {
35 "kind": "neutral",
36 "opacity": 0.5
37 },
38 "translucency": {
39 "enabled": true,
40 "value": 0.5
41 }
42 }
43 ],
44 "supported-platforms": {
45 "squares": ["iOS", "macOS"]
46 }
47}
assets/dev/blueprint-ios-1024.png
Binary files a/assets/dev/blueprint-ios-1024.png and b/assets/dev/blueprint-ios-1024.png differ
assets/dev/blueprint-macos-1024.png
Binary files a/assets/dev/blueprint-macos-1024.png and b/assets/dev/blueprint-macos-1024.png differ
assets/dev/blueprint-universal-1024.png
Binary files a/assets/dev/blueprint-universal-1024.png and b/assets/dev/blueprint-universal-1024.png differ
assets/dev/blueprint-web-apple-touch-180.png
Binary files a/assets/dev/blueprint-web-apple-touch-180.png and b/assets/dev/blueprint-web-apple-touch-180.png differ
assets/dev/blueprint-web-favicon-16x16.png
Binary files a/assets/dev/blueprint-web-favicon-16x16.png and b/assets/dev/blueprint-web-favicon-16x16.png differ
assets/dev/blueprint-web-favicon-32x32.png
Binary files a/assets/dev/blueprint-web-favicon-32x32.png and b/assets/dev/blueprint-web-favicon-32x32.png differ
assets/dev/blueprint-web-favicon.ico
Binary files a/assets/dev/blueprint-web-favicon.ico and b/assets/dev/blueprint-web-favicon.ico differ
assets/dev/blueprint-windows.ico
Binary files a/assets/dev/blueprint-windows.ico and b/assets/dev/blueprint-windows.ico differ
assets/nightly/blueprint-ios-1024.png created
Binary files /dev/null and b/assets/nightly/blueprint-ios-1024.png differ
assets/nightly/blueprint-macos-1024.png created
Binary files /dev/null and b/assets/nightly/blueprint-macos-1024.png differ
assets/nightly/blueprint-universal-1024.png created
Binary files /dev/null and b/assets/nightly/blueprint-universal-1024.png differ
assets/nightly/blueprint-web-apple-touch-180.png created
Binary files /dev/null and b/assets/nightly/blueprint-web-apple-touch-180.png differ
assets/nightly/blueprint-web-favicon-16x16.png created
Binary files /dev/null and b/assets/nightly/blueprint-web-favicon-16x16.png differ
assets/nightly/blueprint-web-favicon-32x32.png created
Binary files /dev/null and b/assets/nightly/blueprint-web-favicon-32x32.png differ
assets/nightly/blueprint-web-favicon.ico created
Binary files /dev/null and b/assets/nightly/blueprint-web-favicon.ico differ
assets/nightly/blueprint-windows.ico created
Binary files /dev/null and b/assets/nightly/blueprint-windows.ico differ
docs/release.md+37-12
...@@ -1,23 +1,47 @@...@@ -1,23 +1,47 @@
1# Release Checklist1# Release Checklist
22
3This document covers how to run desktop releases from one tag, first without signing, then with signing.3This document covers the unified release workflow for stable and nightly desktop releases.
44
5## What the workflow does5## What the workflow does
66
7- Trigger: push tag matching `v*.*.*`.7- Workflow: `.github/workflows/release.yml`
8- Triggers:
9 - push tag matching `v*.*.*` for stable releases
10 - scheduled nightly at `09:00 UTC`
11 - manual `workflow_dispatch` for either channel
8- Runs quality gates first: lint, typecheck, test.12- Runs quality gates first: lint, typecheck, test.
9- Builds four artifacts in parallel:13- Builds four artifacts in parallel for both channels:
10 - macOS `arm64` DMG14 - macOS `arm64` DMG
11 - macOS `x64` DMG15 - macOS `x64` DMG
12 - Linux `x64` AppImage16 - Linux `x64` AppImage
13 - Windows `x64` NSIS installer17 - Windows `x64` NSIS installer
14- Publishes one GitHub Release with all produced files.18- Publishes one GitHub Release with all produced files.
15 - Versions with a suffix after `X.Y.Z` (for example `1.2.3-alpha.1`) are published as GitHub prereleases.19 - Stable tags with a suffix after `X.Y.Z` (for example `1.2.3-alpha.1`) are published as GitHub prereleases.
16 - Only plain `X.Y.Z` releases are marked as the repository's latest release.20 - Only plain stable `X.Y.Z` releases are marked as the repository's latest release.
17- Includes Electron auto-update metadata (for example `latest*.yml` and `*.blockmap`) in release assets.21 - Nightly runs are always GitHub prereleases and never marked latest.
18- Publishes the CLI package (`apps/server`, npm package `t3`) with OIDC trusted publishing.22 - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag.
23- Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets.
24- Publishes the CLI package (`apps/server`, npm package `t3`) with OIDC trusted publishing from the same workflow file:
25 - stable releases publish npm dist-tag `latest`
26 - nightly releases publish npm dist-tag `nightly`
19- Signing is optional and auto-detected per platform from secrets.27- Signing is optional and auto-detected per platform from secrets.
2028
29## Nightly builds
30
31- Workflow: `.github/workflows/release.yml`
32- Triggers:
33 - scheduled every day at `09:00 UTC`
34 - manual `workflow_dispatch` with `channel=nightly`
35- Runs the same desktop quality gates and artifact matrix as the tagged release flow.
36- Publishes a GitHub prerelease only:
37 - tag format: `nightly-vX.Y.Z-nightly.YYYYMMDD.<run_number>`
38 - release name includes the short commit SHA
39 - `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.
41- Publishes Electron auto-update metadata to the dedicated `nightly` updater channel, so desktop users can opt into that track independently from stable.
42- Publishes the CLI package (`apps/server`, npm package `t3`) to the `nightly` npm dist-tag using the same nightly version.
43- Does not commit version bumps back to `main`.
44
21## Desktop auto-update notes45## Desktop auto-update notes
2246
23- Runtime updater: `electron-updater` in `apps/desktop/src/main.ts`.47- Runtime updater: `electron-updater` in `apps/desktop/src/main.ts`.
...@@ -34,15 +58,15 @@ This document covers how to run desktop releases from one tag, first without sig...@@ -34,15 +58,15 @@ This document covers how to run desktop releases from one tag, first without sig
34 - the app forwards it as an `Authorization: Bearer <token>` request header for updater HTTP calls.58 - the app forwards it as an `Authorization: Bearer <token>` request header for updater HTTP calls.
35- Required release assets for updater:59- Required release assets for updater:
36 - platform installers (`.exe`, `.dmg`, `.AppImage`, plus macOS `.zip` for Squirrel.Mac update payloads)60 - platform installers (`.exe`, `.dmg`, `.AppImage`, plus macOS `.zip` for Squirrel.Mac update payloads)
37 - `latest*.yml` metadata61 - channel metadata: `latest*.yml` for stable releases, `nightly*.yml` for nightly releases
38 - `*.blockmap` files (used for differential downloads)62 - `*.blockmap` files (used for differential downloads)
39- macOS metadata note:63- macOS metadata note:
40 - `electron-updater` reads `latest-mac.yml` for both Intel and Apple Silicon.64 - `electron-updater` reads `latest-mac.yml` on stable and `nightly-mac.yml` on nightly, for both Intel and Apple Silicon.
41 - The workflow merges the per-arch mac manifests into one `latest-mac.yml` before publishing the GitHub Release.65 - The workflow merges the per-arch mac manifests into one channel-specific mac manifest before publishing the GitHub Release.
4266
43## 0) npm OIDC trusted publishing setup (CLI)67## 0) npm OIDC trusted publishing setup (CLI)
4468
45The workflow publishes the CLI with `bun publish` from `apps/server` after bumping69The workflow publishes the CLI with `npm publish` from `apps/server` after bumping
46the package version to the release tag version.70the package version to the release tag version.
4771
48Checklist:72Checklist:
...@@ -57,7 +81,8 @@ Checklist:...@@ -57,7 +81,8 @@ Checklist:
574. Create release tag `vX.Y.Z` and push; workflow will:814. Create release tag `vX.Y.Z` and push; workflow will:
58 - set `apps/server/package.json` version to `X.Y.Z`82 - set `apps/server/package.json` version to `X.Y.Z`
59 - build web + server83 - build web + server
60 - run `bun publish --access public`84 - run `npm publish --access public --tag latest`
855. Nightly runs from the same workflow file publish with `npm publish --access public --tag nightly`.
6186
62## 1) Dry-run release without signing87## 1) Dry-run release without signing
6388
packages/contracts/src/ipc.ts+11
...@@ -73,6 +73,14 @@ export type DesktopUpdateStatus =...@@ -73,6 +73,14 @@ export type DesktopUpdateStatus =
7373
74export type DesktopRuntimeArch = "arm64" | "x64" | "other";74export type DesktopRuntimeArch = "arm64" | "x64" | "other";
75export type DesktopTheme = "light" | "dark" | "system";75export type DesktopTheme = "light" | "dark" | "system";
76export type DesktopUpdateChannel = "latest" | "nightly";
77export type DesktopAppStageLabel = "Alpha" | "Dev" | "Nightly";
78
79export interface DesktopAppBranding {
80 baseName: string;
81 stageLabel: DesktopAppStageLabel;
82 displayName: string;
83}
7684
77export interface DesktopRuntimeInfo {85export interface DesktopRuntimeInfo {
78 hostArch: DesktopRuntimeArch;86 hostArch: DesktopRuntimeArch;
...@@ -83,6 +91,7 @@ export interface DesktopRuntimeInfo {...@@ -83,6 +91,7 @@ export interface DesktopRuntimeInfo {
83export interface DesktopUpdateState {91export interface DesktopUpdateState {
84 enabled: boolean;92 enabled: boolean;
85 status: DesktopUpdateStatus;93 status: DesktopUpdateStatus;
94 channel: DesktopUpdateChannel;
86 currentVersion: string;95 currentVersion: string;
87 hostArch: DesktopRuntimeArch;96 hostArch: DesktopRuntimeArch;
88 appArch: DesktopRuntimeArch;97 appArch: DesktopRuntimeArch;
...@@ -132,6 +141,7 @@ export interface DesktopServerExposureState {...@@ -132,6 +141,7 @@ export interface DesktopServerExposureState {
132}141}
133142
134export interface DesktopBridge {143export interface DesktopBridge {
144 getAppBranding: () => DesktopAppBranding | null;
135 getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null;145 getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null;
136 getClientSettings: () => Promise<ClientSettings | null>;146 getClientSettings: () => Promise<ClientSettings | null>;
137 setClientSettings: (settings: ClientSettings) => Promise<void>;147 setClientSettings: (settings: ClientSettings) => Promise<void>;
...@@ -154,6 +164,7 @@ export interface DesktopBridge {...@@ -154,6 +164,7 @@ export interface DesktopBridge {
154 openExternal: (url: string) => Promise<boolean>;164 openExternal: (url: string) => Promise<boolean>;
155 onMenuAction: (listener: (action: string) => void) => () => void;165 onMenuAction: (listener: (action: string) => void) => () => void;
156 getUpdateState: () => Promise<DesktopUpdateState>;166 getUpdateState: () => Promise<DesktopUpdateState>;
167 setUpdateChannel: (channel: DesktopUpdateChannel) => Promise<DesktopUpdateState>;
157 checkForUpdate: () => Promise<DesktopUpdateCheckResult>;168 checkForUpdate: () => Promise<DesktopUpdateCheckResult>;
158 downloadUpdate: () => Promise<DesktopUpdateActionResult>;169 downloadUpdate: () => Promise<DesktopUpdateActionResult>;
159 installUpdate: () => Promise<DesktopUpdateActionResult>;170 installUpdate: () => Promise<DesktopUpdateActionResult>;
scripts/build-desktop-artifact.test.ts+51-1
...@@ -2,9 +2,59 @@ import * as NodeServices from "@effect/platform-node/NodeServices";...@@ -2,9 +2,59 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";2import { assert, it } from "@effect/vitest";
3import { ConfigProvider, Effect, Option } from "effect";3import { ConfigProvider, Effect, Option } from "effect";
44
5import { resolveBuildOptions } from "./build-desktop-artifact.ts";5import {
6 resolveBuildOptions,
7 resolveDesktopBuildIconAssets,
8 resolveDesktopUpdateChannel,
9 resolveMockUpdateServerPort,
10 resolveMockUpdateServerUrl,
11} from "./build-desktop-artifact.ts";
12import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";
613
7it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {14it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
15 it("resolves the dedicated nightly updater channel from nightly versions", () => {
16 assert.equal(resolveDesktopUpdateChannel("0.0.17-nightly.20260413.42"), "nightly");
17 assert.equal(resolveDesktopUpdateChannel("0.0.17"), "latest");
18 });
19
20 it("switches desktop packaging icons to the nightly artwork for nightly versions", () => {
21 assert.deepStrictEqual(resolveDesktopBuildIconAssets("0.0.17"), {
22 macIconPng: BRAND_ASSET_PATHS.productionMacIconPng,
23 linuxIconPng: BRAND_ASSET_PATHS.productionLinuxIconPng,
24 windowsIconIco: BRAND_ASSET_PATHS.productionWindowsIconIco,
25 });
26
27 assert.deepStrictEqual(resolveDesktopBuildIconAssets("0.0.17-nightly.20260413.42"), {
28 macIconPng: BRAND_ASSET_PATHS.nightlyMacIconPng,
29 linuxIconPng: BRAND_ASSET_PATHS.nightlyLinuxIconPng,
30 windowsIconIco: BRAND_ASSET_PATHS.nightlyWindowsIconIco,
31 });
32 });
33
34 it("falls back to the default mock update port when the configured port is blank", () => {
35 assert.equal(resolveMockUpdateServerUrl(undefined), "http://localhost:3000");
36 assert.equal(resolveMockUpdateServerUrl(4123), "http://localhost:4123");
37 });
38
39 it.effect("normalizes mock update server ports from env-style strings", () =>
40 Effect.gen(function* () {
41 assert.equal(yield* resolveMockUpdateServerPort(undefined), undefined);
42 assert.equal(yield* resolveMockUpdateServerPort(""), undefined);
43 assert.equal(yield* resolveMockUpdateServerPort(" "), undefined);
44 assert.equal(yield* resolveMockUpdateServerPort("4123"), 4123);
45 }),
46 );
47
48 it.effect("rejects non-numeric or out-of-range mock update ports", () =>
49 Effect.gen(function* () {
50 const invalidPorts = ["abc", "12.5", "0", "65536"];
51 for (const port of invalidPorts) {
52 const exit = yield* Effect.exit(resolveMockUpdateServerPort(port));
53 assert.equal(exit._tag, "Failure");
54 }
55 }),
56 );
57
8 it.effect("preserves explicit false boolean flags over true env defaults", () =>58 it.effect("preserves explicit false boolean flags over true env defaults", () =>
9 Effect.gen(function* () {59 Effect.gen(function* () {
10 const resolved = yield* resolveBuildOptions({60 const resolved = yield* resolveBuildOptions({
scripts/build-desktop-artifact.ts+101-49
...@@ -23,23 +23,14 @@ const BuildArch = Schema.Literals(["arm64", "x64", "universal"]);...@@ -23,23 +23,14 @@ const BuildArch = Schema.Literals(["arm64", "x64", "universal"]);
23const RepoRoot = Effect.service(Path.Path).pipe(23const RepoRoot = Effect.service(Path.Path).pipe(
24 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),24 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
25);25);
26const ProductionMacIconSource = Effect.zipWith(
27 RepoRoot,
28 Effect.service(Path.Path),
29 (repoRoot, path) => path.join(repoRoot, BRAND_ASSET_PATHS.productionMacIconPng),
30);
31const ProductionLinuxIconSource = Effect.zipWith(
32 RepoRoot,
33 Effect.service(Path.Path),
34 (repoRoot, path) => path.join(repoRoot, BRAND_ASSET_PATHS.productionLinuxIconPng),
35);
36const ProductionWindowsIconSource = Effect.zipWith(
37 RepoRoot,
38 Effect.service(Path.Path),
39 (repoRoot, path) => path.join(repoRoot, BRAND_ASSET_PATHS.productionWindowsIconIco),
40);
41const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString);26const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString);
4227
28interface DesktopBuildIconAssets {
29 readonly macIconPng: string;
30 readonly linuxIconPng: string;
31 readonly windowsIconIco: string;
32}
33
43interface PlatformConfig {34interface PlatformConfig {
44 readonly cliFlag: "--mac" | "--linux" | "--win";35 readonly cliFlag: "--mac" | "--linux" | "--win";
45 readonly defaultTarget: string;36 readonly defaultTarget: string;
...@@ -75,7 +66,7 @@ interface BuildCliInput {...@@ -75,7 +66,7 @@ interface BuildCliInput {
75 readonly signed: Option.Option<boolean>;66 readonly signed: Option.Option<boolean>;
76 readonly verbose: Option.Option<boolean>;67 readonly verbose: Option.Option<boolean>;
77 readonly mockUpdates: Option.Option<boolean>;68 readonly mockUpdates: Option.Option<boolean>;
78 readonly mockUpdateServerPort: Option.Option<string>;69 readonly mockUpdateServerPort: Option.Option<number>;
79}70}
8071
81function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined {72function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined {
...@@ -165,7 +156,7 @@ interface ResolvedBuildOptions {...@@ -165,7 +156,7 @@ interface ResolvedBuildOptions {
165 readonly signed: boolean;156 readonly signed: boolean;
166 readonly verbose: boolean;157 readonly verbose: boolean;
167 readonly mockUpdates: boolean;158 readonly mockUpdates: boolean;
168 readonly mockUpdateServerPort: string | undefined;159 readonly mockUpdateServerPort: number | undefined;
169}160}
170161
171interface StagePackageJson {162interface StagePackageJson {
...@@ -213,11 +204,28 @@ const BuildEnvConfig = Config.all({...@@ -213,11 +204,28 @@ const BuildEnvConfig = Config.all({
213 mockUpdateServerPort: Config.string("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.option),204 mockUpdateServerPort: Config.string("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.option),
214});205});
215206
207const MockUpdateServerPortSchema = Schema.NumberFromString.check(
208 Schema.isInt(),
209 Schema.isBetween({ minimum: 1, maximum: 65535 }),
210);
211const decodeMockUpdateServerPort = Schema.decodeUnknownEffect(MockUpdateServerPortSchema);
212
216const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>213const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>
217 Option.getOrElse(flag, () => envValue);214 Option.getOrElse(flag, () => envValue);
218const mergeOptions = <A>(a: Option.Option<A>, b: Option.Option<A>, defaultValue: A) =>215const mergeOptions = <A>(a: Option.Option<A>, b: Option.Option<A>, defaultValue: A) =>
219 Option.getOrElse(a, () => Option.getOrElse(b, () => defaultValue));216 Option.getOrElse(a, () => Option.getOrElse(b, () => defaultValue));
220217
218export const resolveMockUpdateServerPort = Effect.fn("resolveMockUpdateServerPort")(function* (
219 mockUpdateServerPort: string | undefined,
220) {
221 const port = mockUpdateServerPort?.trim();
222 if (!port) {
223 return undefined;
224 }
225
226 return yield* decodeMockUpdateServerPort(port);
227});
228
221export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (229export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (
222 input: BuildCliInput,230 input: BuildCliInput,
223) {231) {
...@@ -254,11 +262,17 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (...@@ -254,11 +262,17 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (
254 const verbose = resolveBooleanFlag(input.verbose, env.verbose);262 const verbose = resolveBooleanFlag(input.verbose, env.verbose);
255263
256 const mockUpdates = resolveBooleanFlag(input.mockUpdates, env.mockUpdates);264 const mockUpdates = resolveBooleanFlag(input.mockUpdates, env.mockUpdates);
257 const mockUpdateServerPort = mergeOptions(265 const mockUpdateServerPort =
258 input.mockUpdateServerPort,266 Option.getOrUndefined(input.mockUpdateServerPort) ??
259 env.mockUpdateServerPort,267 (yield* resolveMockUpdateServerPort(Option.getOrUndefined(env.mockUpdateServerPort)).pipe(
260 undefined,268 Effect.mapError(
261 );269 (cause) =>
270 new BuildScriptError({
271 message: "Invalid mock update server port.",
272 cause,
273 }),
274 ),
275 ));
262276
263 return {277 return {
264 platform,278 platform,
...@@ -329,14 +343,13 @@ function generateMacIconSet(...@@ -329,14 +343,13 @@ function generateMacIconSet(
329 });343 });
330}344}
331345
332function stageMacIcons(stageResourcesDir: string, verbose: boolean) {346function stageMacIcons(stageResourcesDir: string, sourcePng: string, verbose: boolean) {
333 return Effect.gen(function* () {347 return Effect.gen(function* () {
334 const fs = yield* FileSystem.FileSystem;348 const fs = yield* FileSystem.FileSystem;
335 const path = yield* Path.Path;349 const path = yield* Path.Path;
336 const iconSource = yield* ProductionMacIconSource;350 if (!(yield* fs.exists(sourcePng))) {
337 if (!(yield* fs.exists(iconSource))) {
338 return yield* new BuildScriptError({351 return yield* new BuildScriptError({
339 message: `Production icon source is missing at ${iconSource}`,352 message: `Desktop macOS icon source is missing at ${sourcePng}`,
340 });353 });
341 }354 }
342355
...@@ -350,42 +363,40 @@ function stageMacIcons(stageResourcesDir: string, verbose: boolean) {...@@ -350,42 +363,40 @@ function stageMacIcons(stageResourcesDir: string, verbose: boolean) {
350 yield* runCommand(363 yield* runCommand(
351 ChildProcess.make({364 ChildProcess.make({
352 ...commandOutputOptions(verbose),365 ...commandOutputOptions(verbose),
353 })`sips -z 512 512 ${iconSource} --out ${iconPngPath}`,366 })`sips -z 512 512 ${sourcePng} --out ${iconPngPath}`,
354 );367 );
355368
356 yield* generateMacIconSet(iconSource, iconIcnsPath, tmpRoot, path, verbose);369 yield* generateMacIconSet(sourcePng, iconIcnsPath, tmpRoot, path, verbose);
357 });370 });
358}371}
359372
360function stageLinuxIcons(stageResourcesDir: string) {373function stageLinuxIcons(stageResourcesDir: string, sourcePng: string) {
361 return Effect.gen(function* () {374 return Effect.gen(function* () {
362 const fs = yield* FileSystem.FileSystem;375 const fs = yield* FileSystem.FileSystem;
363 const path = yield* Path.Path;376 const path = yield* Path.Path;
364 const iconSource = yield* ProductionLinuxIconSource;377 if (!(yield* fs.exists(sourcePng))) {
365 if (!(yield* fs.exists(iconSource))) {
366 return yield* new BuildScriptError({378 return yield* new BuildScriptError({
367 message: `Production icon source is missing at ${iconSource}`,379 message: `Desktop Linux icon source is missing at ${sourcePng}`,
368 });380 });
369 }381 }
370382
371 const iconPath = path.join(stageResourcesDir, "icon.png");383 const iconPath = path.join(stageResourcesDir, "icon.png");
372 yield* fs.copyFile(iconSource, iconPath);384 yield* fs.copyFile(sourcePng, iconPath);
373 });385 });
374}386}
375387
376function stageWindowsIcons(stageResourcesDir: string) {388function stageWindowsIcons(stageResourcesDir: string, sourceIco: string) {
377 return Effect.gen(function* () {389 return Effect.gen(function* () {
378 const fs = yield* FileSystem.FileSystem;390 const fs = yield* FileSystem.FileSystem;
379 const path = yield* Path.Path;391 const path = yield* Path.Path;
380 const iconSource = yield* ProductionWindowsIconSource;392 if (!(yield* fs.exists(sourceIco))) {
381 if (!(yield* fs.exists(iconSource))) {
382 return yield* new BuildScriptError({393 return yield* new BuildScriptError({
383 message: `Production Windows icon source is missing at ${iconSource}`,394 message: `Desktop Windows icon source is missing at ${sourceIco}`,
384 });395 });
385 }396 }
386397
387 const iconPath = path.join(stageResourcesDir, "icon.ico");398 const iconPath = path.join(stageResourcesDir, "icon.ico");
388 yield* fs.copyFile(iconSource, iconPath);399 yield* fs.copyFile(sourceIco, iconPath);
389 });400 });
390}401}
391402
...@@ -441,12 +452,13 @@ function resolveDesktopRuntimeDependencies(...@@ -441,12 +452,13 @@ function resolveDesktopRuntimeDependencies(
441 return resolveCatalogDependencies(runtimeDependencies, catalog, "apps/desktop");452 return resolveCatalogDependencies(runtimeDependencies, catalog, "apps/desktop");
442}453}
443454
444function resolveGitHubPublishConfig():455function resolveGitHubPublishConfig(updateChannel: "latest" | "nightly"):
445 | {456 | {
446 readonly provider: "github";457 readonly provider: "github";
447 readonly owner: string;458 readonly owner: string;
448 readonly repo: string;459 readonly repo: string;
449 readonly releaseType: "release";460 readonly releaseType: "release" | "prerelease";
461 readonly channel?: "nightly";
450 }462 }
451 | undefined {463 | undefined {
452 const rawRepo =464 const rawRepo =
...@@ -462,17 +474,43 @@ function resolveGitHubPublishConfig():...@@ -462,17 +474,43 @@ function resolveGitHubPublishConfig():
462 provider: "github",474 provider: "github",
463 owner,475 owner,
464 repo,476 repo,
465 releaseType: "release",477 releaseType: updateChannel === "nightly" ? "prerelease" : "release",
478 ...(updateChannel === "nightly" ? { channel: "nightly" as const } : {}),
466 };479 };
467}480}
468481
482export function resolveDesktopUpdateChannel(version: string): "latest" | "nightly" {
483 return /-nightly\.\d{8}\.\d+$/.test(version) ? "nightly" : "latest";
484}
485
486export function resolveDesktopBuildIconAssets(version: string): DesktopBuildIconAssets {
487 if (resolveDesktopUpdateChannel(version) === "nightly") {
488 return {
489 macIconPng: BRAND_ASSET_PATHS.nightlyMacIconPng,
490 linuxIconPng: BRAND_ASSET_PATHS.nightlyLinuxIconPng,
491 windowsIconIco: BRAND_ASSET_PATHS.nightlyWindowsIconIco,
492 };
493 }
494
495 return {
496 macIconPng: BRAND_ASSET_PATHS.productionMacIconPng,
497 linuxIconPng: BRAND_ASSET_PATHS.productionLinuxIconPng,
498 windowsIconIco: BRAND_ASSET_PATHS.productionWindowsIconIco,
499 };
500}
501
502export function resolveMockUpdateServerUrl(mockUpdateServerPort: number | undefined): string {
503 return `http://localhost:${mockUpdateServerPort ?? 3000}`;
504}
505
469const createBuildConfig = Effect.fn("createBuildConfig")(function* (506const createBuildConfig = Effect.fn("createBuildConfig")(function* (
470 platform: typeof BuildPlatform.Type,507 platform: typeof BuildPlatform.Type,
471 target: string,508 target: string,
472 productName: string,509 productName: string,
510 version: string,
473 signed: boolean,511 signed: boolean,
474 mockUpdates: boolean,512 mockUpdates: boolean,
475 mockUpdateServerPort: string | undefined,513 mockUpdateServerPort: number | undefined,
476) {514) {
477 const buildConfig: Record<string, unknown> = {515 const buildConfig: Record<string, unknown> = {
478 appId: "com.t3tools.t3code",516 appId: "com.t3tools.t3code",
...@@ -482,14 +520,15 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* (...@@ -482,14 +520,15 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* (
482 buildResources: "apps/desktop/resources",520 buildResources: "apps/desktop/resources",
483 },521 },
484 };522 };
485 const publishConfig = resolveGitHubPublishConfig();523 const updateChannel = resolveDesktopUpdateChannel(version);
524 const publishConfig = resolveGitHubPublishConfig(updateChannel);
486 if (publishConfig) {525 if (publishConfig) {
487 buildConfig.publish = [publishConfig];526 buildConfig.publish = [publishConfig];
488 } else if (mockUpdates) {527 } else if (mockUpdates) {
489 buildConfig.publish = [528 buildConfig.publish = [
490 {529 {
491 provider: "generic",530 provider: "generic",
492 url: `http://localhost:${mockUpdateServerPort ?? 3000}`,531 url: resolveMockUpdateServerUrl(mockUpdateServerPort),
493 },532 },
494 ];533 ];
495 }534 }
...@@ -533,20 +572,21 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* (...@@ -533,20 +572,21 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* (
533const assertPlatformBuildResources = Effect.fn("assertPlatformBuildResources")(function* (572const assertPlatformBuildResources = Effect.fn("assertPlatformBuildResources")(function* (
534 platform: typeof BuildPlatform.Type,573 platform: typeof BuildPlatform.Type,
535 stageResourcesDir: string,574 stageResourcesDir: string,
575 iconAssets: DesktopBuildIconAssets,
536 verbose: boolean,576 verbose: boolean,
537) {577) {
538 if (platform === "mac") {578 if (platform === "mac") {
539 yield* stageMacIcons(stageResourcesDir, verbose);579 yield* stageMacIcons(stageResourcesDir, iconAssets.macIconPng, verbose);
540 return;580 return;
541 }581 }
542582
543 if (platform === "linux") {583 if (platform === "linux") {
544 yield* stageLinuxIcons(stageResourcesDir);584 yield* stageLinuxIcons(stageResourcesDir, iconAssets.linuxIconPng);
545 return;585 return;
546 }586 }
547587
548 if (platform === "win") {588 if (platform === "win") {
549 yield* stageWindowsIcons(stageResourcesDir);589 yield* stageWindowsIcons(stageResourcesDir, iconAssets.windowsIconIco);
550 }590 }
551});591});
552592
...@@ -614,6 +654,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -614,6 +654,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
614 });654 });
615655
616 const appVersion = options.version ?? serverPackageJson.version;656 const appVersion = options.version ?? serverPackageJson.version;
657 const iconAssets = resolveDesktopBuildIconAssets(appVersion);
617 const commitHash = resolveGitCommitHash(repoRoot);658 const commitHash = resolveGitCommitHash(repoRoot);
618 const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped;659 const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped;
619 const stageRoot = yield* mkdir({660 const stageRoot = yield* mkdir({
...@@ -665,7 +706,16 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -665,7 +706,16 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
665 yield* fs.copy(distDirs.desktopResources, stageResourcesDir);706 yield* fs.copy(distDirs.desktopResources, stageResourcesDir);
666 yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"));707 yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"));
667708
668 yield* assertPlatformBuildResources(options.platform, stageResourcesDir, options.verbose);709 yield* assertPlatformBuildResources(
710 options.platform,
711 stageResourcesDir,
712 {
713 macIconPng: join(repoRoot, iconAssets.macIconPng),
714 linuxIconPng: join(repoRoot, iconAssets.linuxIconPng),
715 windowsIconIco: join(repoRoot, iconAssets.windowsIconIco),
716 },
717 options.verbose,
718 );
669719
670 // electron-builder is filtering out stageResourcesDir directory in the AppImage for production720 // electron-builder is filtering out stageResourcesDir directory in the AppImage for production
671 yield* fs.copy(stageResourcesDir, path.join(stageAppDir, "apps/desktop/prod-resources"));721 yield* fs.copy(stageResourcesDir, path.join(stageAppDir, "apps/desktop/prod-resources"));
...@@ -683,6 +733,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -683,6 +733,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
683 options.platform,733 options.platform,
684 options.target,734 options.target,
685 desktopPackageJson.productName ?? "T3 Code",735 desktopPackageJson.productName ?? "T3 Code",
736 appVersion,
686 options.signed,737 options.signed,
687 options.mockUpdates,738 options.mockUpdates,
688 options.mockUpdateServerPort,739 options.mockUpdateServerPort,
...@@ -829,7 +880,8 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", {...@@ -829,7 +880,8 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", {
829 Flag.withDescription("Enable mock updates (env: T3CODE_DESKTOP_MOCK_UPDATES)."),880 Flag.withDescription("Enable mock updates (env: T3CODE_DESKTOP_MOCK_UPDATES)."),
830 Flag.optional,881 Flag.optional,
831 ),882 ),
832 mockUpdateServerPort: Flag.string("mock-update-server-port").pipe(883 mockUpdateServerPort: Flag.integer("mock-update-server-port").pipe(
884 Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))),
833 Flag.withDescription("Mock update server port (env: T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT)."),885 Flag.withDescription("Mock update server port (env: T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT)."),
834 Flag.optional,886 Flag.optional,
835 ),887 ),
scripts/lib/brand-assets.ts+6
...@@ -6,6 +6,12 @@ export const BRAND_ASSET_PATHS = {...@@ -6,6 +6,12 @@ export const BRAND_ASSET_PATHS = {
6 productionWebFavicon16Png: "assets/prod/t3-black-web-favicon-16x16.png",6 productionWebFavicon16Png: "assets/prod/t3-black-web-favicon-16x16.png",
7 productionWebFavicon32Png: "assets/prod/t3-black-web-favicon-32x32.png",7 productionWebFavicon32Png: "assets/prod/t3-black-web-favicon-32x32.png",
8 productionWebAppleTouchIconPng: "assets/prod/t3-black-web-apple-touch-180.png",8 productionWebAppleTouchIconPng: "assets/prod/t3-black-web-apple-touch-180.png",
9
10 nightlyMacIconPng: "assets/nightly/blueprint-macos-1024.png",
11 nightlyLinuxIconPng: "assets/nightly/blueprint-universal-1024.png",
12 nightlyWindowsIconIco: "assets/nightly/blueprint-windows.ico",
13
14 developmentDesktopIconPng: "assets/dev/blueprint-macos-1024.png",
9 developmentWindowsIconIco: "assets/dev/blueprint-windows.ico",15 developmentWindowsIconIco: "assets/dev/blueprint-windows.ico",
10 developmentWebFaviconIco: "assets/dev/blueprint-web-favicon.ico",16 developmentWebFaviconIco: "assets/dev/blueprint-web-favicon.ico",
11 developmentWebFavicon16Png: "assets/dev/blueprint-web-favicon-16x16.png",17 developmentWebFavicon16Png: "assets/dev/blueprint-web-favicon-16x16.png",
scripts/release-smoke.ts+35-1
...@@ -95,7 +95,7 @@ try {...@@ -95,7 +95,7 @@ try {
95 },95 },
96 );96 );
9797
98 execFileSync("bun", ["install", "--lockfile-only", "--ignore-scripts"], {98 execFileSync("bun", ["install", "--ignore-scripts"], {
99 cwd: tempRoot,99 cwd: tempRoot,
100 stdio: "inherit",100 stdio: "inherit",
101 });101 });
...@@ -107,6 +107,40 @@ try {...@@ -107,6 +107,40 @@ try {
107 "Expected bun.lock to contain the smoke version.",107 "Expected bun.lock to contain the smoke version.",
108 );108 );
109109
110 const nightlyReleaseMetadata = execFileSync(
111 process.execPath,
112 [
113 resolve(repoRoot, "scripts/resolve-nightly-release.ts"),
114 "--date",
115 "20260413",
116 "--run-number",
117 "321",
118 "--sha",
119 "abcdef1234567890",
120 "--root",
121 tempRoot,
122 ],
123 {
124 cwd: repoRoot,
125 encoding: "utf8",
126 },
127 );
128 assertContains(
129 nightlyReleaseMetadata,
130 "version=9.9.9-nightly.20260413.321",
131 "Expected nightly metadata to contain the derived nightly version.",
132 );
133 assertContains(
134 nightlyReleaseMetadata,
135 "tag=nightly-v9.9.9-nightly.20260413.321",
136 "Expected nightly metadata to contain the derived nightly tag.",
137 );
138 assertContains(
139 nightlyReleaseMetadata,
140 "name=T3 Code Nightly 9.9.9-nightly.20260413.321 (abcdef123456)",
141 "Expected nightly metadata to include the short commit SHA in the release name.",
142 );
143
110 const { arm64Path, x64Path } = writeMacManifestFixtures(tempRoot);144 const { arm64Path, x64Path } = writeMacManifestFixtures(tempRoot);
111 execFileSync(145 execFileSync(
112 process.execPath,146 process.execPath,
scripts/resolve-nightly-release.test.ts created+25
...@@ -0,0 +1,25 @@
1import { assert, it } from "@effect/vitest";
2
3import {
4 resolveNightlyBaseVersion,
5 resolveNightlyReleaseMetadata,
6} from "./resolve-nightly-release.ts";
7
8it("strips prerelease and build metadata when deriving the nightly base version", () => {
9 assert.equal(resolveNightlyBaseVersion("0.0.17"), "0.0.17");
10 assert.equal(resolveNightlyBaseVersion("9.9.9-smoke.0"), "9.9.9");
11 assert.equal(resolveNightlyBaseVersion("1.2.3-beta.4+build.9"), "1.2.3");
12});
13
14it("derives nightly metadata including the short commit sha in the release name", () => {
15 assert.deepStrictEqual(
16 resolveNightlyReleaseMetadata("9.9.9", "20260413", 321, "abcdef1234567890"),
17 {
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)",
22 shortSha: "abcdef123456",
23 },
24 );
25});
scripts/resolve-nightly-release.ts created+126
...@@ -0,0 +1,126 @@
1#!/usr/bin/env node
2
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { Config, Effect, FileSystem, Option, Path, Schema } from "effect";
6import { Command, Flag } from "effect/unstable/cli";
7
8interface NightlyReleaseMetadata {
9 readonly baseVersion: string;
10 readonly version: string;
11 readonly tag: string;
12 readonly name: string;
13 readonly shortSha: string;
14}
15
16const DateSchema = Schema.String.check(Schema.isPattern(/^\d{8}$/));
17const RunNumberSchema = Schema.FiniteFromString.check(
18 Schema.isInt(),
19 Schema.isGreaterThanOrEqualTo(1),
20);
21const ShaSchema = Schema.String.check(Schema.isPattern(/^[0-9a-f]{7,40}$/i));
22const DesktopPackageJsonSchema = Schema.Struct({
23 version: Schema.NonEmptyString,
24});
25
26const RepoRoot = Effect.service(Path.Path).pipe(
27 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
28);
29const decodeDesktopPackageJson = Schema.decodeUnknownEffect(
30 Schema.fromJsonString(DesktopPackageJsonSchema),
31);
32
33export const resolveNightlyBaseVersion = (version: string) => version.replace(/[-+].*$/, "");
34
35export const resolveNightlyReleaseMetadata = (
36 baseVersion: string,
37 date: string,
38 runNumber: number,
39 sha: string,
40) => {
41 const shortSha = sha.slice(0, 12);
42 const version = `${baseVersion}-nightly.${date}.${runNumber}`;
43 return {
44 baseVersion,
45 version,
46 tag: `nightly-v${version}`,
47 name: `T3 Code Nightly ${version} (${shortSha})`,
48 shortSha,
49 };
50};
51
52const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(function* (
53 rootDir: string | undefined,
54) {
55 const fs = yield* FileSystem.FileSystem;
56 const path = yield* Path.Path;
57 const workspaceRoot = rootDir ? path.resolve(rootDir) : yield* RepoRoot;
58 const packageJsonPath = path.join(workspaceRoot, "apps/desktop/package.json");
59 const packageJson = yield* fs
60 .readFileString(packageJsonPath)
61 .pipe(Effect.flatMap(decodeDesktopPackageJson));
62 return resolveNightlyBaseVersion(packageJson.version);
63});
64
65const writeOutput = Effect.fn("writeOutput")(function* (
66 metadata: NightlyReleaseMetadata,
67 writeGithubOutput: boolean,
68) {
69 const fs = yield* FileSystem.FileSystem;
70
71 const entries = [
72 ["base_version", metadata.baseVersion],
73 ["version", metadata.version],
74 ["tag", metadata.tag],
75 ["name", metadata.name],
76 ["short_sha", metadata.shortSha],
77 ] as const;
78
79 if (writeGithubOutput) {
80 const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT");
81 const serialized = entries.map(([key, value]) => `${key}=${value}\n`).join("");
82 yield* fs.writeFileString(githubOutputPath, serialized, { flag: "a" });
83 } else {
84 for (const [key, value] of entries) {
85 console.log(`${key}=${value}`);
86 }
87 }
88});
89
90const command = Command.make(
91 "resolve-nightly-release",
92 {
93 date: Flag.string("date").pipe(
94 Flag.withSchema(DateSchema),
95 Flag.withDescription("Nightly build date in YYYYMMDD."),
96 ),
97 runNumber: Flag.string("run-number").pipe(
98 Flag.withSchema(RunNumberSchema),
99 Flag.withDescription("GitHub Actions run number."),
100 ),
101 sha: Flag.string("sha").pipe(
102 Flag.withSchema(ShaSchema),
103 Flag.withDescription("Commit sha for the nightly build."),
104 ),
105 githubOutput: Flag.boolean("github-output").pipe(
106 Flag.withDescription("Write values to GITHUB_OUTPUT instead of stdout."),
107 Flag.withDefault(false),
108 ),
109 root: Flag.string("root").pipe(
110 Flag.withDescription("Workspace root used to resolve apps/desktop/package.json."),
111 Flag.optional,
112 ),
113 },
114 ({ date, runNumber, sha, githubOutput, root }) =>
115 readDesktopBaseVersion(Option.getOrUndefined(root)).pipe(
116 Effect.map((baseVersion) => resolveNightlyReleaseMetadata(baseVersion, date, runNumber, sha)),
117 Effect.flatMap((metadata) => writeOutput(metadata, githubOutput)),
118 ),
119).pipe(Command.withDescription("Resolve nightly release version metadata."));
120
121if (import.meta.main) {
122 Command.run(command, { version: "0.0.0" }).pipe(
123 Effect.provide(NodeServices.layer),
124 NodeRuntime.runMain,
125 );
126}
scripts/resolve-previous-release-tag.ts created+203
...@@ -0,0 +1,203 @@
1#!/usr/bin/env node
2
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { Array, Config, Effect, FileSystem, Schema, Stream, String } from "effect";
6import { Command, Flag } from "effect/unstable/cli";
7import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
8
9const ReleaseChannel = Schema.Literals(["stable", "nightly"]);
10type ReleaseChannel = typeof ReleaseChannel.Type;
11
12interface StableVersion {
13 readonly major: number;
14 readonly minor: number;
15 readonly patch: number;
16 readonly prerelease: ReadonlyArray<string>;
17}
18
19interface NightlyVersion {
20 readonly major: number;
21 readonly minor: number;
22 readonly patch: number;
23 readonly date: number;
24 readonly runNumber: number;
25}
26
27const parseNumericIdentifier = (identifier: string): number | undefined =>
28 /^\d+$/.test(identifier) ? Number(identifier) : undefined;
29
30const comparePrereleaseIdentifiers = (left: string, right: string): number => {
31 const leftNumeric = parseNumericIdentifier(left);
32 const rightNumeric = parseNumericIdentifier(right);
33
34 if (leftNumeric !== undefined && rightNumeric !== undefined) {
35 return leftNumeric - rightNumeric;
36 }
37 if (leftNumeric !== undefined) {
38 return -1;
39 }
40 if (rightNumeric !== undefined) {
41 return 1;
42 }
43 return left.localeCompare(right);
44};
45
46const compareStableVersions = (left: StableVersion, right: StableVersion): number => {
47 if (left.major !== right.major) return left.major - right.major;
48 if (left.minor !== right.minor) return left.minor - right.minor;
49 if (left.patch !== right.patch) return left.patch - right.patch;
50
51 const leftHasPrerelease = left.prerelease.length > 0;
52 const rightHasPrerelease = right.prerelease.length > 0;
53 if (!leftHasPrerelease && !rightHasPrerelease) return 0;
54 if (!leftHasPrerelease) return 1;
55 if (!rightHasPrerelease) return -1;
56
57 const maxLength = Math.max(left.prerelease.length, right.prerelease.length);
58 for (let index = 0; index < maxLength; index += 1) {
59 const leftIdentifier = left.prerelease[index];
60 const rightIdentifier = right.prerelease[index];
61 if (leftIdentifier === undefined) return -1;
62 if (rightIdentifier === undefined) return 1;
63
64 const comparison = comparePrereleaseIdentifiers(leftIdentifier, rightIdentifier);
65 if (comparison !== 0) return comparison;
66 }
67
68 return 0;
69};
70
71const parseStableTag = (tag: string): StableVersion | undefined => {
72 const match = /^v(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(tag);
73 if (!match) return undefined;
74
75 const [, major, minor, patch, prerelease] = match;
76 if (!major || !minor || !patch) return undefined;
77
78 return {
79 major: Number(major),
80 minor: Number(minor),
81 patch: Number(patch),
82 prerelease: prerelease ? prerelease.split(".") : [],
83 };
84};
85
86const compareNightlyVersions = (left: NightlyVersion, right: NightlyVersion): number => {
87 if (left.major !== right.major) return left.major - right.major;
88 if (left.minor !== right.minor) return left.minor - right.minor;
89 if (left.patch !== right.patch) return left.patch - right.patch;
90 if (left.date !== right.date) return left.date - right.date;
91 return left.runNumber - right.runNumber;
92};
93
94const parseNightlyTag = (tag: string): NightlyVersion | undefined => {
95 const match = /^nightly-v(\d+)\.(\d+)\.(\d+)-nightly\.(\d{8})\.(\d+)$/.exec(tag);
96 if (!match) return undefined;
97
98 const [, major, minor, patch, date, runNumber] = match;
99 if (!major || !minor || !patch || !date || !runNumber) return undefined;
100
101 return {
102 major: Number(major),
103 minor: Number(minor),
104 patch: Number(patch),
105 date: Number(date),
106 runNumber: Number(runNumber),
107 };
108};
109
110const resolvePreviousReleaseTag = (
111 channel: ReleaseChannel,
112 currentTag: string,
113 tags: ReadonlyArray<string>,
114): string | undefined => {
115 if (channel === "stable") {
116 const current = parseStableTag(currentTag);
117 if (!current) {
118 throw new Error(`Invalid stable release tag '${currentTag}'.`);
119 }
120
121 const candidates = tags
122 .map((tag) => ({ tag, parsed: parseStableTag(tag) }))
123 .filter(
124 (entry): entry is { tag: string; parsed: StableVersion } => entry.parsed !== undefined,
125 )
126 .filter((entry) => compareStableVersions(entry.parsed, current) < 0)
127 .toSorted((left, right) => compareStableVersions(right.parsed, left.parsed));
128
129 return candidates[0]?.tag;
130 }
131
132 const current = parseNightlyTag(currentTag);
133 if (!current) {
134 throw new Error(`Invalid nightly release tag '${currentTag}'.`);
135 }
136
137 const candidates = tags
138 .map((tag) => ({ tag, parsed: parseNightlyTag(tag) }))
139 .filter((entry): entry is { tag: string; parsed: NightlyVersion } => entry.parsed !== undefined)
140 .filter((entry) => compareNightlyVersions(entry.parsed, current) < 0)
141 .toSorted((left, right) => compareNightlyVersions(right.parsed, left.parsed));
142
143 return candidates[0]?.tag;
144};
145
146const listGitTags = Effect.fn("listGitTags")(function* () {
147 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
148 const child = yield* spawner.spawn(ChildProcess.make("git", ["tag", "--list"]));
149 const tags = yield* child.stdout.pipe(
150 Stream.decodeText(),
151 Stream.runFold(
152 () => "",
153 (acc, chunk) => acc + chunk,
154 ),
155 Effect.map(String.split(/\r?\n/)),
156 Effect.map(Array.map(String.trim)),
157 Effect.map(Array.filter(String.isNonEmpty)),
158 );
159 return tags;
160});
161
162const writeOutput = Effect.fn("writeOutput")(function* (
163 previousTag: string | undefined,
164 writeGithubOutput: boolean,
165) {
166 const entry = `previous_tag=${previousTag ?? ""}\n`;
167
168 if (writeGithubOutput) {
169 const fs = yield* FileSystem.FileSystem;
170 const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT");
171 yield* fs.writeFileString(githubOutputPath, entry, { flag: "a" });
172 return;
173 }
174
175 process.stdout.write(entry);
176});
177
178const command = Command.make(
179 "resolve-previous-release-tag",
180 {
181 channel: Flag.choice("channel", ReleaseChannel.literals).pipe(
182 Flag.withDescription("Release channel whose previous tag should be resolved."),
183 ),
184 currentTag: Flag.string("current-tag").pipe(
185 Flag.withDescription("Current release tag to compare against."),
186 ),
187 githubOutput: Flag.boolean("github-output").pipe(
188 Flag.withDescription("Write values to GITHUB_OUTPUT instead of stdout."),
189 Flag.withDefault(false),
190 ),
191 },
192 ({ channel, currentTag, githubOutput }) =>
193 listGitTags().pipe(
194 Effect.map((tags) => resolvePreviousReleaseTag(channel, currentTag, tags)),
195 Effect.flatMap((previousTag) => writeOutput(previousTag, githubOutput)),
196 ),
197).pipe(Command.withDescription("Resolve the previous release tag for a stable or nightly series."));
198
199Command.run(command, { version: "0.0.0" }).pipe(
200 Effect.scoped,
201 Effect.provide(NodeServices.layer),
202 NodeRuntime.runMain,
203);