From 7020afea4e20fbf5f557dd5cc81687c39ab08e18 Mon Sep 17 00:00:00 2001 From: tarik02 Date: Tue, 4 Aug 2026 11:56:25 +0000 Subject: [PATCH 1/2] feat(release): add canary channel --- .github/workflows/nix-pnpm-deps.yml | 1 + .github/workflows/release.yml | 31 +++- FORK.md | 13 +- .../app/DesktopEarlyElectronStartup.test.ts | 7 + .../src/app/DesktopEarlyElectronStartup.ts | 31 ++-- apps/desktop/src/app/DesktopEnvironment.ts | 39 +++-- .../src/app/DesktopPreReadyPlatform.ts | 1 + .../src/settings/DesktopAppSettings.test.ts | 2 +- apps/desktop/src/updates/DesktopUpdates.ts | 2 +- apps/desktop/src/updates/updateChannels.ts | 13 +- apps/server/src/cloud/cliAuthHtml.ts | 11 +- apps/server/vite.config.ts | 6 +- apps/web/src/branding.logic.ts | 17 ++- apps/web/src/branding.ts | 12 +- .../src/components/SidebarStageBackdrop.tsx | 28 +++- .../components/settings/SettingsPanels.tsx | 14 +- .../components/sidebar/SidebarUpdatePill.tsx | 4 +- apps/web/src/hooks/useSettings.ts | 6 +- apps/web/src/hostedPairing.ts | 7 +- apps/web/vercel.ts | 18 ++- docs/operations/release.md | 133 ++++++++---------- packages/contracts/src/ipc.ts | 8 +- packages/ssh/src/command.ts | 2 +- scripts/build-desktop-artifact.ts | 38 +++-- scripts/lib/brand-assets.ts | 8 +- scripts/resolve-nightly-release.ts | 22 ++- scripts/resolve-previous-release-tag.ts | 43 +++--- 27 files changed, 333 insertions(+), 184 deletions(-) diff --git a/.github/workflows/nix-pnpm-deps.yml b/.github/workflows/nix-pnpm-deps.yml index f185e414ab..a724217330 100644 --- a/.github/workflows/nix-pnpm-deps.yml +++ b/.github/workflows/nix-pnpm-deps.yml @@ -5,6 +5,7 @@ on: branches: - main - master + - canary permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4883a1f16b..053a583258 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - canary workflow_dispatch: inputs: channel: @@ -14,6 +15,7 @@ on: options: - stable - nightly + - canary version: description: "Release version (for example 1.2.3 or v1.2.3)" required: false @@ -23,6 +25,10 @@ permissions: contents: read id-token: none +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: ${{ github.ref == 'refs/heads/canary' }} + jobs: preflight: name: Preflight @@ -140,16 +146,22 @@ jobs: echo "is_prerelease=false" >> "$GITHUB_OUTPUT" echo "make_latest=true" >> "$GITHUB_OUTPUT" else - nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" + prerelease_channel="nightly" + if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/canary" ]] || + [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "canary" ]]; then + prerelease_channel="canary" + fi + prerelease_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" node scripts/resolve-nightly-release.ts \ - --date "$nightly_date" \ + --channel "$prerelease_channel" \ + --date "$prerelease_date" \ --run-number "$NIGHTLY_RUN_NUMBER" \ --sha "$NIGHTLY_SHA" \ --github-output - echo "release_channel=nightly" >> "$GITHUB_OUTPUT" - echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT" + echo "release_channel=$prerelease_channel" >> "$GITHUB_OUTPUT" + echo "cli_dist_tag=$prerelease_channel" >> "$GITHUB_OUTPUT" echo "is_prerelease=true" >> "$GITHUB_OUTPUT" echo "make_latest=false" >> "$GITHUB_OUTPUT" fi @@ -665,7 +677,7 @@ jobs: # Enable if Windows arm64 builds are enabled. # Windows updater metadata is channel-specific (for example - # "latest.yml" or "nightly.yml"). Suffix each per-arch copy so the + # "latest.yml", "nightly.yml", or "canary.yml"). Suffix each per-arch copy so the # release job can merge matching arm64/x64 manifests back into one # canonical manifest per channel. # if [[ "${{ matrix.platform }}" == "win" ]]; then @@ -713,7 +725,7 @@ jobs: T3CODE_CLERK_JWT_TEMPLATE: "" T3CODE_RELAY_URL: "" VITE_HOSTED_STATIC_APP: "true" - VITE_HOSTED_APP_CHANNEL: ${{ needs.preflight.outputs.release_channel == 'stable' && 'latest' || 'nightly' }} + VITE_HOSTED_APP_CHANNEL: ${{ needs.preflight.outputs.release_channel == 'stable' && 'latest' || needs.preflight.outputs.release_channel }} steps: - name: Checkout uses: actions/checkout@v6 @@ -973,6 +985,7 @@ jobs: T3CODE_WEB_ROUTER_URL: ${{ vars.T3CODE_WEB_ROUTER_URL }} T3CODE_WEB_LATEST_DOMAIN: ${{ vars.T3CODE_WEB_LATEST_DOMAIN }} T3CODE_WEB_NIGHTLY_DOMAIN: ${{ vars.T3CODE_WEB_NIGHTLY_DOMAIN }} + T3CODE_WEB_CANARY_DOMAIN: ${{ vars.T3CODE_WEB_CANARY_DOMAIN }} VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} steps: - name: Checkout @@ -1013,6 +1026,7 @@ jobs: router_url="${T3CODE_WEB_ROUTER_URL:-https://app.t3.codes}" latest_domain="${T3CODE_WEB_LATEST_DOMAIN:-latest.app.t3.codes}" nightly_domain="${T3CODE_WEB_NIGHTLY_DOMAIN:-nightly.app.t3.codes}" + canary_domain="${T3CODE_WEB_CANARY_DOMAIN:-canary.app.t3.codes}" router_domain="${router_url#http://}" router_domain="${router_domain#https://}" router_domain="${router_domain%%/*}" @@ -1020,9 +1034,12 @@ jobs: if [[ "${{ needs.preflight.outputs.release_channel }}" == "stable" ]]; then channel_domain="$latest_domain" channel_name="latest" - else + elif [[ "${{ needs.preflight.outputs.release_channel }}" == "nightly" ]]; then channel_domain="$nightly_domain" channel_name="nightly" + else + channel_domain="$canary_domain" + channel_name="canary" fi vercel_scope="${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}" diff --git a/FORK.md b/FORK.md index 8cd6d95466..3216ba7a35 100644 --- a/FORK.md +++ b/FORK.md @@ -39,7 +39,9 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork ### Release And CI -- Fork workflows create/update a daily stable release PR while main-branch pushes produce nightly releases. +- Fork workflows create/update a daily stable release PR, main-branch pushes produce nightly releases, and canary-branch pushes produce canary releases. +- Canary releases use immutable `X.Y.Z-canary.YYYYMMDD.RUN` versions and GitHub prereleases without changing stable package versions. +- The Nix dependency hash workflow checks both `main` and `canary`, and targets repair PRs back to the branch that drifted. - Stable release PRs list every commit since the previous stable tag, including commits brought in by upstream merges. - Main-branch pushes update the stable release PR immediately when the committed package version is already tagged. - Stable-version pushes wait for the matching release to finish so tag-based version resolution advances past the published version. @@ -57,13 +59,14 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork ### Nix Package - The fork exposes the server and web bundle as an `x86_64-linux` flake package. -- The package version follows the server manifest by default and supports the generated nightly version override. -- Main-branch pushes verify the pnpm dependency hash, open or update a repair PR when it drifts, and fail the source workflow run. +- The package version follows the server manifest by default and supports generated nightly and canary version overrides. +- Main- and canary-branch pushes verify the pnpm dependency hash, open or update a repair PR against the affected branch when it drifts, and fail the source workflow run. ### Desktop Updater Channels -- Stable builds use `latest`; nightly builds use `nightly`. -- Nightly detection accepts fork release metadata while preserving the upstream channel split. +- Stable builds use `latest`, nightly builds use `nightly`, and canary builds use `canary`. +- Nightly and canary detection accepts fork release metadata while preserving separate updater feeds. +- Canary desktop builds use distinct branding and isolated `~/.t3/canary` and `t3code-canary` application state. ### Fork Persistence diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts index b7647b5cc1..0f2c2937a3 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts @@ -12,6 +12,7 @@ describe("DesktopEarlyElectronStartup", () => { it("reads the persisted linux password-store preference before Electron is ready", () => { const preference = resolveEarlyLinuxPasswordStorePreference({ + appVersion: "1.2.3", env: { T3CODE_HOME: "/home/user/.t3-test" }, homeDirectory: "/home/user", joinPath, @@ -26,6 +27,7 @@ describe("DesktopEarlyElectronStartup", () => { it("accepts JSONC in the early desktop settings file", () => { const preference = resolveEarlyLinuxPasswordStorePreference({ + appVersion: "1.2.3", env: { T3CODE_HOME: "/home/user/.t3-test" }, homeDirectory: "/home/user", joinPath, @@ -40,6 +42,7 @@ describe("DesktopEarlyElectronStartup", () => { it("falls back to auto when the early settings document is missing or invalid", () => { const preference = resolveEarlyLinuxPasswordStorePreference({ + appVersion: "1.2.3", env: {}, homeDirectory: "/home/user", joinPath, @@ -53,6 +56,7 @@ describe("DesktopEarlyElectronStartup", () => { it("preserves absolute root paths when resolving early settings", () => { const preference = resolveEarlyLinuxPasswordStorePreference({ + appVersion: "1.2.3", env: { T3CODE_HOME: "/" }, homeDirectory: "/home/user", joinPath, @@ -67,6 +71,7 @@ describe("DesktopEarlyElectronStartup", () => { it("resolves the early linux Electron switches", () => { const options = resolveEarlyLinuxElectronOptions({ + appVersion: "1.2.3", env: { T3CODE_HOME: "/home/user/.t3-test", XDG_CURRENT_DESKTOP: "niri", @@ -88,6 +93,7 @@ describe("DesktopEarlyElectronStartup", () => { it("keeps implicit development state under ~/.t3/dev when T3CODE_HOME is unset", () => { const preference = resolveEarlyLinuxPasswordStorePreference({ + appVersion: "1.2.3", env: { VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", }, @@ -104,6 +110,7 @@ describe("DesktopEarlyElectronStartup", () => { it("treats whitespace-only T3CODE_HOME as unconfigured in development", () => { const preference = resolveEarlyLinuxPasswordStorePreference({ + appVersion: "1.2.3", env: { T3CODE_HOME: " ", VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts index 3e11d7961a..e6667561fa 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts @@ -9,6 +9,7 @@ import { type LinuxPasswordStoreSwitch, type LinuxPasswordStorePreference, } from "../linuxSecretStorage.ts"; +import { isCanaryDesktopVersion } from "../updates/updateChannels.ts"; import { resolveDesktopBaseDir, resolveDesktopStateDir, @@ -16,6 +17,7 @@ import { } from "./DesktopStatePaths.ts"; interface EarlyDesktopSettingsInput { + readonly appVersion: string; readonly env: NodeJS.ProcessEnv; readonly homeDirectory: string; readonly joinPath: JoinPath; @@ -44,23 +46,23 @@ const decodeEarlyDesktopSettingsJson = Schema.decodeSync(EarlyDesktopSettingsJso const isDevelopmentEnvironment = (env: NodeJS.ProcessEnv): boolean => trimNonEmpty(env.VITE_DEV_SERVER_URL) !== null; -function resolveEarlyDesktopSettingsPath(input: { - readonly env: NodeJS.ProcessEnv; - readonly homeDirectory: string; - readonly joinPath: JoinPath; -}): string { +function resolveEarlyDesktopSettingsPath(input: EarlyDesktopSettingsInput): string { const t3Home = Option.fromUndefinedOr(input.env.T3CODE_HOME); const baseDir = resolveDesktopBaseDir({ homeDirectory: input.homeDirectory, joinPath: input.joinPath, t3Home, }); - const stateDir = resolveDesktopStateDir({ - baseDir, - isDevelopment: isDevelopmentEnvironment(input.env), - joinPath: input.joinPath, - t3Home, - }); + const isDevelopment = isDevelopmentEnvironment(input.env); + const stateDir = + !isDevelopment && isCanaryDesktopVersion(input.appVersion) + ? input.joinPath(baseDir, "canary") + : resolveDesktopStateDir({ + baseDir, + isDevelopment, + joinPath: input.joinPath, + t3Home, + }); return input.joinPath(stateDir, "desktop-settings.json"); } @@ -80,8 +82,13 @@ export function resolveEarlyLinuxElectronOptions( input: EarlyLinuxElectronOptionsInput, ): EarlyLinuxElectronOptions { const preference = resolveEarlyLinuxPasswordStorePreference(input); + const isDevelopment = isDevelopmentEnvironment(input.env); return { - linuxWmClass: isDevelopmentEnvironment(input.env) ? "t3code-dev" : "t3code", + linuxWmClass: isDevelopment + ? "t3code-dev" + : isCanaryDesktopVersion(input.appVersion) + ? "t3code-canary" + : "t3code", passwordStore: resolveLinuxPasswordStoreSwitch({ preference, env: input.env, diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 9190acdb1f..b8adb82759 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -15,7 +15,7 @@ import * as Path from "effect/Path"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; import { resolveDesktopBaseDir, resolveDesktopStateDir } from "./DesktopStatePaths.ts"; -import { isNightlyDesktopVersion } from "../updates/updateChannels.ts"; +import { isCanaryDesktopVersion, isNightlyDesktopVersion } from "../updates/updateChannels.ts"; export interface MakeDesktopEnvironmentInput { readonly dirname: string; @@ -90,6 +90,9 @@ function resolveDesktopAppStageLabel(input: { return "Dev"; } + if (isCanaryDesktopVersion(input.appVersion)) { + return "Canary"; + } return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha"; } @@ -143,6 +146,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( const homeDirectory = input.homeDirectory; const devServerUrl = config.devServerUrl; const isDevelopment = Option.isSome(devServerUrl); + const isCanary = !isDevelopment && isCanaryDesktopVersion(input.appVersion); const appDataDirectory = input.platform === "win32" ? Option.getOrElse(config.appDataDirectory, () => @@ -163,14 +167,23 @@ const make = Effect.fn("desktop.environment.make")(function* ( appVersion: input.appVersion, }); const displayName = branding.displayName; - const stateDir = resolveDesktopStateDir({ - baseDir, - isDevelopment, - joinPath: path.join, - t3Home: config.t3Home, - }); - const userDataDirName = isDevelopment ? "t3code-dev" : "t3code"; - const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; + const stateDir = isCanary + ? path.join(baseDir, "canary") + : resolveDesktopStateDir({ + baseDir, + isDevelopment, + joinPath: path.join, + t3Home: config.t3Home, + }); + const appIdentitySuffix = isDevelopment ? "dev" : isCanary ? "canary" : null; + const userDataDirName = appIdentitySuffix !== null ? `t3code-${appIdentitySuffix}` : "t3code"; + const legacyUserDataDirName = isDevelopment + ? "T3 Code (Dev)" + : isCanary + ? "T3 Code (Canary)" + : "T3 Code (Alpha)"; + const appUserModelId = + appIdentitySuffix !== null ? `com.t3tools.t3code.${appIdentitySuffix}` : "com.t3tools.t3code"; const linuxApplicationsDir = path.join( Option.getOrElse(config.xdgDataHome, () => path.join(homeDirectory, ".local", "share")), "applications", @@ -213,11 +226,9 @@ const make = Effect.fn("desktop.environment.make")(function* ( otlpExportIntervalMs: config.otlpExportIntervalMs, branding, displayName, - appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () => - isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code", - ), - linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop", - linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", + appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () => appUserModelId), + linuxDesktopEntryName: `${userDataDirName}.desktop`, + linuxWmClass: userDataDirName, linuxApplicationsDir, appImagePath: config.appImagePath, userDataDirName, diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index 7d145632d0..e767bccd03 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -32,6 +32,7 @@ export function readCommandLineSwitchValue( export const resolveEarlyLinuxElectronOptionsFromProcess = (): DesktopEarlyElectronStartup.EarlyLinuxElectronOptions => DesktopEarlyElectronStartup.resolveEarlyLinuxElectronOptions({ + appVersion: Electron.app.getVersion(), env: process.env, homeDirectory: NodeOS.homedir(), joinPath: NodePath.posix.join, diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 64c59749ab..21b19ecaa9 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -28,7 +28,7 @@ const DesktopSettingsPatch = Schema.Struct({ serverExposureMode: Schema.optionalKey(Schema.Literals(["local-only", "network-accessible"])), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), tailscaleServePort: Schema.optionalKey(Schema.Number), - updateChannel: Schema.optionalKey(Schema.Literals(["latest", "nightly"])), + updateChannel: Schema.optionalKey(Schema.Literals(["latest", "nightly", "canary"])), updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), wslBackendEnabled: Schema.optionalKey(Schema.Boolean), wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])), diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 7357907e17..a27eaedf22 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -331,7 +331,7 @@ export const make = Effect.gen(function* () { channel: DesktopUpdateChannel, ) { yield* Effect.annotateCurrentSpan({ channel }); - const allowsPrerelease = channel === "nightly"; + const allowsPrerelease = channel !== "latest"; yield* electronUpdater.setChannel(channel); yield* electronUpdater.setAllowPrerelease(allowsPrerelease); yield* electronUpdater.setAllowDowngrade(allowsPrerelease); diff --git a/apps/desktop/src/updates/updateChannels.ts b/apps/desktop/src/updates/updateChannels.ts index 360332ca38..c0627910da 100644 --- a/apps/desktop/src/updates/updateChannels.ts +++ b/apps/desktop/src/updates/updateChannels.ts @@ -1,11 +1,22 @@ import type { DesktopUpdateChannel } from "@t3tools/contracts"; const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}(?:\.\d+)?(?:\+[0-9A-Za-z.-]+)?$/; +const CANARY_VERSION_PATTERN = /-canary\.\d{8}(?:\.\d+)?(?:\+[0-9A-Za-z.-]+)?$/; export function isNightlyDesktopVersion(version: string): boolean { return NIGHTLY_VERSION_PATTERN.test(version); } +export function isCanaryDesktopVersion(version: string): boolean { + return CANARY_VERSION_PATTERN.test(version); +} + export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel { - return isNightlyDesktopVersion(appVersion) ? "nightly" : "latest"; + if (isCanaryDesktopVersion(appVersion)) { + return "canary"; + } + if (isNightlyDesktopVersion(appVersion)) { + return "nightly"; + } + return "latest"; } diff --git a/apps/server/src/cloud/cliAuthHtml.ts b/apps/server/src/cloud/cliAuthHtml.ts index 5a22a25993..ffc7acc0c0 100644 --- a/apps/server/src/cloud/cliAuthHtml.ts +++ b/apps/server/src/cloud/cliAuthHtml.ts @@ -1,12 +1,13 @@ -export type LoopbackAuthorizationStage = "dev" | "nightly" | "latest"; +export type LoopbackAuthorizationStage = "canary" | "dev" | "nightly" | "latest"; -declare const __T3CODE_BUILD_CHANNEL__: "nightly" | "latest" | undefined; +declare const __T3CODE_BUILD_CHANNEL__: "canary" | "nightly" | "latest" | undefined; export function resolveLoopbackAuthorizationStage(): LoopbackAuthorizationStage { return typeof __T3CODE_BUILD_CHANNEL__ === "undefined" ? "dev" : __T3CODE_BUILD_CHANNEL__; } const stageBrands = { + canary: "T3 Code (Canary)", dev: "T3 Code (Dev)", nightly: "T3 Code (Nightly)", latest: "T3 Code", @@ -62,10 +63,12 @@ export function renderLoopbackAuthorizationCompleteHtml( radial-gradient(circle at 76% 18%, rgba(136, 204, 255, 0.52), transparent 38%), linear-gradient(135deg, #2468df, #172f82); } - .stage-dev { + .stage-dev, + .stage-canary { background: linear-gradient(145deg, #5ab8fa 0%, #347ff8 46%, #1939bd 100%); } - .stage-dev::before { + .stage-dev::before, + .stage-canary::before { content: ""; position: absolute; inset: 0; diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 521654f327..3e839c3b34 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -17,7 +17,11 @@ export function shouldBundleCliDependency(id: string): boolean { } const repoEnv = loadRepoEnv(); -const cliBuildChannel = packageJson.version.includes("-nightly.") ? "nightly" : "latest"; +const cliBuildChannel = packageJson.version.includes("-canary.") + ? "canary" + : packageJson.version.includes("-nightly.") + ? "nightly" + : "latest"; export default mergeConfig( baseConfig, diff --git a/apps/web/src/branding.logic.ts b/apps/web/src/branding.logic.ts index 06d663ca0b..c61dfff57d 100644 --- a/apps/web/src/branding.logic.ts +++ b/apps/web/src/branding.logic.ts @@ -1,4 +1,5 @@ const NIGHTLY_SERVER_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; +const CANARY_SERVER_VERSION_PATTERN = /-canary\.\d{8}\.\d+$/; export function formatAppDisplayName(input: { readonly baseName: string; @@ -14,14 +15,14 @@ export function formatAppDisplayName(input: { /** * Whether the sidebar v2 beta is on by default for a build stage. * - * Nightly and local dev opt in; Alpha and Latest stay on v1. This is resolved + * Canary, Nightly, and local dev opt in; Alpha and Latest stay on v1. This is resolved * from the client's own stage label rather than the connected server's version: * v2 only exists in the client, so a stable client on a nightly server has * nothing to turn on. */ export function resolveSidebarV2Default(stageLabel: string): boolean { const stage = stageLabel.trim().toLowerCase(); - return stage === "nightly" || stage === "dev"; + return stage === "canary" || stage === "nightly" || stage === "dev"; } /** @@ -60,10 +61,14 @@ export function resolveServerBackedAppStageLabel(input: { readonly primaryServerVersion: string | null | undefined; readonly fallbackStageLabel: string; }): string { - return input.primaryServerVersion && - NIGHTLY_SERVER_VERSION_PATTERN.test(input.primaryServerVersion) - ? "Nightly" - : input.fallbackStageLabel; + const version = input.primaryServerVersion ?? ""; + if (CANARY_SERVER_VERSION_PATTERN.test(version)) { + return "Canary"; + } + if (NIGHTLY_SERVER_VERSION_PATTERN.test(version)) { + return "Nightly"; + } + return input.fallbackStageLabel; } export function resolveServerBackedAppDisplayName(input: { diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index 7fc57cf0d0..9ad3151153 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -13,9 +13,17 @@ const injectedDesktopAppBranding = readInjectedDesktopAppBranding(); const hostedAppChannel = import.meta.env.VITE_HOSTED_APP_CHANNEL?.trim().toLowerCase(); export const HOSTED_APP_CHANNEL = - hostedAppChannel === "latest" || hostedAppChannel === "nightly" ? hostedAppChannel : null; + hostedAppChannel === "latest" || hostedAppChannel === "nightly" || hostedAppChannel === "canary" + ? hostedAppChannel + : null; export const HOSTED_APP_CHANNEL_LABEL = - HOSTED_APP_CHANNEL === "nightly" ? "Nightly" : HOSTED_APP_CHANNEL === "latest" ? "Latest" : null; + HOSTED_APP_CHANNEL === "canary" + ? "Canary" + : HOSTED_APP_CHANNEL === "nightly" + ? "Nightly" + : HOSTED_APP_CHANNEL === "latest" + ? "Latest" + : null; export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "T3 Code"; export const APP_STAGE_LABEL = injectedDesktopAppBranding?.stageLabel ?? diff --git a/apps/web/src/components/SidebarStageBackdrop.tsx b/apps/web/src/components/SidebarStageBackdrop.tsx index 9fb448e940..f64a25d8d9 100644 --- a/apps/web/src/components/SidebarStageBackdrop.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.tsx @@ -6,7 +6,7 @@ import { resolveServerBackedAppStageLabel } from "../branding.logic"; import { primaryServerConfigAtom } from "../state/server"; export type SidebarStageBackdropVariant = "nightly" | "dev"; -export type EnvironmentIdentificationPillLabel = "Dev" | "Nightly"; +export type EnvironmentIdentificationPillLabel = "Canary" | "Dev" | "Nightly"; // A wide viewBox keeps the 96-unit art height at a fixed scale while sidebar resizing reveals // more horizontal canvas instead of zooming the scene. @@ -16,10 +16,19 @@ export function resolveSidebarStageBackdropVariant( stageLabel: string, enabled = true, ): SidebarStageBackdropVariant | null { - if (!enabled) return null; + if (!enabled) { + return null; + } const normalized = stageLabel.trim().toLowerCase(); - if (normalized === "nightly") return "nightly"; - if (normalized === "dev") return "dev"; + if (normalized === "canary") { + return "dev"; + } + if (normalized === "nightly") { + return "nightly"; + } + if (normalized === "dev") { + return "dev"; + } return null; } @@ -27,8 +36,15 @@ export function resolveEnvironmentIdentificationPillLabel( stageLabel: string, ): EnvironmentIdentificationPillLabel | null { const normalized = stageLabel.trim().toLowerCase(); - if (normalized === "dev") return "Dev"; - if (normalized === "nightly") return "Nightly"; + if (normalized === "canary") { + return "Canary"; + } + if (normalized === "dev") { + return "Dev"; + } + if (normalized === "nightly") { + return "Nightly"; + } return null; } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index de3f31c8fb..1584d49d5d 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -502,7 +502,7 @@ function AboutVersionSection() { {hasDesktopBridge ? ( - {selectedUpdateChannel === "nightly" ? "Nightly" : "Stable"} + {selectedUpdateChannel === "canary" + ? "Canary" + : selectedUpdateChannel === "nightly" + ? "Nightly" + : "Stable"} @@ -526,6 +530,9 @@ function AboutVersionSection() { Nightly + + Canary + } @@ -554,6 +561,9 @@ function AboutVersionSection() { Nightly + + Canary + } diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index fb95dc11a4..6845034426 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -26,7 +26,7 @@ function SidebarUpdateReleaseNotesTooltip({ readonly state: NonNullable>; readonly tooltip: string; }) { - if (state.channel !== "nightly" || state.releaseNotes.length === 0) { + if (state.channel === "latest" || state.releaseNotes.length === 0) { return <>{tooltip}; } @@ -193,7 +193,7 @@ export function SidebarUpdatePill() { 0 + state !== null && state.channel !== "latest" && state.releaseNotes.length > 0 ? "max-w-none text-balance" : undefined } diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 2caa6a3475..6b406e3037 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -245,9 +245,9 @@ export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMod /** * Resolved sidebar v2 state: an explicit choice in Settings → Beta if the user - * has made one, otherwise the default for this build stage (on for nightly and - * dev, off for production). Every consumer must read through this rather than - * `settings.sidebarV2Enabled`, which is only meaningful alongside + * has made one, otherwise the default for this build stage (on for canary, + * nightly, and dev; off for production). Every consumer must read through this + * rather than `settings.sidebarV2Enabled`, which is only meaningful alongside * `sidebarV2ConfiguredByUser`. * * Held at v1 until client settings hydrate. The pre-hydration snapshot is just diff --git a/apps/web/src/hostedPairing.ts b/apps/web/src/hostedPairing.ts index b49b4142d3..48d4ee5870 100644 --- a/apps/web/src/hostedPairing.ts +++ b/apps/web/src/hostedPairing.ts @@ -8,7 +8,7 @@ export interface HostedPairingRequest { readonly label: string; } -export type HostedAppChannel = "latest" | "nightly"; +export type HostedAppChannel = "latest" | "nightly" | "canary"; function hostedStaticAppEnabled(): boolean { return import.meta.env.VITE_HOSTED_STATIC_APP === "true"; @@ -32,7 +32,10 @@ function configuredBackendUrl(): string { function configuredHostedAppChannel(): HostedAppChannel | null { const channel = import.meta.env.VITE_HOSTED_APP_CHANNEL?.trim().toLowerCase(); - return channel === "latest" || channel === "nightly" ? channel : null; + if (channel === "latest" || channel === "nightly" || channel === "canary") { + return channel; + } + return null; } function originFromUrl(value: string): string | null { diff --git a/apps/web/vercel.ts b/apps/web/vercel.ts index 12a823a360..a31968d544 100644 --- a/apps/web/vercel.ts +++ b/apps/web/vercel.ts @@ -4,6 +4,7 @@ const ROUTER_HOST = "app.t3.codes"; const HOSTED_WEB_CHANNEL_COOKIE = "t3code_web_channel"; const LATEST_ORIGIN = "https://latest.app.t3.codes"; const NIGHTLY_ORIGIN = "https://nightly.app.t3.codes"; +const CANARY_ORIGIN = "https://canary.app.t3.codes"; const CLEAN_CHANNEL_QUERY_TRANSFORMS = [ { type: "request.query", @@ -12,7 +13,7 @@ const CLEAN_CHANNEL_QUERY_TRANSFORMS = [ }, ] satisfies Transform[]; -function channelCookie(channel: "latest" | "nightly"): string { +function channelCookie(channel: "latest" | "nightly" | "canary"): string { return [ `${HOSTED_WEB_CHANNEL_COOKIE}=${channel}`, "Path=/", @@ -32,6 +33,16 @@ export const config: VercelConfig = { installCommand: "npm install -g vite-plus && vp install --ignore-scripts --filter '@t3tools/scripts...' --filter '@t3tools/web...'", routes: [ + { + src: "/__t3code/channel", + has: [matchers.query("channel", "canary")], + transforms: CLEAN_CHANNEL_QUERY_TRANSFORMS, + headers: { + Location: "/", + "Set-Cookie": channelCookie("canary"), + }, + status: 302, + }, { src: "/__t3code/channel", has: [matchers.query("channel", "nightly")], @@ -51,6 +62,11 @@ export const config: VercelConfig = { }, status: 302, }, + { + src: "/(.*)", + has: [matchers.host(ROUTER_HOST), matchers.cookie(HOSTED_WEB_CHANNEL_COOKIE, "canary")], + dest: `${CANARY_ORIGIN}/$1`, + }, { src: "/(.*)", has: [matchers.host(ROUTER_HOST), matchers.cookie(HOSTED_WEB_CHANNEL_COOKIE, "nightly")], diff --git a/docs/operations/release.md b/docs/operations/release.md index 89c4006baf..fd17564c05 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -2,34 +2,28 @@ > For maintainers. Using T3 Code? See [docs/user](../user/). -This document covers the unified release workflow for stable and nightly desktop releases. +This document covers the unified release workflow for stable, nightly, and canary desktop releases. ## What the workflow does - Workflow: `.github/workflows/release.yml` - Triggers: - - push tag matching `v*.*.*` for stable releases - - scheduled nightly check every three hours - - manual `workflow_dispatch` for either channel + - pushes to `main` for stable or nightly releases + - pushes to `canary` for canary releases + - manual `workflow_dispatch` for any channel - Runs quality gates first: lint, typecheck, test. -- Reads the shared production T3 Connect relay URL and Clerk client configuration before packaging clients. -- Builds four artifacts in parallel for both channels: +- Fork builds use empty relay and Clerk configuration because the production relay config job is disabled. +- Builds four artifacts in parallel for every channel: - macOS `arm64` DMG - macOS `x64` DMG - Linux `x64` AppImage - Windows `x64` NSIS installer - Publishes one GitHub Release with all produced files. - - Stable tags with a suffix after `X.Y.Z` (for example `1.2.3-alpha.1`) are published as GitHub prereleases. - - Only plain stable `X.Y.Z` releases are marked as the repository's latest release. - - Nightly runs are always GitHub prereleases and never marked latest. - - 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. -- Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets. -- Publishes the CLI package (`apps/server`, npm package `t3`) with OIDC trusted publishing from the same workflow file: - - stable releases publish npm dist-tag `latest` - - nightly releases publish npm dist-tag `nightly` -- Deploys the hosted web app to Vercel only after a release is published: - - stable releases are aliased to the `latest` hosted app channel - - nightly releases are aliased to the `nightly` hosted app channel + - Stable runs publish plain `X.Y.Z` versions and mark them as the repository's latest release. + - Nightly and canary runs are always GitHub prereleases and never marked latest. + - Automatically generated release notes compare against the previous tag in the same channel. +- Includes Electron auto-update metadata such as `latest*.yml`, `nightly*.yml`, `canary*.yml`, and `*.blockmap` in release assets. +- The fork keeps npm publishing and hosted Vercel deployment disabled. Releases still include the desktop artifacts and hosted web archive. - Signing is optional and auto-detected per platform from secrets. ## Required release credentials @@ -40,18 +34,16 @@ credentials documented below: - `RELEASE_APP_ID` - `RELEASE_APP_PRIVATE_KEY` -The GitHub Release job uses them to mint the token that publishes release assets. Stable releases use -them again in the finalize job, which can commit and push aligned package versions to `main`. +The GitHub Release job uses them to mint the token that publishes release assets. ## T3 Connect relay deployment -The relay is a shared control plane versioned separately from client releases. Stable and nightly +The relay is a shared control plane versioned separately from client releases. Stable, nightly, and canary client builds must point at the same relay so users see the same linked environments when switching release channels. -`.github/workflows/deploy-relay.yml` deploys Alchemy stage `prod` on every push to `main`. The -release workflow reads the relay URL and Clerk client configuration from the existing `production` -GitHub Actions environment before building desktop, CLI, or hosted web artifacts. +`.github/workflows/deploy-relay.yml` deploys Alchemy stage `prod` on every push to `main`. The fork's +release workflow does not read this deployment state because its relay config job is disabled. Required repository variables shared by relay deployments: @@ -103,10 +95,10 @@ vp run --filter t3code-relay deploy -- --stage "$USER" --env-file .env.local ## Hosted web app release deployment -The hosted app is intentionally not deployed by Vercel's Git integration. The -web project disables automatic Git deployments in `apps/web/vercel.ts` via -`git.deploymentEnabled: false`, and `.github/workflows/release.yml` deploys the -web app with Vercel CLI after the GitHub Release succeeds. +The web project disables automatic Git deployments in `apps/web/vercel.ts` via +`git.deploymentEnabled: false`. The fork also disables the `deploy_web` release +job, so releases do not change Vercel aliases. The configuration below applies +if that job is enabled later. Required GitHub Actions secrets: @@ -120,24 +112,27 @@ Optional GitHub Actions variables: - `T3CODE_WEB_ROUTER_URL`: defaults to `https://app.t3.codes`. - `T3CODE_WEB_LATEST_DOMAIN`: defaults to `latest.app.t3.codes`. - `T3CODE_WEB_NIGHTLY_DOMAIN`: defaults to `nightly.app.t3.codes`. +- `T3CODE_WEB_CANARY_DOMAIN`: defaults to `canary.app.t3.codes`. Required Vercel domains: - `app.t3.codes`: the router domain users open, updated by stable releases. - `latest.app.t3.codes`: channel alias updated by stable releases. - `nightly.app.t3.codes`: channel alias updated by nightly releases. +- `canary.app.t3.codes`: channel alias updated by canary releases. The router domain uses `apps/web/vercel.ts` routes. Users opt into a channel by -visiting `/__t3code/channel?channel=latest` or -`/__t3code/channel?channel=nightly`; the router stores the +visiting `/__t3code/channel?channel=latest`, +`/__t3code/channel?channel=nightly`, or +`/__t3code/channel?channel=canary`; the router stores the `t3code_web_channel` cookie and rewrites future requests on `app.t3.codes` to the matching channel alias. The release deploy job rewrites release package versions before upload so the hosted app's About panel renders the release version. Stable deploys alias the same deployment to both the `latest` channel and the router domain so the router -rules stay current. Nightly deploys only alias the `nightly` channel. The job -also passes `VITE_HOSTED_APP_CHANNEL=latest|nightly`, which renders the hosted +rules stay current. Nightly and canary deploy only their matching channel aliases. The job +also passes `VITE_HOSTED_APP_CHANNEL=latest|nightly|canary`, which renders the hosted update track selector in the About panel. Changing the selector navigates through `/__t3code/channel` on the router domain so the user's channel cookie is updated before redirecting to the hosted app root. @@ -145,7 +140,7 @@ updated before redirecting to the hosted app root. One-time Vercel dashboard setup: 1. Confirm the web project root directory remains `apps/web`. -2. Add the three domains above to the web project. +2. Add the four domains above to the web project. 3. Disable automatic Git deployments in the dashboard if desired; the committed `vercel.ts` setting is the source-of-truth, but disconnecting Git in the dashboard is also safe. @@ -157,9 +152,9 @@ One-time Vercel dashboard setup: - Workflow: `.github/workflows/release.yml` - Triggers: - - scheduled check every three hours + - pushes to `main` unless the committed package version is an untagged stable version - manual `workflow_dispatch` with `channel=nightly` -- Runs the same desktop quality gates and artifact matrix as the tagged release flow. +- Runs the same desktop quality gates and artifact matrix as the stable release flow. - Publishes a GitHub prerelease only: - current tag format: `vX.Y.Z-nightly.YYYYMMDD.` - `nightly-v...` is accepted only as a legacy previous-nightly tag @@ -167,30 +162,28 @@ One-time Vercel dashboard setup: - `make_latest` is always `false` - Uses the next stable patch version as the nightly base. For example, `0.0.17` produces nightlies on `0.0.18-nightly.*`. - Publishes Electron auto-update metadata to the dedicated `nightly` updater channel, so desktop users can opt into that track independently from stable. -- Publishes the CLI package (`apps/server`, npm package `t3`) to the `nightly` npm dist-tag using the same nightly version. +- Does not publish the CLI package in the fork because `publish_cli` is disabled. - Does not commit version bumps back to `main`. -## Server self-update release invariant - -Connected servers update to the client's exact version, not to an npm dist-tag. Every released -desktop or hosted client version must therefore have a matching `t3@` package available on -npm before users can receive that client. +## Canary builds -The workflow enforces this ordering: +- Every push to the long-lived `canary` branch publishes one immutable GitHub prerelease. +- Tag format: `vX.Y.Z-canary.YYYYMMDD.`. +- Uses the next stable patch version as its base, independently of the nightly tag series. +- Publishes Electron metadata to the `canary` updater channel. Canary desktop builds select it by default. +- Uses `T3 Code (Canary)` branding and stores desktop and its managed server state under `~/.t3/canary`. Electron user data uses `t3code-canary`, so canary cannot migrate stable state. +- Does not publish the CLI package or deploy the hosted web app in the fork. +- Does not change package versions on `canary` or `main`. +- Merge `main` into `canary` regularly. Develop experiments on focused branches and merge them into `canary`; promote proven changes to `main` through separate focused PRs. -1. `publish_cli` publishes the exact stable or nightly version to npm. -2. `release` depends on `publish_cli` before exposing desktop artifacts in GitHub Releases. -3. `deploy_web` depends on `release` before moving the hosted channel to the new client. +## Server self-update release invariant -Preserve these dependencies when changing the release graph. Publishing a client first would leave -the **Update server** action targeting a package version that does not exist yet. +Connected servers update to the client's exact version, not to an npm dist-tag. The fork disables +`publish_cli`, so its GitHub releases do not create matching `t3@` packages. Remote server +self-update cannot target those release versions unless npm publishing is enabled again. -For a release smoke test, confirm `npm view t3@ version` returns the expected version, then -connect the new client to a server on the previous version and verify that the update action -reconnects to the matching server. Use releases with identical migration manifests for the -automatic path. When the manifest changed, verify that the remote action stops before restart and -shows the exact local `npx t3@ service update` command. Also test the manual or -desktop-managed guidance when those environments are available. +If npm publishing is enabled again, confirm `npm view t3@ version` returns the expected +version before testing remote server self-update. ## Desktop auto-update notes @@ -207,17 +200,17 @@ desktop-managed guidance when those environments are available. - otherwise `GITHUB_REPOSITORY` from GitHub Actions. - Required release assets for updater: - platform installers (`.exe`, `.dmg`, `.AppImage`, plus macOS `.zip` for Squirrel.Mac update payloads) - - channel metadata: `latest*.yml` for stable releases, `nightly*.yml` for nightly releases + - channel metadata: `latest*.yml` for stable releases, `nightly*.yml` for nightly releases, and `canary*.yml` for canary releases - `*.blockmap` files (used for differential downloads) - macOS metadata note: - - `electron-updater` reads `latest-mac.yml` on stable and `nightly-mac.yml` on nightly, for both Intel and Apple Silicon. + - `electron-updater` reads `latest-mac.yml`, `nightly-mac.yml`, or `canary-mac.yml` for the selected channel, for both Intel and Apple Silicon. - The workflow merges the per-arch mac manifests into one channel-specific mac manifest before publishing the GitHub Release. ## 0) npm OIDC trusted publishing setup (CLI) -The workflow invokes `node apps/server/scripts/cli.ts publish` after aligning package versions. That -script temporarily prepares the `t3` package, then runs `vp pm publish --filter t3 ...` from the -repository root so workspace publish configuration is applied correctly. +The fork disables the `publish_cli` job. If it is enabled later, the job invokes +`node apps/server/scripts/cli.ts publish` after aligning package versions. That script temporarily +prepares the `t3` package, then runs `vp pm publish --filter t3 ...` from the repository root. Checklist: @@ -228,25 +221,19 @@ Checklist: - Workflow file: `.github/workflows/release.yml` - Environment (if used): match your npm trusted publishing config 3. Ensure npm account and org policies allow trusted publishing for the package. -4. Create release tag `vX.Y.Z` and push; workflow will: +4. Enable the `publish_cli` job and run a stable release; the workflow will: - align the release package versions to `X.Y.Z` - build web + server - invoke the CLI publish script with npm dist-tag `latest` -5. Nightly runs invoke the same publish script with npm dist-tag `nightly`. +5. Nightly and canary runs invoke the same publish script with their matching npm dist-tag. ## 1) Release validation and unsigned builds -There is no dry-run tag path. Pushing any accepted non-nightly tag, including -`v0.0.0-test.1`, classifies the run as the stable channel. It publishes `t3` with npm dist-tag -`latest`, creates a real GitHub Release, aliases the hosted app to `latest.app.t3.codes` and -`app.t3.codes`, and can commit a version bump to `main` in the finalize job. Do not push a test tag -to validate the workflow. - The workflow has no non-publishing `workflow_dispatch` mode. Use normal CI or local quality gates to validate checks and builds without shipping. To exercise the complete release graph at lower stable -risk, manually dispatch `channel=nightly`; this still publishes a real nightly npm package, GitHub -prerelease, desktop updater release, and hosted nightly alias, but it does not update stable aliases or -commit a version bump to `main`. Only run it when a real nightly release is acceptable. +risk, manually dispatch `channel=nightly` or `channel=canary`; this still publishes a real GitHub +prerelease and desktop updater release. It does not publish npm packages, deploy hosted aliases, or +commit a version bump to `main` in the fork. Manual `channel=stable` with a version input is also a real stable-channel release. Omitting signing secrets only makes platform artifacts unsigned; it does not prevent publication. @@ -328,15 +315,13 @@ Checklist: ## 4) Ongoing release checklist 1. Ensure `main` is green in CI. -2. Bump app version as needed. -3. Create release tag: `vX.Y.Z`. -4. Push tag. -5. Verify workflow steps: +2. Review and merge the generated stable release PR. +3. Verify workflow steps: - preflight passes - all matrix builds pass - - `publish_cli` publishes the exact release version before the release job + - the fork's disabled `publish_cli` job remains skipped - release job uploads expected files -6. Smoke test downloaded artifacts. +4. Smoke test downloaded artifacts. ## 5) Troubleshooting diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index a92eb972d6..01e8189d61 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -144,8 +144,8 @@ export type DesktopUpdateStatus = export type DesktopRuntimeArch = "arm64" | "x64" | "other"; export type DesktopTheme = "light" | "dark" | "system"; -export type DesktopUpdateChannel = "latest" | "nightly"; -export type DesktopAppStageLabel = "Alpha" | "Dev" | "Nightly"; +export type DesktopUpdateChannel = "latest" | "nightly" | "canary"; +export type DesktopAppStageLabel = "Alpha" | "Canary" | "Dev" | "Nightly"; export const DesktopUpdateStatusSchema = Schema.Literals([ "disabled", @@ -159,8 +159,8 @@ export const DesktopUpdateStatusSchema = Schema.Literals([ ]); export const DesktopRuntimeArchSchema = Schema.Literals(["arm64", "x64", "other"]); export const DesktopThemeSchema = Schema.Literals(["light", "dark", "system"]); -export const DesktopUpdateChannelSchema = Schema.Literals(["latest", "nightly"]); -export const DesktopAppStageLabelSchema = Schema.Literals(["Alpha", "Dev", "Nightly"]); +export const DesktopUpdateChannelSchema = Schema.Literals(["latest", "nightly", "canary"]); +export const DesktopAppStageLabelSchema = Schema.Literals(["Alpha", "Canary", "Dev", "Nightly"]); export interface DesktopAppBranding { baseName: string; diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index 10927b4308..3055a360cd 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -378,5 +378,5 @@ export function resolveRemoteT3CliPackageSpec(input: { return "t3@nightly"; } - return input.updateChannel === "nightly" ? "t3@nightly" : "t3@latest"; + return input.updateChannel === "latest" ? "t3@latest" : `t3@${input.updateChannel}`; } diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 925d5481b7..d7710fc009 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -15,6 +15,7 @@ import { BRAND_ASSET_PATHS, resolveWebAssetBrandForChannel, type WebAssetBrand, + type WebAssetChannel, } from "./lib/brand-assets.ts"; import { getDefaultBuildArch } from "./lib/build-target-arch.ts"; import { loadRepoEnv } from "./lib/public-config.ts"; @@ -1449,7 +1450,7 @@ export function resolveDesktopRuntimeDependencies( } export const resolveGitHubPublishConfig = Effect.fn("resolveGitHubPublishConfig")(function* ( - updateChannel: "latest" | "nightly", + updateChannel: WebAssetChannel, ) { const env = yield* Config.all({ updateRepository: Config.string("T3CODE_DESKTOP_UPDATE_REPOSITORY").pipe(Config.option), @@ -1469,13 +1470,19 @@ export const resolveGitHubPublishConfig = Effect.fn("resolveGitHubPublishConfig" provider: "github", owner, repo, - releaseType: updateChannel === "nightly" ? "prerelease" : "release", - ...(updateChannel === "nightly" ? { channel: "nightly" as const } : {}), + releaseType: updateChannel === "latest" ? "release" : "prerelease", + ...(updateChannel === "latest" ? {} : { channel: updateChannel }), }; }); -export function resolveDesktopUpdateChannel(version: string): "latest" | "nightly" { - return /-nightly\.\d{8}(?:\.\d+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version) ? "nightly" : "latest"; +export function resolveDesktopUpdateChannel(version: string): WebAssetChannel { + if (/-canary\.\d{8}(?:\.\d+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)) { + return "canary"; + } + if (/-nightly\.\d{8}(?:\.\d+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)) { + return "nightly"; + } + return "latest"; } export function resolveDesktopWebAssetBrand(version: string): WebAssetBrand { @@ -1483,7 +1490,15 @@ export function resolveDesktopWebAssetBrand(version: string): WebAssetBrand { } export function resolveDesktopBuildIconAssets(version: string): DesktopBuildIconAssets { - if (resolveDesktopUpdateChannel(version) === "nightly") { + const channel = resolveDesktopUpdateChannel(version); + if (channel === "canary") { + return { + macIconPng: BRAND_ASSET_PATHS.developmentDesktopIconPng, + linuxIconPng: BRAND_ASSET_PATHS.developmentUniversalIconPng, + windowsIconIco: BRAND_ASSET_PATHS.developmentWindowsIconIco, + }; + } + if (channel === "nightly") { return { macIconPng: BRAND_ASSET_PATHS.nightlyMacIconPng, linuxIconPng: BRAND_ASSET_PATHS.nightlyLinuxIconPng, @@ -1516,9 +1531,14 @@ export function resolvePackageManagerUserAgent(packageManager: string): string { } export function resolveDesktopProductName(version: string): string { - return resolveDesktopUpdateChannel(version) === "nightly" - ? "T3 Code (Nightly)" - : (desktopPackageJson.productName ?? "T3 Code"); + const channel = resolveDesktopUpdateChannel(version); + if (channel === "canary") { + return "T3 Code (Canary)"; + } + if (channel === "nightly") { + return "T3 Code (Nightly)"; + } + return desktopPackageJson.productName ?? "T3 Code"; } export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( diff --git a/scripts/lib/brand-assets.ts b/scripts/lib/brand-assets.ts index 2dcc6ccd6c..e87d5a575e 100644 --- a/scripts/lib/brand-assets.ts +++ b/scripts/lib/brand-assets.ts @@ -33,15 +33,21 @@ export const BRAND_ASSET_PATHS = { export type WebAssetBrand = "development" | "nightly" | "production"; -export const WEB_ASSET_CHANNELS = ["latest", "nightly"] as const; +export const WEB_ASSET_CHANNELS = ["latest", "nightly", "canary"] as const; export type WebAssetChannel = (typeof WEB_ASSET_CHANNELS)[number]; export function resolveWebAssetBrandForChannel(channel: WebAssetChannel): WebAssetBrand { + if (channel === "canary") { + return "development"; + } return channel === "nightly" ? "nightly" : "production"; } export function resolveWebAssetBrandForPackageVersion(version: string): WebAssetBrand { + if (version.includes("-canary.")) { + return "development"; + } return version.includes("-nightly.") ? "nightly" : "production"; } diff --git a/scripts/resolve-nightly-release.ts b/scripts/resolve-nightly-release.ts index 5b42f931d7..2510803800 100644 --- a/scripts/resolve-nightly-release.ts +++ b/scripts/resolve-nightly-release.ts @@ -20,6 +20,8 @@ export interface NightlyReleaseMetadata { } const DateSchema = Schema.String.check(Schema.isPattern(/^\d{8}$/)); +const PrereleaseChannel = Schema.Literals(["nightly", "canary"]); +type PrereleaseChannel = typeof PrereleaseChannel.Type; const RunNumberSchema = Schema.FiniteFromString.check( Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), @@ -101,14 +103,16 @@ export const resolveNightlyReleaseMetadata = ( date: string, runNumber: number, sha: string, + channel: PrereleaseChannel = "nightly", ) => { const shortSha = sha.slice(0, 12); - const version = `${baseVersion}-nightly.${date}.${runNumber}`; + const version = `${baseVersion}-${channel}.${date}.${runNumber}`; + const channelLabel = channel === "canary" ? "Canary" : "Nightly"; return { baseVersion, version, tag: `v${version}`, - name: `T3 Code Nightly ${version} (${shortSha})`, + name: `T3 Code ${channelLabel} ${version} (${shortSha})`, shortSha, }; }; @@ -186,9 +190,13 @@ export const writeNightlyReleaseOutput = Effect.fn("writeNightlyReleaseOutput")( const command = Command.make( "resolve-nightly-release", { + channel: Flag.choice("channel", PrereleaseChannel.literals).pipe( + Flag.withDescription("Prerelease channel."), + Flag.withDefault("nightly"), + ), date: Flag.string("date").pipe( Flag.withSchema(DateSchema), - Flag.withDescription("Nightly build date in YYYYMMDD."), + Flag.withDescription("Prerelease build date in YYYYMMDD."), ), runNumber: Flag.string("run-number").pipe( Flag.withSchema(RunNumberSchema), @@ -207,12 +215,14 @@ const command = Command.make( Flag.optional, ), }, - ({ date, runNumber, sha, githubOutput, root }) => + ({ channel, date, runNumber, sha, githubOutput, root }) => readDesktopBaseVersion(Option.getOrUndefined(root)).pipe( - Effect.map((baseVersion) => resolveNightlyReleaseMetadata(baseVersion, date, runNumber, sha)), + Effect.map((baseVersion) => + resolveNightlyReleaseMetadata(baseVersion, date, runNumber, sha, channel), + ), Effect.flatMap((metadata) => writeNightlyReleaseOutput(metadata, githubOutput)), ), -).pipe(Command.withDescription("Resolve nightly release version metadata.")); +).pipe(Command.withDescription("Resolve nightly or canary release version metadata.")); if (import.meta.main) { Command.run(command, { version: "0.0.0" }).pipe( diff --git a/scripts/resolve-previous-release-tag.ts b/scripts/resolve-previous-release-tag.ts index dfb09b0cf7..67ef2ab1e9 100644 --- a/scripts/resolve-previous-release-tag.ts +++ b/scripts/resolve-previous-release-tag.ts @@ -11,8 +11,9 @@ import * as String from "effect/String"; import { Command, Flag } from "effect/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -const ReleaseChannel = Schema.Literals(["stable", "nightly"]); +const ReleaseChannel = Schema.Literals(["stable", "nightly", "canary"]); type ReleaseChannel = typeof ReleaseChannel.Type; +type PrereleaseChannel = "nightly" | "canary"; export class InvalidReleaseTagError extends Schema.TaggedErrorClass()( "InvalidReleaseTagError", @@ -91,7 +92,7 @@ interface StableVersion { readonly prerelease: ReadonlyArray; } -interface NightlyVersion { +interface PrereleaseVersion { readonly major: number; readonly minor: number; readonly patch: number; @@ -151,10 +152,11 @@ const parseStableTag = (tag: string): StableVersion | undefined => { if (!major || !minor || !patch) return undefined; const prereleaseIdentifiers = prerelease ? prerelease.split(".") : []; - // Nightly tags also start with `v` and carry a `nightly.*` prerelease - // identifier. They must not be considered stable candidates when resolving - // the previous stable tag. - if (prereleaseIdentifiers[0] === "nightly") return undefined; + // Channel tags also start with `v`, but must not be considered stable + // candidates when resolving the previous stable tag. + if (prereleaseIdentifiers[0] === "nightly" || prereleaseIdentifiers[0] === "canary") { + return undefined; + } return { major: Number(major), @@ -164,7 +166,7 @@ const parseStableTag = (tag: string): StableVersion | undefined => { }; }; -const compareNightlyVersions = (left: NightlyVersion, right: NightlyVersion): number => { +const comparePrereleaseVersions = (left: PrereleaseVersion, right: PrereleaseVersion): number => { if (left.major !== right.major) return left.major - right.major; if (left.minor !== right.minor) return left.minor - right.minor; if (left.patch !== right.patch) return left.patch - right.patch; @@ -172,13 +174,16 @@ const compareNightlyVersions = (left: NightlyVersion, right: NightlyVersion): nu return left.sequence - right.sequence; }; -const parseNightlyTag = (tag: string): NightlyVersion | undefined => { - // Accept both the current `v` format and the legacy `nightly-v` - // format so release note diffs keep working across the tag-format transition. +const parsePrereleaseTag = ( + channel: PrereleaseChannel, + tag: string, +): PrereleaseVersion | undefined => { const match = - /^(?:nightly-)?v(\d+)\.(\d+)\.(\d+)-nightly\.(\d{8})(?:\.(\d+))?(?:\+[0-9A-Za-z.-]+)?$/.exec( - tag, - ); + channel === "nightly" + ? /^(?:nightly-)?v(\d+)\.(\d+)\.(\d+)-nightly\.(\d{8})(?:\.(\d+))?(?:\+[0-9A-Za-z.-]+)?$/.exec( + tag, + ) + : /^v(\d+)\.(\d+)\.(\d+)-canary\.(\d{8})(?:\.(\d+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(tag); if (!match) return undefined; const [, major, minor, patch, date, sequence] = match; @@ -216,18 +221,18 @@ export const resolvePreviousReleaseTag = ( return candidates[0]?.tag; } - const current = parseNightlyTag(currentTag); + const current = parsePrereleaseTag(channel, currentTag); if (!current) { return yield* new InvalidReleaseTagError({ channel, currentTag }); } const candidates = tags - .map((tag) => ({ tag, parsed: parseNightlyTag(tag) })) + .map((tag) => ({ tag, parsed: parsePrereleaseTag(channel, tag) })) .filter( - (entry): entry is { tag: string; parsed: NightlyVersion } => entry.parsed !== undefined, + (entry): entry is { tag: string; parsed: PrereleaseVersion } => entry.parsed !== undefined, ) - .filter((entry) => compareNightlyVersions(entry.parsed, current) < 0) - .toSorted((left, right) => compareNightlyVersions(right.parsed, left.parsed)); + .filter((entry) => comparePrereleaseVersions(entry.parsed, current) < 0) + .toSorted((left, right) => comparePrereleaseVersions(right.parsed, left.parsed)); return candidates[0]?.tag; }); @@ -358,7 +363,7 @@ const command = Command.make( Effect.flatMap((tags) => resolvePreviousReleaseTag(channel, currentTag, tags)), Effect.flatMap((previousTag) => writePreviousReleaseTagOutput(previousTag, githubOutput)), ), -).pipe(Command.withDescription("Resolve the previous release tag for a stable or nightly series.")); +).pipe(Command.withDescription("Resolve the previous release tag for a release channel.")); if (import.meta.main) { Command.run(command, { version: "0.0.0" }).pipe( From b7051ac45d6b3c6e642d2a3e5a5999f4cd5fe790 Mon Sep 17 00:00:00 2001 From: tarik02 Date: Tue, 4 Aug 2026 15:40:45 +0000 Subject: [PATCH 2/2] fix(release): address canary review findings --- FORK.md | 2 +- .../src/app/DesktopEarlyElectronStartup.ts | 15 ++++++--------- apps/desktop/src/app/DesktopEnvironment.ts | 17 ++++++++--------- docs/operations/release.md | 5 +++-- packages/ssh/src/command.ts | 3 +++ scripts/build-desktop-artifact.ts | 5 +++-- 6 files changed, 24 insertions(+), 23 deletions(-) diff --git a/FORK.md b/FORK.md index 3216ba7a35..cc03188997 100644 --- a/FORK.md +++ b/FORK.md @@ -66,7 +66,7 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork - Stable builds use `latest`, nightly builds use `nightly`, and canary builds use `canary`. - Nightly and canary detection accepts fork release metadata while preserving separate updater feeds. -- Canary desktop builds use distinct branding and isolated `~/.t3/canary` and `t3code-canary` application state. +- Canary desktop builds isolate server and client state under `~/.t3/canary` and Electron data under `t3code-canary`. Desktop settings stay shared so update-track changes survive channel switches. ### Fork Persistence diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts index e6667561fa..adbbc50869 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts @@ -54,15 +54,12 @@ function resolveEarlyDesktopSettingsPath(input: EarlyDesktopSettingsInput): stri t3Home, }); const isDevelopment = isDevelopmentEnvironment(input.env); - const stateDir = - !isDevelopment && isCanaryDesktopVersion(input.appVersion) - ? input.joinPath(baseDir, "canary") - : resolveDesktopStateDir({ - baseDir, - isDevelopment, - joinPath: input.joinPath, - t3Home, - }); + const stateDir = resolveDesktopStateDir({ + baseDir, + isDevelopment, + joinPath: input.joinPath, + t3Home, + }); return input.joinPath(stateDir, "desktop-settings.json"); } diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index b8adb82759..58602d2dfc 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -167,14 +167,13 @@ const make = Effect.fn("desktop.environment.make")(function* ( appVersion: input.appVersion, }); const displayName = branding.displayName; - const stateDir = isCanary - ? path.join(baseDir, "canary") - : resolveDesktopStateDir({ - baseDir, - isDevelopment, - joinPath: path.join, - t3Home: config.t3Home, - }); + const desktopSettingsDir = resolveDesktopStateDir({ + baseDir, + isDevelopment, + joinPath: path.join, + t3Home: config.t3Home, + }); + const stateDir = isCanary ? path.join(baseDir, "canary") : desktopSettingsDir; const appIdentitySuffix = isDevelopment ? "dev" : isCanary ? "canary" : null; const userDataDirName = appIdentitySuffix !== null ? `t3code-${appIdentitySuffix}` : "t3code"; const legacyUserDataDirName = isDevelopment @@ -204,7 +203,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( appDataDirectory, baseDir, stateDir, - desktopSettingsPath: path.join(stateDir, "desktop-settings.json"), + desktopSettingsPath: path.join(desktopSettingsDir, "desktop-settings.json"), clientSettingsPath: path.join(stateDir, "client-settings.json"), savedEnvironmentRegistryPath: path.join(stateDir, "saved-environments.json"), serverSettingsPath: path.join(stateDir, "settings.json"), diff --git a/docs/operations/release.md b/docs/operations/release.md index fd17564c05..d6d3424ccc 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -171,7 +171,7 @@ One-time Vercel dashboard setup: - Tag format: `vX.Y.Z-canary.YYYYMMDD.`. - Uses the next stable patch version as its base, independently of the nightly tag series. - Publishes Electron metadata to the `canary` updater channel. Canary desktop builds select it by default. -- Uses `T3 Code (Canary)` branding and stores desktop and its managed server state under `~/.t3/canary`. Electron user data uses `t3code-canary`, so canary cannot migrate stable state. +- Uses `T3 Code (Canary)` branding, isolates client and managed server state under `~/.t3/canary`, and stores Electron data under `t3code-canary`. Desktop settings remain shared so switching update tracks persists across restarts. - Does not publish the CLI package or deploy the hosted web app in the fork. - Does not change package versions on `canary` or `main`. - Merge `main` into `canary` regularly. Develop experiments on focused branches and merge them into `canary`; promote proven changes to `main` through separate focused PRs. @@ -180,7 +180,8 @@ One-time Vercel dashboard setup: Connected servers update to the client's exact version, not to an npm dist-tag. The fork disables `publish_cli`, so its GitHub releases do not create matching `t3@` packages. Remote server -self-update cannot target those release versions unless npm publishing is enabled again. +self-update cannot target those release versions unless npm publishing is enabled again. Canary SSH +environments use the published `t3@nightly` runner instead of requesting a missing canary package. If npm publishing is enabled again, confirm `npm view t3@ version` returns the expected version before testing remote server self-update. diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index 3055a360cd..b169633d39 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -370,6 +370,9 @@ export function resolveRemoteT3CliPackageSpec(input: { readonly isDevelopment?: boolean; }): string { const appVersion = input.appVersion.trim(); + if (!input.isDevelopment && appVersion.includes("-canary.")) { + return "t3@nightly"; + } if (!input.isDevelopment && PUBLISHABLE_T3_VERSION_PATTERN.test(appVersion)) { return `t3@${appVersion}`; } diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index d7710fc009..1a6cf9370d 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -1607,9 +1607,10 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( } if (platform === "linux") { + const executableName = updateChannel === "canary" ? "t3code-canary" : "t3code"; buildConfig.linux = { target: [target], - executableName: "t3code", + executableName, icon: "icons", category: "Development", // electron-builder turns these into MimeType=x-scheme-handler/; @@ -1623,7 +1624,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( ], desktop: { entry: { - StartupWMClass: "t3code", + StartupWMClass: executableName, }, }, };