authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-05-08 12:47:08-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-08 12:47:08-07:00
logaa8b9f2286fd8c4fcc36a240e34e8e89a3d793fa
tree6473fa52331efef3298e0ac83b301e5093e7d242
parent073eb389d3b831ea3622fbb1a98a9fea6454a6ba
signature Signed by PGP key B5690EEEBB952194

Deploy hosted web app from release workflow (#2507)

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

14 files changed, 348 insertions(+), 44 deletions(-)

.github/workflows/release.yml+86-1
......@@ -567,6 +567,90 @@ jobs:
567567 fail_on_unmatched_files: true
568568 token: ${{ steps.app_token.outputs.token }}
569569
570 deploy_web:
571 name: Deploy hosted web app
572 needs: [preflight, release]
573 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }}
574 runs-on: blacksmith-8vcpu-ubuntu-2404
575 timeout-minutes: 10
576 env:
577 VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
578 VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
579 VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
580 T3CODE_WEB_ROUTER_URL: ${{ vars.T3CODE_WEB_ROUTER_URL }}
581 T3CODE_WEB_LATEST_DOMAIN: ${{ vars.T3CODE_WEB_LATEST_DOMAIN }}
582 T3CODE_WEB_NIGHTLY_DOMAIN: ${{ vars.T3CODE_WEB_NIGHTLY_DOMAIN }}
583 VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }}
584 steps:
585 - name: Checkout
586 uses: actions/checkout@v6
587 with:
588 ref: ${{ needs.preflight.outputs.ref }}
589
590 - name: Setup Bun
591 uses: oven-sh/setup-bun@v2
592 with:
593 bun-version-file: package.json
594
595 - name: Setup Node
596 uses: actions/setup-node@v6
597 with:
598 node-version-file: package.json
599
600 - name: Install release tooling dependencies
601 run: bun install --frozen-lockfile --filter=@t3tools/scripts --filter=@t3tools/web
602
603 - name: Align package versions to release version
604 run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"
605
606 - name: Refresh release lockfile
607 run: bun install --lockfile-only --ignore-scripts
608
609 - name: Deploy and alias channel
610 shell: bash
611 run: |
612 set -euo pipefail
613
614 if [[ -z "${VERCEL_TOKEN:-}" || -z "${VERCEL_ORG_ID:-}" || -z "${VERCEL_PROJECT_ID:-}" ]]; then
615 echo "Missing one or more required Vercel secrets: VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID." >&2
616 exit 1
617 fi
618
619 router_url="${T3CODE_WEB_ROUTER_URL:-https://app.t3.codes}"
620 latest_domain="${T3CODE_WEB_LATEST_DOMAIN:-latest.app.t3.codes}"
621 nightly_domain="${T3CODE_WEB_NIGHTLY_DOMAIN:-nightly.app.t3.codes}"
622
623 if [[ "${{ needs.preflight.outputs.release_channel }}" == "stable" ]]; then
624 channel_domain="$latest_domain"
625 channel_name="latest"
626 else
627 channel_domain="$nightly_domain"
628 channel_name="nightly"
629 fi
630
631 vercel_scope_args=()
632 if [[ -n "${VERCEL_TEAM_SLUG:-}" ]]; then
633 vercel_scope_args=(--scope "$VERCEL_TEAM_SLUG")
634 fi
635
636 echo "Deploying hosted web app for $channel_name channel."
637 deployment_url="$(
638 bunx vercel@53.1.1 deploy apps/web \
639 --prod \
640 --skip-domain \
641 --yes \
642 --token "$VERCEL_TOKEN" \
643 "${vercel_scope_args[@]}" \
644 --build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \
645 --build-env "VITE_HOSTED_APP_URL=$router_url" \
646 --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name"
647 )"
648
649 echo "Aliasing $deployment_url to $channel_domain."
650 bunx vercel@53.1.1 alias set "$deployment_url" "$channel_domain" \
651 --token "$VERCEL_TOKEN" \
652 "${vercel_scope_args[@]}"
653
570654 finalize:
571655 name: Finalize release
572656 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'stable' }}
......@@ -651,8 +735,9 @@ jobs:
651735 always() && !cancelled() &&
652736 needs.preflight.result == 'success' &&
653737 needs.release.result == 'success' &&
738 needs.deploy_web.result == 'success' &&
654739 (needs.finalize.result == 'success' || needs.finalize.result == 'skipped')
655 needs: [preflight, release, finalize]
740 needs: [preflight, release, deploy_web, finalize]
656741 runs-on: blacksmith-8vcpu-ubuntu-2404
657742 timeout-minutes: 10
658743 steps:
apps/web/package.json+1
......@@ -50,6 +50,7 @@
5050 "@types/babel__core": "^7.20.5",
5151 "@types/react": "^19.0.0",
5252 "@types/react-dom": "^19.0.0",
53 "@vercel/config": "^0.3.0",
5354 "@vitejs/plugin-react": "^6.0.0",
5455 "@vitest/browser-playwright": "^4.0.18",
5556 "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",
apps/web/src/branding.test.ts+18
......@@ -34,4 +34,22 @@ describe("branding", () => {
3434 expect(branding.APP_STAGE_LABEL).toBe("Nightly");
3535 expect(branding.APP_DISPLAY_NAME).toBe("T3 Code (Nightly)");
3636 });
37
38 it("normalizes hosted app channel metadata", async () => {
39 vi.stubEnv("VITE_HOSTED_APP_CHANNEL", "nightly");
40
41 const branding = await import("./branding");
42
43 expect(branding.HOSTED_APP_CHANNEL).toBe("nightly");
44 expect(branding.HOSTED_APP_CHANNEL_LABEL).toBe("Nightly");
45 });
46
47 it("ignores unknown hosted app channels", async () => {
48 vi.stubEnv("VITE_HOSTED_APP_CHANNEL", "preview");
49
50 const branding = await import("./branding");
51
52 expect(branding.HOSTED_APP_CHANNEL).toBeNull();
53 expect(branding.HOSTED_APP_CHANNEL_LABEL).toBeNull();
54 });
3755});
apps/web/src/branding.ts+5
......@@ -9,6 +9,7 @@ function readInjectedDesktopAppBranding(): DesktopAppBranding | null {
99}
1010
1111const injectedDesktopAppBranding = readInjectedDesktopAppBranding();
12const hostedAppChannel = import.meta.env.VITE_HOSTED_APP_CHANNEL?.trim().toLowerCase();
1213
1314export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "T3 Code";
1415export const APP_STAGE_LABEL =
......@@ -16,3 +17,7 @@ export const APP_STAGE_LABEL =
1617export const APP_DISPLAY_NAME =
1718 injectedDesktopAppBranding?.displayName ?? `${APP_BASE_NAME} (${APP_STAGE_LABEL})`;
1819export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0";
20export const HOSTED_APP_CHANNEL =
21 hostedAppChannel === "latest" || hostedAppChannel === "nightly" ? hostedAppChannel : null;
22export const HOSTED_APP_CHANNEL_LABEL =
23 HOSTED_APP_CHANNEL === "nightly" ? "Nightly" : HOSTED_APP_CHANNEL === "latest" ? "Latest" : null;
apps/web/src/components/settings/SettingsPanels.tsx+62-30
......@@ -15,7 +15,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime";
1515import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
1616import { createModelSelection } from "@t3tools/shared/model";
1717import * as Equal from "effect/Equal";
18import { APP_VERSION } from "../../branding";
18import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding";
1919import {
2020 canCheckForUpdate,
2121 getDesktopUpdateButtonTooltip,
......@@ -26,6 +26,7 @@ import {
2626import { ProviderModelPicker } from "../chat/ProviderModelPicker";
2727import { TraitsPicker } from "../chat/TraitsPicker";
2828import { isElectron } from "../../env";
29import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing";
2930import { useTheme } from "../../hooks/useTheme";
3031import { useSettings, useUpdateSettings } from "../../hooks/useSettings";
3132import { useThreadActions } from "../../hooks/useThreadActions";
......@@ -162,6 +163,7 @@ function AboutVersionSection() {
162163 const updateState = updateStateQuery.data ?? null;
163164 const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge);
164165 const selectedUpdateChannel = updateState?.channel ?? "latest";
166 const selectedHostedAppChannel = hasDesktopBridge ? null : HOSTED_APP_CHANNEL;
165167
166168 const handleUpdateChannelChange = useCallback(
167169 (channel: DesktopUpdateChannel) => {
......@@ -314,36 +316,66 @@ function AboutVersionSection() {
314316 </Tooltip>
315317 }
316318 />
317 <SettingsRow
318 title="Update track"
319 description="Stable follows full releases. Nightly follows the nightly desktop channel and can switch back to stable immediately."
320 control={
321 <Select
322 value={selectedUpdateChannel}
323 onValueChange={(value) => {
324 handleUpdateChannelChange(value as DesktopUpdateChannel);
325 }}
326 >
327 <SelectTrigger
328 className="w-full sm:w-40"
329 aria-label="Update track"
330 disabled={!hasDesktopBridge || isChangingUpdateChannel}
319 {hasDesktopBridge ? (
320 <SettingsRow
321 title="Update track"
322 description="Stable follows full releases. Nightly follows the nightly desktop channel and can switch back to stable immediately."
323 control={
324 <Select
325 value={selectedUpdateChannel}
326 onValueChange={(value) => {
327 handleUpdateChannelChange(value as DesktopUpdateChannel);
328 }}
331329 >
332 <SelectValue>
333 {selectedUpdateChannel === "nightly" ? "Nightly" : "Stable"}
334 </SelectValue>
335 </SelectTrigger>
336 <SelectPopup align="end" alignItemWithTrigger={false}>
337 <SelectItem hideIndicator value="latest">
338 Stable
339 </SelectItem>
340 <SelectItem hideIndicator value="nightly">
341 Nightly
342 </SelectItem>
343 </SelectPopup>
344 </Select>
345 }
346 />
330 <SelectTrigger
331 className="w-full sm:w-40"
332 aria-label="Update track"
333 disabled={isChangingUpdateChannel}
334 >
335 <SelectValue>
336 {selectedUpdateChannel === "nightly" ? "Nightly" : "Stable"}
337 </SelectValue>
338 </SelectTrigger>
339 <SelectPopup align="end" alignItemWithTrigger={false}>
340 <SelectItem hideIndicator value="latest">
341 Stable
342 </SelectItem>
343 <SelectItem hideIndicator value="nightly">
344 Nightly
345 </SelectItem>
346 </SelectPopup>
347 </Select>
348 }
349 />
350 ) : selectedHostedAppChannel ? (
351 <SettingsRow
352 title="Update track"
353 description="Switches the hosted app release channel."
354 control={
355 <Select
356 value={selectedHostedAppChannel}
357 onValueChange={(value) => {
358 if (value === selectedHostedAppChannel) return;
359 window.location.assign(
360 buildHostedChannelSelectionUrl({ channel: value as HostedAppChannel }),
361 );
362 }}
363 >
364 <SelectTrigger className="w-full sm:w-40" aria-label="Update track">
365 <SelectValue>{HOSTED_APP_CHANNEL_LABEL}</SelectValue>
366 </SelectTrigger>
367 <SelectPopup align="end" alignItemWithTrigger={false}>
368 <SelectItem hideIndicator value="latest">
369 Latest
370 </SelectItem>
371 <SelectItem hideIndicator value="nightly">
372 Nightly
373 </SelectItem>
374 </SelectPopup>
375 </Select>
376 }
377 />
378 ) : null}
347379 </>
348380 );
349381}
apps/web/src/hostedPairing.test.ts+16
......@@ -1,6 +1,7 @@
11import { afterEach, describe, expect, it, vi } from "vitest";
22
33import {
4 buildHostedChannelSelectionUrl,
45 buildHostedPairingUrl,
56 hasHostedPairingRequest,
67 isHostedStaticApp,
......@@ -42,6 +43,21 @@ describe("hostedPairing", () => {
4243 expect(url.hash).toBe("#token=pairing-token");
4344 });
4445
46 it("builds hosted channel selection URLs through the configured router origin", () => {
47 vi.stubEnv("VITE_HOSTED_APP_URL", "https://app.t3.codes");
48
49 const url = new URL(
50 buildHostedChannelSelectionUrl({
51 channel: "nightly",
52 }),
53 );
54
55 expect(url.origin).toBe("https://app.t3.codes");
56 expect(url.pathname).toBe("/__t3code/channel");
57 expect(url.searchParams.get("channel")).toBe("nightly");
58 expect(url.searchParams.has("next")).toBe(false);
59 });
60
4561 it("ignores incomplete hosted pairing requests", () => {
4662 expect(
4763 hasHostedPairingRequest(new URL("https://app.t3.codes/pair?host=backend.example.com")),
apps/web/src/hostedPairing.ts+10
......@@ -8,6 +8,8 @@ export interface HostedPairingRequest {
88 readonly label: string;
99}
1010
11export type HostedAppChannel = "latest" | "nightly";
12
1113function configuredHostedAppUrl(): string {
1214 return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL;
1315}
......@@ -68,3 +70,11 @@ export function buildHostedPairingUrl(input: {
6870
6971 return setPairingTokenOnUrl(url, input.token).toString();
7072}
73
74export function buildHostedChannelSelectionUrl(input: {
75 readonly channel: HostedAppChannel;
76}): string {
77 const url = new URL("/__t3code/channel", configuredHostedAppUrl());
78 url.searchParams.set("channel", input.channel);
79 return url.toString();
80}
apps/web/src/vite-env.d.ts+1
......@@ -6,6 +6,7 @@ interface ImportMetaEnv {
66 readonly VITE_HTTP_URL: string;
77 readonly VITE_WS_URL: string;
88 readonly VITE_HOSTED_APP_URL: string;
9 readonly VITE_HOSTED_APP_CHANNEL: string;
910 readonly APP_VERSION: string;
1011}
1112
apps/web/tsconfig.json+1-1
......@@ -35,5 +35,5 @@
3535 }
3636 ]
3737 },
38 "include": ["src", "vite.config.ts", "test"]
38 "include": ["src", "vite.config.ts", "vercel.ts", "test"]
3939}
apps/web/vercel.json deleted-10
......@@ -1,10 +0,0 @@
1{
2 "buildCommand": "turbo build --filter @t3tools/web && bun ../../scripts/apply-web-brand-assets.ts production",
3 "installCommand": "bun add -g turbo && bun install --filter '@t3tools/contracts' --filter '@t3tools/client-runtime' --filter '@t3tools/scripts' --filter '@t3tools/web'",
4 "rewrites": [
5 {
6 "source": "/(.*)",
7 "destination": "/index.html"
8 }
9 ]
10}
apps/web/vercel.ts created+57
......@@ -0,0 +1,57 @@
1import { matchers, routes, type VercelConfig } from "@vercel/config/v1";
2
3const ROUTER_HOST = "app.t3.codes";
4const HOSTED_WEB_CHANNEL_COOKIE = "t3code_web_channel";
5const LATEST_ORIGIN = "https://latest.app.t3.codes";
6const NIGHTLY_ORIGIN = "https://nightly.app.t3.codes";
7
8function channelCookie(channel: "latest" | "nightly"): string {
9 return [
10 `${HOSTED_WEB_CHANNEL_COOKIE}=${channel}`,
11 "Path=/",
12 "Max-Age=31536000",
13 "HttpOnly",
14 "Secure",
15 "SameSite=Lax",
16 ].join("; ");
17}
18
19export const config: VercelConfig = {
20 buildCommand:
21 "turbo build --filter @t3tools/web && bun ../../scripts/apply-web-brand-assets.ts production",
22 git: {
23 deploymentEnabled: false,
24 },
25 installCommand:
26 "bun add -g turbo && bun install --filter '@t3tools/contracts' --filter '@t3tools/client-runtime' --filter '@t3tools/scripts' --filter '@t3tools/web'",
27 routes: [
28 {
29 src: "/__t3code/channel",
30 has: [matchers.query("channel", "nightly")],
31 headers: {
32 Location: "/",
33 "Set-Cookie": channelCookie("nightly"),
34 },
35 status: 302,
36 },
37 {
38 src: "/__t3code/channel",
39 headers: {
40 Location: "/",
41 "Set-Cookie": channelCookie("latest"),
42 },
43 status: 302,
44 },
45 {
46 src: "/(.*)",
47 has: [matchers.host(ROUTER_HOST), matchers.cookie(HOSTED_WEB_CHANNEL_COOKIE, "nightly")],
48 dest: `${NIGHTLY_ORIGIN}/$1`,
49 },
50 {
51 src: "/(.*)",
52 has: [matchers.host(ROUTER_HOST)],
53 dest: `${LATEST_ORIGIN}/$1`,
54 },
55 ],
56 rewrites: [routes.rewrite("/(.*)", "/index.html")],
57};
apps/web/vite.config.ts+9-2
......@@ -8,14 +8,20 @@ import pkg from "./package.json" with { type: "json" };
88const port = Number(process.env.PORT ?? 5733);
99const host = process.env.HOST?.trim() || "localhost";
1010const configuredWsUrl = process.env.VITE_WS_URL?.trim();
11const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || "";
12const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version;
1113const configuredHostedAppUrl = (() => {
14 const explicitHostedAppUrl = process.env.VITE_HOSTED_APP_URL?.trim();
15 if (explicitHostedAppUrl) {
16 return explicitHostedAppUrl;
17 }
1218 if (process.env.VERCEL_ENV === "production" && process.env.VERCEL_PROJECT_PRODUCTION_URL) {
1319 return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`;
1420 }
1521 if (process.env.VERCEL_URL) {
1622 return `https://${process.env.VERCEL_URL}`;
1723 }
18 return process.env.VITE_HOSTED_APP_URL?.trim();
24 return undefined;
1925})();
2026const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase();
2127
......@@ -76,7 +82,8 @@ export default defineConfig({
7682 // In dev mode, tell the web app where the WebSocket server lives
7783 "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""),
7884 "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""),
79 "import.meta.env.APP_VERSION": JSON.stringify(pkg.version),
85 "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel),
86 "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion),
8087 },
8188 resolve: {
8289 tsconfigPaths: true,
bun.lock+29
......@@ -124,6 +124,7 @@
124124 "@types/babel__core": "^7.20.5",
125125 "@types/react": "^19.0.0",
126126 "@types/react-dom": "^19.0.0",
127 "@vercel/config": "^0.3.0",
127128 "@vitejs/plugin-react": "^6.0.0",
128129 "@vitest/browser-playwright": "^4.0.18",
129130 "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",
......@@ -927,6 +928,14 @@
927928
928929 "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
929930
931 "@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=="],
932
933 "@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=="],
934
935 "@vercel/oidc": ["@vercel/oidc@3.4.0", "", {}, "sha512-p0sKfHkfRmMaqqDwNL4tjnX9TgRrLMlEtUjIxfrEns8pOxz1R9ztqOVI+ehqiq93/2/HnfPe/UBZkfAZwnx0UA=="],
936
937 "@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=="],
938
930939 "@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=="],
931940
932941 "@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 @@
12411250
12421251 "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
12431252
1253 "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
1254
12441255 "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
12451256
12461257 "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
......@@ -1685,6 +1696,8 @@
16851696
16861697 "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
16871698
1699 "path-to-regexp-updated": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
1700
16881701 "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
16891702
16901703 "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
......@@ -1707,6 +1720,8 @@
17071720
17081721 "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
17091722
1723 "pretty-cache-header": ["pretty-cache-header@1.0.0", "", { "dependencies": { "timestring": "^6.0.0" } }, "sha512-xtXazslu25CdnGnUkByU1RoOjK55TqwatJkjjJLg5ZAdz2Lngko/mmaUgeET36P2GMlNwh3fdM7FWBO717pNcw=="],
1724
17101725 "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
17111726
17121727 "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
......@@ -1717,6 +1732,8 @@
17171732
17181733 "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
17191734
1735 "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
1736
17201737 "pure-rand": ["pure-rand@8.1.0", "", {}, "sha512-53B3MB8wetRdD6JZ4W/0gDKaOvKwuXrEmV1auQc0hASWge8rieKV4PCCVNVbJ+i24miiubb4c/B+dg8Ho0ikYw=="],
17211738
17221739 "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
......@@ -1897,6 +1914,8 @@
18971914
18981915 "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
18991916
1917 "timestring": ["timestring@6.0.0", "", {}, "sha512-wMctrWD2HZZLuIlchlkE2dfXJh7J2KDI9Dwl+2abPYg0mswQHfOAyQW3jJg1pY5VfttSINZuKcXoB3FGypVklA=="],
1918
19001919 "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
19011920
19021921 "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
......@@ -2019,6 +2038,8 @@
20192038
20202039 "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=="],
20212040
2041 "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
2042
20222043 "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=="],
20232044
20242045 "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="],
......@@ -2193,6 +2214,12 @@
21932214
21942215 "@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=="],
21952216
2217 "@vercel/config/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
2218
2219 "@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=="],
2220
2221 "@vercel/routing-utils/path-to-regexp": ["path-to-regexp@6.1.0", "", {}, "sha512-h9DqehX3zZZDCEm+xbfU0ZmwCGFCAAraPJWMXJ4+v32NjZJilVg3k1TcKsRgIb8IQ/izZSaydDc1OhJCZvs2Dw=="],
2222
21962223 "@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=="],
21972224
21982225 "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
......@@ -2317,6 +2344,8 @@
23172344
23182345 "@tanstack/router-plugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
23192346
2347 "@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
2348
23202349 "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=="],
23212350
23222351 "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
docs/release.md+53
......@@ -24,8 +24,61 @@ This document covers the unified release workflow for stable and nightly desktop
2424- Publishes the CLI package (`apps/server`, npm package `t3`) with OIDC trusted publishing from the same workflow file:
2525 - stable releases publish npm dist-tag `latest`
2626 - nightly releases publish npm dist-tag `nightly`
27- Deploys the hosted web app to Vercel only after a release is published:
28 - stable releases are aliased to the `latest` hosted app channel
29 - nightly releases are aliased to the `nightly` hosted app channel
2730- Signing is optional and auto-detected per platform from secrets.
2831
32## Hosted web app release deployment
33
34The hosted app is intentionally not deployed by Vercel's Git integration. The
35web project disables automatic Git deployments in `apps/web/vercel.ts` via
36`git.deploymentEnabled: false`, and `.github/workflows/release.yml` deploys the
37web app with Vercel CLI after the GitHub Release succeeds.
38
39Required GitHub Actions secrets:
40
41- `VERCEL_TOKEN`
42- `VERCEL_ORG_ID`
43- `VERCEL_PROJECT_ID`
44
45Optional GitHub Actions variables:
46
47- `VERCEL_TEAM_SLUG`: required only if the token needs an explicit Vercel team scope.
48- `T3CODE_WEB_ROUTER_URL`: defaults to `https://app.t3.codes`.
49- `T3CODE_WEB_LATEST_DOMAIN`: defaults to `latest.app.t3.codes`.
50- `T3CODE_WEB_NIGHTLY_DOMAIN`: defaults to `nightly.app.t3.codes`.
51
52Required Vercel domains:
53
54- `app.t3.codes`: the stable router domain users open.
55- `latest.app.t3.codes`: channel alias updated by stable releases.
56- `nightly.app.t3.codes`: channel alias updated by nightly releases.
57
58The router domain uses `apps/web/vercel.ts` routes. Users opt into a channel by
59visiting `/__t3code/channel?channel=latest` or
60`/__t3code/channel?channel=nightly`; the router stores the
61`t3code_web_channel` cookie and rewrites future requests on `app.t3.codes` to
62the matching channel alias.
63
64The release deploy job rewrites release package versions before upload so the
65hosted app's About panel renders the release version. It also passes
66`VITE_HOSTED_APP_CHANNEL=latest|nightly`, which renders the hosted update track
67selector in the About panel. Changing the selector navigates through
68`/__t3code/channel` on the router domain so the user's channel cookie is updated
69before redirecting to the hosted app root.
70
71One-time Vercel dashboard setup:
72
731. Confirm the web project root directory remains `apps/web`.
742. Add the three domains above to the web project.
753. Disable automatic Git deployments in the dashboard if desired; the committed
76 `vercel.ts` setting is the source-of-truth, but disconnecting Git in the
77 dashboard is also safe.
784. Promote or alias one deployment containing the router rules in `apps/web/vercel.ts` to
79 `app.t3.codes` once. Future release jobs should only update the channel
80 aliases.
81
2982## Nightly builds
3083
3184- Workflow: `.github/workflows/release.yml`