Skip to content

Commit aa8b9f2

Browse files
juliusmarmingecursoragentcursor[bot]
authored
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>
1 parent 073eb38 commit aa8b9f2

14 files changed

Lines changed: 348 additions & 44 deletions

File tree

.github/workflows/release.yml

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,15 @@ function readInjectedDesktopAppBranding(): DesktopAppBranding | null {
99
}
1010

1111
const injectedDesktopAppBranding = readInjectedDesktopAppBranding();
12+
const hostedAppChannel = import.meta.env.VITE_HOSTED_APP_CHANNEL?.trim().toLowerCase();
1213

1314
export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "T3 Code";
1415
export const APP_STAGE_LABEL =
1516
injectedDesktopAppBranding?.stageLabel ?? (import.meta.env.DEV ? "Dev" : "Alpha");
1617
export const APP_DISPLAY_NAME =
1718
injectedDesktopAppBranding?.displayName ?? `${APP_BASE_NAME} (${APP_STAGE_LABEL})`;
1819
export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0";
20+
export const HOSTED_APP_CHANNEL =
21+
hostedAppChannel === "latest" || hostedAppChannel === "nightly" ? hostedAppChannel : null;
22+
export const HOSTED_APP_CHANNEL_LABEL =
23+
HOSTED_APP_CHANNEL === "nightly" ? "Nightly" : HOSTED_APP_CHANNEL === "latest" ? "Latest" : null;

apps/web/src/components/settings/SettingsPanels.tsx

Lines changed: 62 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime";
1515
import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
1616
import { createModelSelection } from "@t3tools/shared/model";
1717
import * as Equal from "effect/Equal";
18-
import { APP_VERSION } from "../../branding";
18+
import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding";
1919
import {
2020
canCheckForUpdate,
2121
getDesktopUpdateButtonTooltip,
@@ -26,6 +26,7 @@ import {
2626
import { ProviderModelPicker } from "../chat/ProviderModelPicker";
2727
import { TraitsPicker } from "../chat/TraitsPicker";
2828
import { isElectron } from "../../env";
29+
import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing";
2930
import { useTheme } from "../../hooks/useTheme";
3031
import { useSettings, useUpdateSettings } from "../../hooks/useSettings";
3132
import { 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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { afterEach, describe, expect, it, vi } from "vitest";
22

33
import {
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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export interface HostedPairingRequest {
88
readonly label: string;
99
}
1010

11+
export type HostedAppChannel = "latest" | "nightly";
12+
1113
function 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+
74+
export 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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 0 additions & 10 deletions
This file was deleted.

0 commit comments

Comments
 (0)