diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 16bbc25f249..382bd6f1eb1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -567,6 +567,90 @@ jobs: fail_on_unmatched_files: true token: ${{ steps.app_token.outputs.token }} + deploy_web: + name: Deploy hosted web app + needs: [preflight, release] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }} + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 10 + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + 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 }} + VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Install release tooling dependencies + run: bun install --frozen-lockfile --filter=@t3tools/scripts --filter=@t3tools/web + + - name: Align package versions to release version + run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" + + - name: Refresh release lockfile + run: bun install --lockfile-only --ignore-scripts + + - name: Deploy and alias channel + shell: bash + run: | + set -euo pipefail + + if [[ -z "${VERCEL_TOKEN:-}" || -z "${VERCEL_ORG_ID:-}" || -z "${VERCEL_PROJECT_ID:-}" ]]; then + echo "Missing one or more required Vercel secrets: VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID." >&2 + exit 1 + fi + + 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}" + + if [[ "${{ needs.preflight.outputs.release_channel }}" == "stable" ]]; then + channel_domain="$latest_domain" + channel_name="latest" + else + channel_domain="$nightly_domain" + channel_name="nightly" + fi + + vercel_scope_args=() + if [[ -n "${VERCEL_TEAM_SLUG:-}" ]]; then + vercel_scope_args=(--scope "$VERCEL_TEAM_SLUG") + fi + + echo "Deploying hosted web app for $channel_name channel." + deployment_url="$( + bunx vercel@53.1.1 deploy apps/web \ + --prod \ + --skip-domain \ + --yes \ + --token "$VERCEL_TOKEN" \ + "${vercel_scope_args[@]}" \ + --build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \ + --build-env "VITE_HOSTED_APP_URL=$router_url" \ + --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name" + )" + + echo "Aliasing $deployment_url to $channel_domain." + bunx vercel@53.1.1 alias set "$deployment_url" "$channel_domain" \ + --token "$VERCEL_TOKEN" \ + "${vercel_scope_args[@]}" + finalize: name: Finalize release if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'stable' }} @@ -651,8 +735,9 @@ jobs: always() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && + needs.deploy_web.result == 'success' && (needs.finalize.result == 'success' || needs.finalize.result == 'skipped') - needs: [preflight, release, finalize] + needs: [preflight, release, deploy_web, finalize] runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: diff --git a/apps/web/package.json b/apps/web/package.json index b61d529ab1c..fe91425c019 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -50,6 +50,7 @@ "@types/babel__core": "^7.20.5", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@vercel/config": "^0.3.0", "@vitejs/plugin-react": "^6.0.0", "@vitest/browser-playwright": "^4.0.18", "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", diff --git a/apps/web/src/branding.test.ts b/apps/web/src/branding.test.ts index b6643d2a72f..764b63cdb4b 100644 --- a/apps/web/src/branding.test.ts +++ b/apps/web/src/branding.test.ts @@ -34,4 +34,22 @@ describe("branding", () => { expect(branding.APP_STAGE_LABEL).toBe("Nightly"); expect(branding.APP_DISPLAY_NAME).toBe("T3 Code (Nightly)"); }); + + it("normalizes hosted app channel metadata", async () => { + vi.stubEnv("VITE_HOSTED_APP_CHANNEL", "nightly"); + + const branding = await import("./branding"); + + expect(branding.HOSTED_APP_CHANNEL).toBe("nightly"); + expect(branding.HOSTED_APP_CHANNEL_LABEL).toBe("Nightly"); + }); + + it("ignores unknown hosted app channels", async () => { + vi.stubEnv("VITE_HOSTED_APP_CHANNEL", "preview"); + + const branding = await import("./branding"); + + expect(branding.HOSTED_APP_CHANNEL).toBeNull(); + expect(branding.HOSTED_APP_CHANNEL_LABEL).toBeNull(); + }); }); diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index 99775a4c55d..d8018f0d924 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -9,6 +9,7 @@ function readInjectedDesktopAppBranding(): DesktopAppBranding | null { } const injectedDesktopAppBranding = readInjectedDesktopAppBranding(); +const hostedAppChannel = import.meta.env.VITE_HOSTED_APP_CHANNEL?.trim().toLowerCase(); export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "T3 Code"; export const APP_STAGE_LABEL = @@ -16,3 +17,7 @@ export const APP_STAGE_LABEL = export const APP_DISPLAY_NAME = injectedDesktopAppBranding?.displayName ?? `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0"; +export const HOSTED_APP_CHANNEL = + hostedAppChannel === "latest" || hostedAppChannel === "nightly" ? hostedAppChannel : null; +export const HOSTED_APP_CHANNEL_LABEL = + HOSTED_APP_CHANNEL === "nightly" ? "Nightly" : HOSTED_APP_CHANNEL === "latest" ? "Latest" : null; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 20d3b1040a8..9f0166a9d2a 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -15,7 +15,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; import * as Equal from "effect/Equal"; -import { APP_VERSION } from "../../branding"; +import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; import { canCheckForUpdate, getDesktopUpdateButtonTooltip, @@ -26,6 +26,7 @@ import { import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; import { isElectron } from "../../env"; +import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; import { useTheme } from "../../hooks/useTheme"; import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; @@ -162,6 +163,7 @@ function AboutVersionSection() { const updateState = updateStateQuery.data ?? null; const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge); const selectedUpdateChannel = updateState?.channel ?? "latest"; + const selectedHostedAppChannel = hasDesktopBridge ? null : HOSTED_APP_CHANNEL; const handleUpdateChannelChange = useCallback( (channel: DesktopUpdateChannel) => { @@ -314,36 +316,66 @@ function AboutVersionSection() { } /> - { - handleUpdateChannelChange(value as DesktopUpdateChannel); - }} - > - { + handleUpdateChannelChange(value as DesktopUpdateChannel); + }} > - - {selectedUpdateChannel === "nightly" ? "Nightly" : "Stable"} - - - - - Stable - - - Nightly - - - - } - /> + + + {selectedUpdateChannel === "nightly" ? "Nightly" : "Stable"} + + + + + Stable + + + Nightly + + + + } + /> + ) : selectedHostedAppChannel ? ( + { + if (value === selectedHostedAppChannel) return; + window.location.assign( + buildHostedChannelSelectionUrl({ channel: value as HostedAppChannel }), + ); + }} + > + + {HOSTED_APP_CHANNEL_LABEL} + + + + Latest + + + Nightly + + + + } + /> + ) : null} ); } diff --git a/apps/web/src/hostedPairing.test.ts b/apps/web/src/hostedPairing.test.ts index ef2b2eb5279..ea592780db5 100644 --- a/apps/web/src/hostedPairing.test.ts +++ b/apps/web/src/hostedPairing.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { + buildHostedChannelSelectionUrl, buildHostedPairingUrl, hasHostedPairingRequest, isHostedStaticApp, @@ -42,6 +43,21 @@ describe("hostedPairing", () => { expect(url.hash).toBe("#token=pairing-token"); }); + it("builds hosted channel selection URLs through the configured router origin", () => { + vi.stubEnv("VITE_HOSTED_APP_URL", "https://app.t3.codes"); + + const url = new URL( + buildHostedChannelSelectionUrl({ + channel: "nightly", + }), + ); + + expect(url.origin).toBe("https://app.t3.codes"); + expect(url.pathname).toBe("/__t3code/channel"); + expect(url.searchParams.get("channel")).toBe("nightly"); + expect(url.searchParams.has("next")).toBe(false); + }); + it("ignores incomplete hosted pairing requests", () => { expect( hasHostedPairingRequest(new URL("https://app.t3.codes/pair?host=backend.example.com")), diff --git a/apps/web/src/hostedPairing.ts b/apps/web/src/hostedPairing.ts index a44cfa70834..8ebe7ac2572 100644 --- a/apps/web/src/hostedPairing.ts +++ b/apps/web/src/hostedPairing.ts @@ -8,6 +8,8 @@ export interface HostedPairingRequest { readonly label: string; } +export type HostedAppChannel = "latest" | "nightly"; + function configuredHostedAppUrl(): string { return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL; } @@ -68,3 +70,11 @@ export function buildHostedPairingUrl(input: { return setPairingTokenOnUrl(url, input.token).toString(); } + +export function buildHostedChannelSelectionUrl(input: { + readonly channel: HostedAppChannel; +}): string { + const url = new URL("/__t3code/channel", configuredHostedAppUrl()); + url.searchParams.set("channel", input.channel); + return url.toString(); +} diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 99c5a65d85e..75ee936603c 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -6,6 +6,7 @@ interface ImportMetaEnv { readonly VITE_HTTP_URL: string; readonly VITE_WS_URL: string; readonly VITE_HOSTED_APP_URL: string; + readonly VITE_HOSTED_APP_CHANNEL: string; readonly APP_VERSION: string; } diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 033144d714b..0a23203c7b0 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -35,5 +35,5 @@ } ] }, - "include": ["src", "vite.config.ts", "test"] + "include": ["src", "vite.config.ts", "vercel.ts", "test"] } diff --git a/apps/web/vercel.json b/apps/web/vercel.json deleted file mode 100644 index ecfa7a47fc9..00000000000 --- a/apps/web/vercel.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "buildCommand": "turbo build --filter @t3tools/web && bun ../../scripts/apply-web-brand-assets.ts production", - "installCommand": "bun add -g turbo && bun install --filter '@t3tools/contracts' --filter '@t3tools/client-runtime' --filter '@t3tools/scripts' --filter '@t3tools/web'", - "rewrites": [ - { - "source": "/(.*)", - "destination": "/index.html" - } - ] -} diff --git a/apps/web/vercel.ts b/apps/web/vercel.ts new file mode 100644 index 00000000000..c01b115109e --- /dev/null +++ b/apps/web/vercel.ts @@ -0,0 +1,57 @@ +import { matchers, routes, type VercelConfig } from "@vercel/config/v1"; + +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"; + +function channelCookie(channel: "latest" | "nightly"): string { + return [ + `${HOSTED_WEB_CHANNEL_COOKIE}=${channel}`, + "Path=/", + "Max-Age=31536000", + "HttpOnly", + "Secure", + "SameSite=Lax", + ].join("; "); +} + +export const config: VercelConfig = { + buildCommand: + "turbo build --filter @t3tools/web && bun ../../scripts/apply-web-brand-assets.ts production", + git: { + deploymentEnabled: false, + }, + installCommand: + "bun add -g turbo && bun install --filter '@t3tools/contracts' --filter '@t3tools/client-runtime' --filter '@t3tools/scripts' --filter '@t3tools/web'", + routes: [ + { + src: "/__t3code/channel", + has: [matchers.query("channel", "nightly")], + headers: { + Location: "/", + "Set-Cookie": channelCookie("nightly"), + }, + status: 302, + }, + { + src: "/__t3code/channel", + headers: { + Location: "/", + "Set-Cookie": channelCookie("latest"), + }, + status: 302, + }, + { + src: "/(.*)", + has: [matchers.host(ROUTER_HOST), matchers.cookie(HOSTED_WEB_CHANNEL_COOKIE, "nightly")], + dest: `${NIGHTLY_ORIGIN}/$1`, + }, + { + src: "/(.*)", + has: [matchers.host(ROUTER_HOST)], + dest: `${LATEST_ORIGIN}/$1`, + }, + ], + rewrites: [routes.rewrite("/(.*)", "/index.html")], +}; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 07162c8768d..38819e28d73 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -8,14 +8,20 @@ import pkg from "./package.json" with { type: "json" }; const port = Number(process.env.PORT ?? 5733); const host = process.env.HOST?.trim() || "localhost"; const configuredWsUrl = process.env.VITE_WS_URL?.trim(); +const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || ""; +const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version; const configuredHostedAppUrl = (() => { + const explicitHostedAppUrl = process.env.VITE_HOSTED_APP_URL?.trim(); + if (explicitHostedAppUrl) { + return explicitHostedAppUrl; + } if (process.env.VERCEL_ENV === "production" && process.env.VERCEL_PROJECT_PRODUCTION_URL) { return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`; } if (process.env.VERCEL_URL) { return `https://${process.env.VERCEL_URL}`; } - return process.env.VITE_HOSTED_APP_URL?.trim(); + return undefined; })(); const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase(); @@ -76,7 +82,8 @@ export default defineConfig({ // In dev mode, tell the web app where the WebSocket server lives "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""), "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""), - "import.meta.env.APP_VERSION": JSON.stringify(pkg.version), + "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel), + "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion), }, resolve: { tsconfigPaths: true, diff --git a/bun.lock b/bun.lock index e00ceb9e75c..d9d3baaf8e7 100644 --- a/bun.lock +++ b/bun.lock @@ -124,6 +124,7 @@ "@types/babel__core": "^7.20.5", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@vercel/config": "^0.3.0", "@vitejs/plugin-react": "^6.0.0", "@vitest/browser-playwright": "^4.0.18", "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", @@ -927,6 +928,14 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@vercel/config": ["@vercel/config@0.3.0", "", { "dependencies": { "@vercel/routing-utils": "6.2.0", "pretty-cache-header": "^1.0.0", "zod": "^3.22.0" }, "bin": { "config": "dist/cli.js" } }, "sha512-Tf5k5y2F478oTiQcU5R8Ntix1UejE6NdduZnI7aa1XXxjCtifX1XdRS/D2uTjiQAwIL3pLa1LSAN80ABbba+TQ=="], + + "@vercel/functions": ["@vercel/functions@3.5.0", "", { "dependencies": { "@vercel/oidc": "3.4.0" }, "peerDependencies": { "@aws-sdk/credential-provider-web-identity": "*" }, "optionalPeers": ["@aws-sdk/credential-provider-web-identity"] }, "sha512-+RokZ+4gkYyOsKBuJ29cQ8iSZG123LLJbZfPry20kkTgrN9U0277La4feP4DnWVo3sGoYa4plCEKY9XKUYoX9g=="], + + "@vercel/oidc": ["@vercel/oidc@3.4.0", "", {}, "sha512-p0sKfHkfRmMaqqDwNL4tjnX9TgRrLMlEtUjIxfrEns8pOxz1R9ztqOVI+ehqiq93/2/HnfPe/UBZkfAZwnx0UA=="], + + "@vercel/routing-utils": ["@vercel/routing-utils@6.2.0", "", { "dependencies": { "path-to-regexp": "6.1.0", "path-to-regexp-updated": "npm:path-to-regexp@6.3.0" }, "optionalDependencies": { "ajv": "^6.12.3" } }, "sha512-YI2cGYZmJKEyGSEgf5Fw5rVuW7X1bTOQ7/pnLRNTFMH3YB3qqP5erCLCJGv2kIvNo1iQAPINHQRJj6ykEdGoPg=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], "@vitest/browser": ["@vitest/browser@4.1.0", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.0", "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.0.3", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.0" } }, "sha512-tG/iOrgbiHQks0ew7CdelUyNEHkv8NLrt+CqdTivIuoSnXvO7scWMn4Kqo78/UGY1NJ6Hv+vp8BvRnED/bjFdQ=="], @@ -1241,6 +1250,8 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], @@ -1685,6 +1696,8 @@ "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + "path-to-regexp-updated": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], @@ -1707,6 +1720,8 @@ "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + "pretty-cache-header": ["pretty-cache-header@1.0.0", "", { "dependencies": { "timestring": "^6.0.0" } }, "sha512-xtXazslu25CdnGnUkByU1RoOjK55TqwatJkjjJLg5ZAdz2Lngko/mmaUgeET36P2GMlNwh3fdM7FWBO717pNcw=="], + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], @@ -1717,6 +1732,8 @@ "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "pure-rand": ["pure-rand@8.1.0", "", {}, "sha512-53B3MB8wetRdD6JZ4W/0gDKaOvKwuXrEmV1auQc0hASWge8rieKV4PCCVNVbJ+i24miiubb4c/B+dg8Ho0ikYw=="], "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], @@ -1897,6 +1914,8 @@ "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + "timestring": ["timestring@6.0.0", "", {}, "sha512-wMctrWD2HZZLuIlchlkE2dfXJh7J2KDI9Dwl+2abPYg0mswQHfOAyQW3jJg1pY5VfttSINZuKcXoB3FGypVklA=="], + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], @@ -2019,6 +2038,8 @@ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], @@ -2193,6 +2214,12 @@ "@tanstack/router-utils/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + "@vercel/config/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@vercel/routing-utils/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "@vercel/routing-utils/path-to-regexp": ["path-to-regexp@6.1.0", "", {}, "sha512-h9DqehX3zZZDCEm+xbfU0ZmwCGFCAAraPJWMXJ4+v32NjZJilVg3k1TcKsRgIb8IQ/izZSaydDc1OhJCZvs2Dw=="], + "@vitest/browser/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -2317,6 +2344,8 @@ "@tanstack/router-plugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "ast-kit/@babel/parser/@babel/types": ["@babel/types@8.0.0-rc.2", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.2", "@babel/helper-validator-identifier": "^8.0.0-rc.2" } }, "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw=="], "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], diff --git a/docs/release.md b/docs/release.md index fc17d3e39db..640fdc333c9 100644 --- a/docs/release.md +++ b/docs/release.md @@ -24,8 +24,61 @@ This document covers the unified release workflow for stable and nightly desktop - 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 - Signing is optional and auto-detected per platform from secrets. +## 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. + +Required GitHub Actions secrets: + +- `VERCEL_TOKEN` +- `VERCEL_ORG_ID` +- `VERCEL_PROJECT_ID` + +Optional GitHub Actions variables: + +- `VERCEL_TEAM_SLUG`: required only if the token needs an explicit Vercel team scope. +- `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`. + +Required Vercel domains: + +- `app.t3.codes`: the stable router domain users open. +- `latest.app.t3.codes`: channel alias updated by stable releases. +- `nightly.app.t3.codes`: channel alias updated by nightly 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 +`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. It also passes +`VITE_HOSTED_APP_CHANNEL=latest|nightly`, 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. + +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. +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. +4. Promote or alias one deployment containing the router rules in `apps/web/vercel.ts` to + `app.t3.codes` once. Future release jobs should only update the channel + aliases. + ## Nightly builds - Workflow: `.github/workflows/release.yml`