| author | |
| committer | |
| log | aa8b9f2286fd8c4fcc36a240e34e8e89a3d793fa |
| tree | 6473fa52331efef3298e0ac83b301e5093e7d242 |
| parent | 073eb389d3b831ea3622fbb1a98a9fea6454a6ba |
| signature | Signed by PGP key B5690EEEBB952194 |
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: |
| 567 | 567 | fail_on_unmatched_files: true |
| 568 | 568 | token: ${{ steps.app_token.outputs.token }} |
| 569 | 569 | |
| 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 | ||
| 570 | 654 | finalize: |
| 571 | 655 | name: Finalize release |
| 572 | 656 | if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'stable' }} |
| ... | ... | @@ -651,8 +735,9 @@ jobs: |
| 651 | 735 | always() && !cancelled() && |
| 652 | 736 | needs.preflight.result == 'success' && |
| 653 | 737 | needs.release.result == 'success' && |
| 738 | needs.deploy_web.result == 'success' && | |
| 654 | 739 | (needs.finalize.result == 'success' || needs.finalize.result == 'skipped') |
| 655 | needs: [preflight, release, finalize] | |
| 740 | needs: [preflight, release, deploy_web, finalize] | |
| 656 | 741 | runs-on: blacksmith-8vcpu-ubuntu-2404 |
| 657 | 742 | timeout-minutes: 10 |
| 658 | 743 | steps: |
apps/web/package.json+1| ... | ... | @@ -50,6 +50,7 @@ |
| 50 | 50 | "@types/babel__core": "^7.20.5", |
| 51 | 51 | "@types/react": "^19.0.0", |
| 52 | 52 | "@types/react-dom": "^19.0.0", |
| 53 | "@vercel/config": "^0.3.0", | |
| 53 | 54 | "@vitejs/plugin-react": "^6.0.0", |
| 54 | 55 | "@vitest/browser-playwright": "^4.0.18", |
| 55 | 56 | "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", |
apps/web/src/branding.test.ts+18| ... | ... | @@ -34,4 +34,22 @@ describe("branding", () => { |
| 34 | 34 | expect(branding.APP_STAGE_LABEL).toBe("Nightly"); |
| 35 | 35 | expect(branding.APP_DISPLAY_NAME).toBe("T3 Code (Nightly)"); |
| 36 | 36 | }); |
| 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 | }); | |
| 37 | 55 | }); |
apps/web/src/branding.ts+5| ... | ... | @@ -9,6 +9,7 @@ function readInjectedDesktopAppBranding(): DesktopAppBranding | null { |
| 9 | 9 | } |
| 10 | 10 | |
| 11 | 11 | const injectedDesktopAppBranding = readInjectedDesktopAppBranding(); |
| 12 | const hostedAppChannel = import.meta.env.VITE_HOSTED_APP_CHANNEL?.trim().toLowerCase(); | |
| 12 | 13 | |
| 13 | 14 | export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "T3 Code"; |
| 14 | 15 | export const APP_STAGE_LABEL = |
| ... | ... | @@ -16,3 +17,7 @@ export const APP_STAGE_LABEL = |
| 16 | 17 | export const APP_DISPLAY_NAME = |
| 17 | 18 | injectedDesktopAppBranding?.displayName ?? `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; |
| 18 | 19 | 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+62-30| ... | ... | @@ -15,7 +15,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime"; |
| 15 | 15 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; |
| 16 | 16 | import { createModelSelection } from "@t3tools/shared/model"; |
| 17 | 17 | 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"; | |
| 19 | 19 | import { |
| 20 | 20 | canCheckForUpdate, |
| 21 | 21 | getDesktopUpdateButtonTooltip, |
| ... | ... | @@ -26,6 +26,7 @@ import { |
| 26 | 26 | import { ProviderModelPicker } from "../chat/ProviderModelPicker"; |
| 27 | 27 | import { TraitsPicker } from "../chat/TraitsPicker"; |
| 28 | 28 | import { isElectron } from "../../env"; |
| 29 | import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; | |
| 29 | 30 | import { useTheme } from "../../hooks/useTheme"; |
| 30 | 31 | import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; |
| 31 | 32 | import { useThreadActions } from "../../hooks/useThreadActions"; |
| ... | ... | @@ -162,6 +163,7 @@ function AboutVersionSection() { |
| 162 | 163 | const updateState = updateStateQuery.data ?? null; |
| 163 | 164 | const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge); |
| 164 | 165 | const selectedUpdateChannel = updateState?.channel ?? "latest"; |
| 166 | const selectedHostedAppChannel = hasDesktopBridge ? null : HOSTED_APP_CHANNEL; | |
| 165 | 167 | |
| 166 | 168 | const handleUpdateChannelChange = useCallback( |
| 167 | 169 | (channel: DesktopUpdateChannel) => { |
| ... | ... | @@ -314,36 +316,66 @@ function AboutVersionSection() { |
| 314 | 316 | </Tooltip> |
| 315 | 317 | } |
| 316 | 318 | /> |
| 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 | }} | |
| 331 | 329 | > |
| 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} | |
| 347 | 379 | </> |
| 348 | 380 | ); |
| 349 | 381 | } |
apps/web/src/hostedPairing.test.ts+16| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | buildHostedChannelSelectionUrl, | |
| 4 | 5 | buildHostedPairingUrl, |
| 5 | 6 | hasHostedPairingRequest, |
| 6 | 7 | isHostedStaticApp, |
| ... | ... | @@ -42,6 +43,21 @@ describe("hostedPairing", () => { |
| 42 | 43 | expect(url.hash).toBe("#token=pairing-token"); |
| 43 | 44 | }); |
| 44 | 45 | |
| 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 | ||
| 45 | 61 | it("ignores incomplete hosted pairing requests", () => { |
| 46 | 62 | expect( |
| 47 | 63 | 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 { |
| 8 | 8 | readonly label: string; |
| 9 | 9 | } |
| 10 | 10 | |
| 11 | export type HostedAppChannel = "latest" | "nightly"; | |
| 12 | ||
| 11 | 13 | function configuredHostedAppUrl(): string { |
| 12 | 14 | return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL; |
| 13 | 15 | } |
| ... | ... | @@ -68,3 +70,11 @@ export function buildHostedPairingUrl(input: { |
| 68 | 70 | |
| 69 | 71 | return setPairingTokenOnUrl(url, input.token).toString(); |
| 70 | 72 | } |
| 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+1| ... | ... | @@ -6,6 +6,7 @@ interface ImportMetaEnv { |
| 6 | 6 | readonly VITE_HTTP_URL: string; |
| 7 | 7 | readonly VITE_WS_URL: string; |
| 8 | 8 | readonly VITE_HOSTED_APP_URL: string; |
| 9 | readonly VITE_HOSTED_APP_CHANNEL: string; | |
| 9 | 10 | readonly APP_VERSION: string; |
| 10 | 11 | } |
| 11 | 12 |
apps/web/tsconfig.json+1-1| ... | ... | @@ -35,5 +35,5 @@ |
| 35 | 35 | } |
| 36 | 36 | ] |
| 37 | 37 | }, |
| 38 | "include": ["src", "vite.config.ts", "test"] | |
| 38 | "include": ["src", "vite.config.ts", "vercel.ts", "test"] | |
| 39 | 39 | } |
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 @@ |
| 1 | import { matchers, routes, type VercelConfig } from "@vercel/config/v1"; | |
| 2 | ||
| 3 | const ROUTER_HOST = "app.t3.codes"; | |
| 4 | const HOSTED_WEB_CHANNEL_COOKIE = "t3code_web_channel"; | |
| 5 | const LATEST_ORIGIN = "https://latest.app.t3.codes"; | |
| 6 | const NIGHTLY_ORIGIN = "https://nightly.app.t3.codes"; | |
| 7 | ||
| 8 | function 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 | ||
| 19 | export 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" }; |
| 8 | 8 | const port = Number(process.env.PORT ?? 5733); |
| 9 | 9 | const host = process.env.HOST?.trim() || "localhost"; |
| 10 | 10 | const configuredWsUrl = process.env.VITE_WS_URL?.trim(); |
| 11 | const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || ""; | |
| 12 | const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version; | |
| 11 | 13 | const configuredHostedAppUrl = (() => { |
| 14 | const explicitHostedAppUrl = process.env.VITE_HOSTED_APP_URL?.trim(); | |
| 15 | if (explicitHostedAppUrl) { | |
| 16 | return explicitHostedAppUrl; | |
| 17 | } | |
| 12 | 18 | if (process.env.VERCEL_ENV === "production" && process.env.VERCEL_PROJECT_PRODUCTION_URL) { |
| 13 | 19 | return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`; |
| 14 | 20 | } |
| 15 | 21 | if (process.env.VERCEL_URL) { |
| 16 | 22 | return `https://${process.env.VERCEL_URL}`; |
| 17 | 23 | } |
| 18 | return process.env.VITE_HOSTED_APP_URL?.trim(); | |
| 24 | return undefined; | |
| 19 | 25 | })(); |
| 20 | 26 | const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase(); |
| 21 | 27 | |
| ... | ... | @@ -76,7 +82,8 @@ export default defineConfig({ |
| 76 | 82 | // In dev mode, tell the web app where the WebSocket server lives |
| 77 | 83 | "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""), |
| 78 | 84 | "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), | |
| 80 | 87 | }, |
| 81 | 88 | resolve: { |
| 82 | 89 | tsconfigPaths: true, |
bun.lock+29| ... | ... | @@ -124,6 +124,7 @@ |
| 124 | 124 | "@types/babel__core": "^7.20.5", |
| 125 | 125 | "@types/react": "^19.0.0", |
| 126 | 126 | "@types/react-dom": "^19.0.0", |
| 127 | "@vercel/config": "^0.3.0", | |
| 127 | 128 | "@vitejs/plugin-react": "^6.0.0", |
| 128 | 129 | "@vitest/browser-playwright": "^4.0.18", |
| 129 | 130 | "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", |
| ... | ... | @@ -927,6 +928,14 @@ |
| 927 | 928 | |
| 928 | 929 | "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], |
| 929 | 930 | |
| 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 | ||
| 930 | 939 | "@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=="], |
| 931 | 940 | |
| 932 | 941 | "@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 @@ |
| 1241 | 1250 | |
| 1242 | 1251 | "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], |
| 1243 | 1252 | |
| 1253 | "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], | |
| 1254 | ||
| 1244 | 1255 | "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], |
| 1245 | 1256 | |
| 1246 | 1257 | "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], |
| ... | ... | @@ -1685,6 +1696,8 @@ |
| 1685 | 1696 | |
| 1686 | 1697 | "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], |
| 1687 | 1698 | |
| 1699 | "path-to-regexp-updated": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], | |
| 1700 | ||
| 1688 | 1701 | "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], |
| 1689 | 1702 | |
| 1690 | 1703 | "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], |
| ... | ... | @@ -1707,6 +1720,8 @@ |
| 1707 | 1720 | |
| 1708 | 1721 | "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], |
| 1709 | 1722 | |
| 1723 | "pretty-cache-header": ["pretty-cache-header@1.0.0", "", { "dependencies": { "timestring": "^6.0.0" } }, "sha512-xtXazslu25CdnGnUkByU1RoOjK55TqwatJkjjJLg5ZAdz2Lngko/mmaUgeET36P2GMlNwh3fdM7FWBO717pNcw=="], | |
| 1724 | ||
| 1710 | 1725 | "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], |
| 1711 | 1726 | |
| 1712 | 1727 | "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], |
| ... | ... | @@ -1717,6 +1732,8 @@ |
| 1717 | 1732 | |
| 1718 | 1733 | "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], |
| 1719 | 1734 | |
| 1735 | "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], | |
| 1736 | ||
| 1720 | 1737 | "pure-rand": ["pure-rand@8.1.0", "", {}, "sha512-53B3MB8wetRdD6JZ4W/0gDKaOvKwuXrEmV1auQc0hASWge8rieKV4PCCVNVbJ+i24miiubb4c/B+dg8Ho0ikYw=="], |
| 1721 | 1738 | |
| 1722 | 1739 | "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], |
| ... | ... | @@ -1897,6 +1914,8 @@ |
| 1897 | 1914 | |
| 1898 | 1915 | "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], |
| 1899 | 1916 | |
| 1917 | "timestring": ["timestring@6.0.0", "", {}, "sha512-wMctrWD2HZZLuIlchlkE2dfXJh7J2KDI9Dwl+2abPYg0mswQHfOAyQW3jJg1pY5VfttSINZuKcXoB3FGypVklA=="], | |
| 1918 | ||
| 1900 | 1919 | "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], |
| 1901 | 1920 | |
| 1902 | 1921 | "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], |
| ... | ... | @@ -2019,6 +2038,8 @@ |
| 2019 | 2038 | |
| 2020 | 2039 | "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=="], |
| 2021 | 2040 | |
| 2041 | "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], | |
| 2042 | ||
| 2022 | 2043 | "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=="], |
| 2023 | 2044 | |
| 2024 | 2045 | "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], |
| ... | ... | @@ -2193,6 +2214,12 @@ |
| 2193 | 2214 | |
| 2194 | 2215 | "@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=="], |
| 2195 | 2216 | |
| 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 | ||
| 2196 | 2223 | "@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=="], |
| 2197 | 2224 | |
| 2198 | 2225 | "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], |
| ... | ... | @@ -2317,6 +2344,8 @@ |
| 2317 | 2344 | |
| 2318 | 2345 | "@tanstack/router-plugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], |
| 2319 | 2346 | |
| 2347 | "@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], | |
| 2348 | ||
| 2320 | 2349 | "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=="], |
| 2321 | 2350 | |
| 2322 | 2351 | "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 |
| 24 | 24 | - Publishes the CLI package (`apps/server`, npm package `t3`) with OIDC trusted publishing from the same workflow file: |
| 25 | 25 | - stable releases publish npm dist-tag `latest` |
| 26 | 26 | - 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 | |
| 27 | 30 | - Signing is optional and auto-detected per platform from secrets. |
| 28 | 31 | |
| 32 | ## Hosted web app release deployment | |
| 33 | ||
| 34 | The hosted app is intentionally not deployed by Vercel's Git integration. The | |
| 35 | web project disables automatic Git deployments in `apps/web/vercel.ts` via | |
| 36 | `git.deploymentEnabled: false`, and `.github/workflows/release.yml` deploys the | |
| 37 | web app with Vercel CLI after the GitHub Release succeeds. | |
| 38 | ||
| 39 | Required GitHub Actions secrets: | |
| 40 | ||
| 41 | - `VERCEL_TOKEN` | |
| 42 | - `VERCEL_ORG_ID` | |
| 43 | - `VERCEL_PROJECT_ID` | |
| 44 | ||
| 45 | Optional 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 | ||
| 52 | Required 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 | ||
| 58 | The router domain uses `apps/web/vercel.ts` routes. Users opt into a channel by | |
| 59 | visiting `/__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 | |
| 62 | the matching channel alias. | |
| 63 | ||
| 64 | The release deploy job rewrites release package versions before upload so the | |
| 65 | hosted app's About panel renders the release version. It also passes | |
| 66 | `VITE_HOSTED_APP_CHANNEL=latest|nightly`, which renders the hosted update track | |
| 67 | selector in the About panel. Changing the selector navigates through | |
| 68 | `/__t3code/channel` on the router domain so the user's channel cookie is updated | |
| 69 | before redirecting to the hosted app root. | |
| 70 | ||
| 71 | One-time Vercel dashboard setup: | |
| 72 | ||
| 73 | 1. Confirm the web project root directory remains `apps/web`. | |
| 74 | 2. Add the three domains above to the web project. | |
| 75 | 3. 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. | |
| 78 | 4. 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 | ||
| 29 | 82 | ## Nightly builds |
| 30 | 83 | |
| 31 | 84 | - Workflow: `.github/workflows/release.yml` |