authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-30 22:58:18-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-30 22:58:18-07:00
log783ccf0fdde0d20824ad87af10a870fc28005835
tree1720c7df2dabd157df27fb2d206a62473a23dee2
parentc57a04b722f2172e3be2c8f73c936d10b4582431
signature Signed by PGP key B5690EEEBB952194

perf(release): build Vercel deployments early and go live after publish (#14029)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

2 files changed, 161 insertions(+), 38 deletions(-)

.github/workflows/release.yml+147-32
...@@ -948,12 +948,20 @@ jobs:...@@ -948,12 +948,20 @@ jobs:
948 secrets:948 secrets:
949 AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}949 AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
950950
951 deploy_web:951 # Vercel builds the hosted web app while the desktop jobs run. `--skip-domain`
952 name: Deploy hosted web app952 # keeps the custom domains (the channel and router domains) on the previous
953 needs: [preflight, relay_public_config, release]953 # deployment until deploy_web aliases them, after the GitHub Release. It does
954 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }}954 # still move the project's own *.vercel.app production hostname at build time.
955 # That hostname sits behind Vercel SSO and is not a user channel, so the
956 # npm-before-client order in docs/operations/release.md holds for users.
957 build_web:
958 name: Build hosted web app
959 needs: [preflight, relay_public_config]
960 if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }}
955 runs-on: blacksmith-8vcpu-ubuntu-2404961 runs-on: blacksmith-8vcpu-ubuntu-2404
956 timeout-minutes: 10962 timeout-minutes: 10
963 outputs:
964 deployment_url: ${{ steps.deploy.outputs.deployment_url }}
957 env:965 env:
958 T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }}966 T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }}
959 T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }}967 T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }}
...@@ -963,8 +971,6 @@ jobs:...@@ -963,8 +971,6 @@ jobs:
963 VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}971 VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
964 VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}972 VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
965 T3CODE_WEB_ROUTER_URL: ${{ vars.T3CODE_WEB_ROUTER_URL }}973 T3CODE_WEB_ROUTER_URL: ${{ vars.T3CODE_WEB_ROUTER_URL }}
966 T3CODE_WEB_LATEST_DOMAIN: ${{ vars.T3CODE_WEB_LATEST_DOMAIN }}
967 T3CODE_WEB_NIGHTLY_DOMAIN: ${{ vars.T3CODE_WEB_NIGHTLY_DOMAIN }}
968 VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }}974 VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }}
969 steps:975 steps:
970 - name: Checkout976 - name: Checkout
...@@ -1006,7 +1012,8 @@ jobs:...@@ -1006,7 +1012,8 @@ jobs:
1006 - name: Refresh release lockfile1012 - name: Refresh release lockfile
1007 run: vp install --lockfile-only --ignore-scripts1013 run: vp install --lockfile-only --ignore-scripts
10081014
1009 - name: Deploy and alias channel1015 - id: deploy
1016 name: Build staged deployment
1010 shell: bash1017 shell: bash
1011 run: |1018 run: |
1012 set -euo pipefail1019 set -euo pipefail
...@@ -1017,24 +1024,13 @@ jobs:...@@ -1017,24 +1024,13 @@ jobs:
1017 fi1024 fi
10181025
1019 router_url="${T3CODE_WEB_ROUTER_URL:-https://app.t3.codes}"1026 router_url="${T3CODE_WEB_ROUTER_URL:-https://app.t3.codes}"
1020 latest_domain="${T3CODE_WEB_LATEST_DOMAIN:-latest.app.t3.codes}"
1021 nightly_domain="${T3CODE_WEB_NIGHTLY_DOMAIN:-nightly.app.t3.codes}"
1022 router_domain="${router_url#http://}"
1023 router_domain="${router_domain#https://}"
1024 router_domain="${router_domain%%/*}"
1025
1026 if [[ "${{ needs.preflight.outputs.release_channel }}" == "stable" ]]; then1027 if [[ "${{ needs.preflight.outputs.release_channel }}" == "stable" ]]; then
1027 channel_domain="$latest_domain"
1028 channel_name="latest"1028 channel_name="latest"
1029 else1029 else
1030 channel_domain="$nightly_domain"
1031 channel_name="nightly"1030 channel_name="nightly"
1032 fi1031 fi
10331032
1034 vercel_scope="${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}"1033 echo "Building hosted web app for $channel_name channel."
1035 vercel_scope_args=(--scope "$vercel_scope")
1036
1037 echo "Deploying hosted web app for $channel_name channel."
1038 deployment_url="$(1034 deployment_url="$(
1039 vp dlx vercel@53.1.1 deploy \1035 vp dlx vercel@53.1.1 deploy \
1040 --archive=tgz \1036 --archive=tgz \
...@@ -1042,7 +1038,7 @@ jobs:...@@ -1042,7 +1038,7 @@ jobs:
1042 --skip-domain \1038 --skip-domain \
1043 --yes \1039 --yes \
1044 --token "$VERCEL_TOKEN" \1040 --token "$VERCEL_TOKEN" \
1045 "${vercel_scope_args[@]}" \1041 --scope "${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}" \
1046 --build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \1042 --build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \
1047 --build-env "T3CODE_CLERK_PUBLISHABLE_KEY=${T3CODE_CLERK_PUBLISHABLE_KEY:-}" \1043 --build-env "T3CODE_CLERK_PUBLISHABLE_KEY=${T3CODE_CLERK_PUBLISHABLE_KEY:-}" \
1048 --build-env "T3CODE_CLERK_JWT_TEMPLATE=${T3CODE_CLERK_JWT_TEMPLATE:-}" \1044 --build-env "T3CODE_CLERK_JWT_TEMPLATE=${T3CODE_CLERK_JWT_TEMPLATE:-}" \
...@@ -1054,25 +1050,92 @@ jobs:...@@ -1054,25 +1050,92 @@ jobs:
1054 --build-env "VITE_HOSTED_APP_URL=$router_url" \1050 --build-env "VITE_HOSTED_APP_URL=$router_url" \
1055 --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name"1051 --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name"
1056 )"1052 )"
1053 echo "Staged $deployment_url."
1054 echo "deployment_url=$deployment_url" >> "$GITHUB_OUTPUT"
10571055
1058 echo "Aliasing $deployment_url to $channel_domain."1056 deploy_web:
1059 vp dlx vercel@53.1.1 alias set "$deployment_url" "$channel_domain" \1057 name: Deploy hosted web app
1058 needs: [preflight, build_web, release]
1059 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build_web.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }}
1060 runs-on: blacksmith-2vcpu-ubuntu-2404
1061 timeout-minutes: 10
1062 env:
1063 DEPLOYMENT_URL: ${{ needs.build_web.outputs.deployment_url }}
1064 VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
1065 VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
1066 VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
1067 T3CODE_WEB_ROUTER_URL: ${{ vars.T3CODE_WEB_ROUTER_URL }}
1068 T3CODE_WEB_LATEST_DOMAIN: ${{ vars.T3CODE_WEB_LATEST_DOMAIN }}
1069 T3CODE_WEB_NIGHTLY_DOMAIN: ${{ vars.T3CODE_WEB_NIGHTLY_DOMAIN }}
1070 VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }}
1071 steps:
1072 - name: Checkout
1073 uses: actions/checkout@v6
1074 with:
1075 ref: ${{ needs.preflight.outputs.ref }}
1076 sparse-checkout: |
1077 /*
1078 !/.repos/
1079 sparse-checkout-cone-mode: false
1080
1081 - name: Setup Vite+
1082 uses: voidzero-dev/setup-vp@v1
1083 with:
1084 node-version-file: package.json
1085 cache: false
1086 run-install: false
1087
1088 - name: Alias channel
1089 shell: bash
1090 run: |
1091 set -euo pipefail
1092
1093 if [[ -z "$DEPLOYMENT_URL" ]]; then
1094 echo "Build hosted web app did not report a deployment URL." >&2
1095 exit 1
1096 fi
1097
1098 router_url="${T3CODE_WEB_ROUTER_URL:-https://app.t3.codes}"
1099 latest_domain="${T3CODE_WEB_LATEST_DOMAIN:-latest.app.t3.codes}"
1100 nightly_domain="${T3CODE_WEB_NIGHTLY_DOMAIN:-nightly.app.t3.codes}"
1101 router_domain="${router_url#http://}"
1102 router_domain="${router_domain#https://}"
1103 router_domain="${router_domain%%/*}"
1104
1105 if [[ "${{ needs.preflight.outputs.release_channel }}" == "stable" ]]; then
1106 channel_domain="$latest_domain"
1107 channel_name="latest"
1108 else
1109 channel_domain="$nightly_domain"
1110 channel_name="nightly"
1111 fi
1112
1113 vercel_scope_args=(--scope "${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}")
1114
1115 echo "Aliasing $DEPLOYMENT_URL to $channel_domain."
1116 vp dlx vercel@53.1.1 alias set "$DEPLOYMENT_URL" "$channel_domain" \
1060 --token "$VERCEL_TOKEN" \1117 --token "$VERCEL_TOKEN" \
1061 "${vercel_scope_args[@]}"1118 "${vercel_scope_args[@]}"
10621119
1063 if [[ "$channel_name" == "latest" && -n "$router_domain" && "$router_domain" != "$channel_domain" ]]; then1120 if [[ "$channel_name" == "latest" && -n "$router_domain" && "$router_domain" != "$channel_domain" ]]; then
1064 echo "Aliasing $deployment_url to router domain $router_domain."1121 echo "Aliasing $DEPLOYMENT_URL to router domain $router_domain."
1065 vp dlx vercel@53.1.1 alias set "$deployment_url" "$router_domain" \1122 vp dlx vercel@53.1.1 alias set "$DEPLOYMENT_URL" "$router_domain" \
1066 --token "$VERCEL_TOKEN" \1123 --token "$VERCEL_TOKEN" \
1067 "${vercel_scope_args[@]}"1124 "${vercel_scope_args[@]}"
1068 fi1125 fi
10691126
1070 deploy_marketing:1127 # Same split as the web app: build a production deployment early, then
1071 name: Deploy marketing site1128 # promote it to t3.codes once the release it links to exists. As above, the
1072 needs: [preflight, release]1129 # project's own *.vercel.app hostname moves at build time.
1073 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'nightly' }}1130 build_marketing:
1131 name: Build marketing site
1132 needs: [preflight]
1133 if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.preflight.outputs.release_channel == 'nightly' }}
1074 runs-on: blacksmith-8vcpu-ubuntu-24041134 runs-on: blacksmith-8vcpu-ubuntu-2404
1075 timeout-minutes: 101135 timeout-minutes: 10
1136 outputs:
1137 deployment_url: ${{ steps.deploy.outputs.deployment_url }}
1138 project_id: ${{ steps.deploy.outputs.project_id }}
1076 env:1139 env:
1077 VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}1140 VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
1078 VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}1141 VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
...@@ -1096,7 +1159,8 @@ jobs:...@@ -1096,7 +1159,8 @@ jobs:
1096 args:1159 args:
1097 - --filter=@t3tools/marketing...1160 - --filter=@t3tools/marketing...
10981161
1099 - name: Deploy marketing site to Vercel1162 - id: deploy
1163 name: Build staged deployment
1100 shell: bash1164 shell: bash
1101 run: |1165 run: |
1102 set -euo pipefail1166 set -euo pipefail
...@@ -1114,9 +1178,60 @@ jobs:...@@ -1114,9 +1178,60 @@ jobs:
1114 )"1178 )"
1115 export VERCEL_PROJECT_ID1179 export VERCEL_PROJECT_ID
11161180
1117 vp dlx vercel@53.1.1 deploy \1181 deployment_url="$(
1118 --archive=tgz \1182 vp dlx vercel@53.1.1 deploy \
1119 --prod \1183 --archive=tgz \
1184 --prod \
1185 --skip-domain \
1186 --yes \
1187 --token "$VERCEL_TOKEN" \
1188 --scope "${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}"
1189 )"
1190 echo "Staged $deployment_url."
1191 echo "deployment_url=$deployment_url" >> "$GITHUB_OUTPUT"
1192 echo "project_id=$VERCEL_PROJECT_ID" >> "$GITHUB_OUTPUT"
1193
1194 deploy_marketing:
1195 name: Deploy marketing site
1196 needs: [preflight, build_marketing, release]
1197 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build_marketing.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'nightly' }}
1198 runs-on: blacksmith-2vcpu-ubuntu-2404
1199 timeout-minutes: 10
1200 env:
1201 DEPLOYMENT_URL: ${{ needs.build_marketing.outputs.deployment_url }}
1202 VERCEL_PROJECT_ID: ${{ needs.build_marketing.outputs.project_id }}
1203 VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
1204 VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
1205 VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }}
1206 steps:
1207 - name: Checkout
1208 uses: actions/checkout@v6
1209 with:
1210 ref: ${{ needs.preflight.outputs.ref }}
1211 sparse-checkout: |
1212 /*
1213 !/.repos/
1214 sparse-checkout-cone-mode: false
1215
1216 - name: Setup Vite+
1217 uses: voidzero-dev/setup-vp@v1
1218 with:
1219 node-version-file: package.json
1220 cache: false
1221 run-install: false
1222
1223 - name: Promote marketing site
1224 shell: bash
1225 run: |
1226 set -euo pipefail
1227
1228 if [[ -z "$DEPLOYMENT_URL" ]]; then
1229 echo "Build marketing site did not report a deployment URL." >&2
1230 exit 1
1231 fi
1232
1233 echo "Promoting $DEPLOYMENT_URL."
1234 vp dlx vercel@53.1.1 promote "$DEPLOYMENT_URL" \
1120 --yes \1235 --yes \
1121 --token "$VERCEL_TOKEN" \1236 --token "$VERCEL_TOKEN" \
1122 --scope "${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}"1237 --scope "${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}"
docs/operations/release.md+14-6
...@@ -47,7 +47,7 @@ This document covers the unified release workflow for stable and nightly desktop...@@ -47,7 +47,7 @@ This document covers the unified release workflow for stable and nightly desktop
47 - nightly releases publish npm dist-tag `nightly`47 - nightly releases publish npm dist-tag `nightly`
48 - preview releases publish npm dist-tag `preview`, which nothing resolves unless asked for by name48 - preview releases publish npm dist-tag `preview`, which nothing resolves unless asked for by name
49 - one-time setup: the `@t3code` npm scope (org) must exist, and `t3` and each `@t3code/t3-<platform>-<arch>` package needs a trusted publisher registered for this workflow file (see below).49 - one-time setup: the `@t3code` npm scope (org) must exist, and `t3` and each `@t3code/t3-<platform>-<arch>` package needs a trusted publisher registered for this workflow file (see below).
50- Deploys the hosted web app to Vercel only after a release is published:50- Builds the hosted web app on Vercel while the desktop jobs run, and makes it live only after a release is published:
51 - stable releases are aliased to the `latest` hosted app channel51 - stable releases are aliased to the `latest` hosted app channel
52 - nightly releases are aliased to the `nightly` hosted app channel52 - nightly releases are aliased to the `nightly` hosted app channel
53- Signing is optional and auto-detected per platform from secrets.53- Signing is optional and auto-detected per platform from secrets.
...@@ -211,9 +211,11 @@ stage, test Cloudflare account, disposable host, and disposable T3 home. Keep pr...@@ -211,9 +211,11 @@ stage, test Cloudflare account, disposable host, and disposable T3 home. Keep pr
211211
212## Marketing site deployment212## Marketing site deployment
213213
214After a nightly release is published, the release workflow deploys the same commit214On nightly releases, the release workflow builds the same commit as a staged
215to the marketing site's Vercel production project. Stable releases do not deploy215production deployment of the marketing site's Vercel project while the desktop
216the marketing site because they can promote an older nightly commit.216jobs run, and promotes it with `vercel promote` after the release is published.
217Stable releases do not deploy the marketing site because they can promote an
218older nightly commit.
217219
218The job looks up the `t3code-marketing` project using the existing `VERCEL_TOKEN`220The job looks up the `t3code-marketing` project using the existing `VERCEL_TOKEN`
219and `VERCEL_ORG_ID` secrets. It also respects the optional `VERCEL_TEAM_SLUG`221and `VERCEL_ORG_ID` secrets. It also respects the optional `VERCEL_TEAM_SLUG`
...@@ -224,8 +226,10 @@ Git deployments remain disabled in `apps/marketing/vercel.ts`....@@ -224,8 +226,10 @@ Git deployments remain disabled in `apps/marketing/vercel.ts`.
224226
225The hosted app is intentionally not deployed by Vercel's Git integration. The227The hosted app is intentionally not deployed by Vercel's Git integration. The
226web project disables automatic Git deployments in `apps/web/vercel.ts` via228web project disables automatic Git deployments in `apps/web/vercel.ts` via
227`git.deploymentEnabled: false`, and `.github/workflows/release.yml` deploys the229`git.deploymentEnabled: false`. `.github/workflows/release.yml` builds the web
228web app with Vercel CLI after the GitHub Release succeeds.230app with Vercel CLI as a staged production deployment (`--skip-domain`) while
231the desktop jobs run, and aliases the channel domains to it after the GitHub
232Release succeeds.
229233
230Required GitHub Actions secrets:234Required GitHub Actions secrets:
231235
...@@ -302,6 +306,10 @@ The workflow enforces this ordering:...@@ -302,6 +306,10 @@ The workflow enforces this ordering:
3021. `publish_cli` publishes the exact release version to npm, on every channel.3061. `publish_cli` publishes the exact release version to npm, on every channel.
3032. `release` depends on `publish_cli` before exposing desktop artifacts in GitHub Releases.3072. `release` depends on `publish_cli` before exposing desktop artifacts in GitHub Releases.
3043. `deploy_web` depends on `release` before moving the hosted channel to the new client.3083. `deploy_web` depends on `release` before moving the hosted channel to the new client.
309 `build_web` builds that client earlier with `vercel deploy --prod --skip-domain`, which
310 leaves the custom domains alone but moves the project's own `*.vercel.app` production
311 hostname. That hostname is behind Vercel SSO, so users only get the client through the
312 custom domains.
305313
306Preserve these dependencies when changing the release graph. Publishing a client first would leave314Preserve these dependencies when changing the release graph. Publishing a client first would leave
307the **Update server** action targeting a package version that does not exist yet.315the **Update server** action targeting a package version that does not exist yet.