1name: Web Preview
2
3# Label a PR `preview:web` to get a hosted-web preview deployment on Vercel for
4# that push and every subsequent push. The deployment is a plain (non-prod,
5# non-aliased) deploy into the existing hosted-web Vercel project, so the
6# latest/nightly channel aliases are never touched.
7#
8# The build intentionally omits the T3 Connect cloud config (Clerk keys, relay
9# URL): previews boot as the hosted-static app with manual pairing only. Pair a
10# server into a preview with `t3 pair --tailscale` (or any reachable HTTPS
11# backend) and open the pairing URL against the preview origin.
12#
13# The preview must be opened at the exact deployment URL from the PR comment.
14# Vite bakes that URL in as the hosted origin (via VERCEL_URL), and
15# `isHostedStaticApp` matches on origin, so branch-alias URLs will not
16# self-identify as the hosted app.
17
18on:
19 pull_request:
20 types: [labeled, synchronize, reopened]
21
22permissions:
23 contents: read
24 pull-requests: write
25
26concurrency:
27 group: web-preview-${{ github.event.pull_request.number }}
28 cancel-in-progress: true
29
30jobs:
31 deploy:
32 name: Deploy web preview
33 # Same-repo PRs only: fork PRs do not receive the Vercel secrets, and this
34 # workflow should skip rather than fail for them. On `labeled` events, only
35 # the preview label itself triggers a deploy.
36 if: >-
37 github.event.pull_request.head.repo.full_name == github.repository &&
38 contains(github.event.pull_request.labels.*.name, 'preview:web') &&
39 (github.event.action != 'labeled' || github.event.label.name == 'preview:web')
40 runs-on: blacksmith-8vcpu-ubuntu-2404
41 timeout-minutes: 10
42 env:
43 VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
44 VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
45 VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
46 VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }}
47 steps:
48 - name: Checkout
49 uses: actions/checkout@v6
50 with:
51 ref: ${{ github.event.pull_request.head.sha }}
52 sparse-checkout: |
53 /*
54 !/.repos/
55 sparse-checkout-cone-mode: false
56
57 - name: Setup Vite+
58 uses: voidzero-dev/setup-vp@v1
59 with:
60 node-version-file: package.json
61 cache: true
62 run-install: |
63 args:
64 - --filter=@t3tools/scripts...
65 - --filter=@t3tools/web...
66
67 - id: deploy
68 name: Deploy preview
69 shell: bash
70 run: |
71 set -euo pipefail
72
73 if [[ -z "${VERCEL_TOKEN:-}" || -z "${VERCEL_ORG_ID:-}" || -z "${VERCEL_PROJECT_ID:-}" ]]; then
74 echo "Missing one or more required Vercel secrets: VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID." >&2
75 exit 1
76 fi
77
78 vercel_scope="${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}"
79
80 deployment_url="$(
81 vp dlx vercel@53.1.1 deploy \
82 --archive=tgz \
83 --yes \
84 --token "$VERCEL_TOKEN" \
85 --scope "$vercel_scope"
86 )"
87
88 echo "Deployed $deployment_url"
89 echo "deployment_url=$deployment_url" >> "$GITHUB_OUTPUT"
90
91 - name: Comment deployment URL
92 uses: actions/github-script@v8
93 env:
94 DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment_url }}
95 HEAD_SHA: ${{ github.event.pull_request.head.sha }}
96 with:
97 script: |
98 const marker = "<!-- web-preview-deploy -->";
99 const body = [
100 marker,
101 "### Web preview",
102 "",
103 `${process.env.DEPLOYMENT_URL} (for ${process.env.HEAD_SHA.slice(0, 7)})`,
104 "",
105 "Open this exact URL — the hosted-app origin is baked in at build time.",
106 "Pair a server into it with `t3 pair --tailscale`, or paste a host + pairing",
107 "code under Settings → Connections.",
108 ].join("\n");
109
110 const { data: comments } = await github.rest.issues.listComments({
111 owner: context.repo.owner,
112 repo: context.repo.repo,
113 issue_number: context.payload.pull_request.number,
114 per_page: 100,
115 });
116 const existing = comments.find((comment) => comment.body?.includes(marker));
117
118 if (existing) {
119 await github.rest.issues.updateComment({
120 owner: context.repo.owner,
121 repo: context.repo.repo,
122 comment_id: existing.id,
123 body,
124 });
125 } else {
126 await github.rest.issues.createComment({
127 owner: context.repo.owner,
128 repo: context.repo.repo,
129 issue_number: context.payload.pull_request.number,
130 body,
131 });
132 }