1name: Release
2
3on:
4 push:
5 tags:
6 - "v*.*.*"
7 - "!v*-nightly.*"
8 - "!v*-preview.*"
9 schedule:
10 # Avoid minute zero, when GitHub scheduled jobs are busiest.
11 - cron: "8,38 * * * *"
12 workflow_dispatch:
13 inputs:
14 channel:
15 description: "Release channel"
16 required: false
17 default: preview
18 type: choice
19 options:
20 - preview
21 - stable
22 - nightly
23 version:
24 description: "Stable version override (for example 1.2.3). Defaults to the version the latest nightly previewed."
25 required: false
26 type: string
27
28# Serialize nightlies (scheduled and manual) so overlapping runs cannot build
29# the same commit twice or publish out of order. Stable tag releases get their
30# own group so a nightly never blocks them. Running publishers are never
31# canceled, and queue: max keeps every pending run instead of the default
32# newest-wins single slot, so a queued stable tag can never be silently
33# dropped. Automatic nightlies recheck the release gap after leaving the queue.
34concurrency:
35 group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly' || inputs.channel == 'preview') && 'nightly' || 'stable' }}
36 cancel-in-progress: false
37 queue: max
38
39permissions:
40 # The Windows desktop jobs read this run's artifact list to wait for the
41 # Linux CLI archive they embed (see release-desktop.yml).
42 actions: read
43 contents: read
44 id-token: none
45
46jobs:
47 # Picks the commit every later job builds. Nightlies and tag pushes build the
48 # triggering commit. Manual stable releases build the commit of the latest
49 # published nightly, so stable only ever ships a build that nightly users
50 # have already run. Scheduled runs also decide here whether a nightly is due.
51 resolve_commit:
52 name: Resolve release commit
53 runs-on: blacksmith-8vcpu-ubuntu-2404
54 timeout-minutes: 5
55 outputs:
56 ref: ${{ steps.resolve.outputs.ref }}
57 nightly_version: ${{ steps.resolve.outputs.nightly_version }}
58 has_changes: ${{ steps.resolve.outputs.has_changes }}
59 steps:
60 - name: Checkout
61 uses: actions/checkout@v6
62 with:
63 sparse-checkout: .github/scripts
64
65 - id: resolve
66 name: Resolve release commit
67 uses: actions/github-script@v8
68 env:
69 DISPATCH_CHANNEL: ${{ inputs.channel }}
70 with:
71 script: |
72 const {
73 assertCommitOnDefaultBranch,
74 assertReleaseSource,
75 shouldReleaseNightly,
76 resolveLatestNightlyCommit,
77 } = require('./.github/scripts/check-nightly-release.cjs');
78
79 const dispatchChannel = process.env.DISPATCH_CHANNEL || 'preview';
80 const releaseChannel = context.eventName === 'schedule'
81 ? 'nightly'
82 : context.eventName === 'workflow_dispatch'
83 ? dispatchChannel
84 : 'stable';
85
86 await assertReleaseSource({ github, context, releaseChannel });
87
88 if (context.eventName === 'schedule') {
89 core.setOutput('has_changes', await shouldReleaseNightly({ github, context, core }));
90 core.setOutput('ref', context.sha);
91 } else if (context.eventName === 'workflow_dispatch' && dispatchChannel === 'stable') {
92 const { tag, sha, version } = await resolveLatestNightlyCommit({ github, context, core });
93 await assertCommitOnDefaultBranch({ github, context, sha });
94 core.notice(`Stable release builds ${sha}, the commit shipped by ${tag}.`);
95 core.setOutput('ref', sha);
96 core.setOutput('nightly_version', version);
97 } else {
98 core.setOutput('ref', context.sha);
99 }
100
101 preflight:
102 name: Preflight
103 needs: [resolve_commit]
104 if: |
105 needs.resolve_commit.result == 'success' &&
106 (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true')
107 runs-on: blacksmith-8vcpu-ubuntu-2404
108 timeout-minutes: 10
109 outputs:
110 release_channel: ${{ steps.release_meta.outputs.release_channel }}
111 version: ${{ steps.release_meta.outputs.version }}
112 tag: ${{ steps.release_meta.outputs.tag }}
113 release_name: ${{ steps.release_meta.outputs.name }}
114 short_sha: ${{ steps.release_meta.outputs.short_sha }}
115 previous_tag: ${{ steps.previous_tag.outputs.previous_tag }}
116 cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }}
117 is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }}
118 make_latest: ${{ steps.release_meta.outputs.make_latest }}
119 ref: ${{ needs.resolve_commit.outputs.ref }}
120 steps:
121 - name: Checkout
122 uses: actions/checkout@v6
123 with:
124 ref: ${{ needs.resolve_commit.outputs.ref }}
125 fetch-depth: 0
126 sparse-checkout: |
127 /*
128 !/.repos/
129 sparse-checkout-cone-mode: false
130
131 - name: Setup Vite+
132 uses: voidzero-dev/setup-vp@v1
133 with:
134 node-version-file: package.json
135 cache: true
136 run-install: true
137 env:
138 pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata
139
140 - id: release_meta
141 name: Resolve release version
142 shell: bash
143 env:
144 DISPATCH_CHANNEL: ${{ github.event.inputs.channel }}
145 DISPATCH_VERSION: ${{ github.event.inputs.version }}
146 NIGHTLY_VERSION: ${{ needs.resolve_commit.outputs.nightly_version }}
147 NIGHTLY_DATE: ${{ github.run_started_at }}
148 NIGHTLY_SHA: ${{ needs.resolve_commit.outputs.ref }}
149 NIGHTLY_RUN_NUMBER: ${{ github.run_number }}
150 run: |
151 if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-preview}" == "nightly" ) ]]; then
152 nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)"
153
154 node scripts/resolve-nightly-release.ts \
155 --date "$nightly_date" \
156 --run-number "$NIGHTLY_RUN_NUMBER" \
157 --sha "$NIGHTLY_SHA" \
158 --github-output
159
160 echo "release_channel=nightly" >> "$GITHUB_OUTPUT"
161 echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT"
162 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
163 echo "make_latest=false" >> "$GITHUB_OUTPUT"
164 elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-preview}" == "preview" ]]; then
165 # Manual-only test train: exercises the whole release flow for a
166 # commit end users must never receive. Never scheduled.
167 # Same versioning as nightly under its own prerelease identifier.
168 # A preview release is reachable only by asking for it: npm gets it
169 # under the `preview` dist-tag, which nothing resolves by default,
170 # its desktop builds carry no update feed, and no updater manifest
171 # is attached to the release, so neither stable nor nightly
172 # installs can ever be offered one.
173 nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)"
174
175 node scripts/resolve-nightly-release.ts \
176 --channel preview \
177 --date "$nightly_date" \
178 --run-number "$NIGHTLY_RUN_NUMBER" \
179 --sha "$NIGHTLY_SHA" \
180 --github-output
181
182 echo "release_channel=preview" >> "$GITHUB_OUTPUT"
183 echo "cli_dist_tag=preview" >> "$GITHUB_OUTPUT"
184 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
185 echo "make_latest=false" >> "$GITHUB_OUTPUT"
186 else
187 if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
188 raw="${DISPATCH_VERSION:-$NIGHTLY_VERSION}"
189 if [[ -z "$raw" ]]; then
190 echo "workflow_dispatch stable releases need a version input or a published nightly." >&2
191 exit 1
192 fi
193 else
194 raw="${GITHUB_REF_NAME}"
195 fi
196
197 version="${raw#v}"
198 if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
199 echo "Invalid release version: $raw" >&2
200 exit 1
201 fi
202
203 echo "release_channel=stable" >> "$GITHUB_OUTPUT"
204 echo "version=$version" >> "$GITHUB_OUTPUT"
205 echo "tag=v$version" >> "$GITHUB_OUTPUT"
206 echo "name=T3 Code v$version" >> "$GITHUB_OUTPUT"
207 echo "cli_dist_tag=latest" >> "$GITHUB_OUTPUT"
208 if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
209 echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
210 echo "make_latest=true" >> "$GITHUB_OUTPUT"
211 else
212 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
213 echo "make_latest=false" >> "$GITHUB_OUTPUT"
214 fi
215 fi
216
217 - id: previous_tag
218 name: Resolve previous release tag
219 run: |
220 node scripts/resolve-previous-release-tag.ts \
221 --channel "${{ steps.release_meta.outputs.release_channel }}" \
222 --current-tag "${{ steps.release_meta.outputs.tag }}" \
223 --github-output
224
225 # Share only the verification results, not the large registry metadata cache.
226 - name: Upload dependency verification
227 continue-on-error: true
228 uses: actions/upload-artifact@v7
229 with:
230 name: release-dependency-verification
231 path: ${{ runner.temp }}/pnpm-metadata/lockfile-verified.jsonl
232
233 quality:
234 name: Release quality checks
235 needs: [preflight]
236 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }}
237 runs-on: blacksmith-8vcpu-ubuntu-2404
238 timeout-minutes: 10
239 steps:
240 - name: Checkout
241 uses: actions/checkout@v6
242 with:
243 ref: ${{ needs.preflight.outputs.ref }}
244 sparse-checkout: |
245 /*
246 !/.repos/
247 sparse-checkout-cone-mode: false
248
249 - name: Setup Vite+
250 uses: voidzero-dev/setup-vp@v1
251 with:
252 node-version-file: package.json
253 cache: true
254 run-install: true
255
256 - name: Ensure Electron runtime is installed
257 run: vp run --filter @t3tools/desktop ensure:electron
258
259 - name: Check
260 run: vp check
261
262 - name: Typecheck
263 run: vp run typecheck
264
265 # Keep tests on their own runners, using the same package split and server
266 # shards as CI, so the release check job does not spend its budget on tests.
267 test:
268 name: Release tests
269 needs: [preflight]
270 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }}
271 runs-on: blacksmith-8vcpu-ubuntu-2404
272 timeout-minutes: 10
273 steps:
274 - name: Checkout
275 uses: actions/checkout@v6
276 with:
277 ref: ${{ needs.preflight.outputs.ref }}
278 sparse-checkout: |
279 /*
280 !/.repos/
281 sparse-checkout-cone-mode: false
282
283 - name: Setup Vite+
284 uses: voidzero-dev/setup-vp@v1
285 with:
286 node-version-file: package.json
287 cache: true
288 run-install: true
289
290 - name: Ensure Electron runtime is installed
291 run: vp run --filter @t3tools/desktop ensure:electron
292
293 - uses: ./.github/actions/setup-apt-mirrors
294
295 - name: Install browser secret helper build libraries
296 run: |
297 sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
298 sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config build-essential
299
300 - name: Test
301 run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test
302
303 test_server:
304 name: Release server tests ${{ matrix.shard }}
305 needs: [preflight]
306 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }}
307 runs-on: blacksmith-8vcpu-ubuntu-2404
308 timeout-minutes: 10
309 strategy:
310 fail-fast: false
311 matrix:
312 shard: [1, 2, 3]
313 steps:
314 - name: Checkout
315 uses: actions/checkout@v6
316 with:
317 ref: ${{ needs.preflight.outputs.ref }}
318 sparse-checkout: |
319 /*
320 !/.repos/
321 sparse-checkout-cone-mode: false
322
323 - name: Setup Vite+
324 uses: voidzero-dev/setup-vp@v1
325 with:
326 node-version-file: package.json
327 cache: true
328 run-install: true
329
330 # No Electron setup here: `t3` (apps/server) has no Electron dependency
331 # and none of its tests touch the runtime. Only the non-server `test`
332 # job, which covers @t3tools/desktop, needs the download.
333 - name: Install Chromium for server browser integration tests
334 run: node apps/server/node_modules/playwright-core/cli.js install --with-deps --only-shell chromium
335
336 - name: Test
337 run: vp run --filter t3 test --shard ${{ matrix.shard }}/${{ strategy.job-total }}
338
339 relay_public_config:
340 name: Resolve T3 Connect public config
341 # Consumes only the release commit, not preflight's resolved version, so it
342 # runs alongside preflight instead of after it. The condition mirrors preflight's.
343 needs: [resolve_commit]
344 if: |
345 needs.resolve_commit.result == 'success' &&
346 (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true')
347 runs-on: blacksmith-8vcpu-ubuntu-2404
348 timeout-minutes: 5
349 environment:
350 name: production
351 outputs:
352 clerk_publishable_key: ${{ steps.public_config.outputs.clerk_publishable_key }}
353 clerk_jwt_template: ${{ steps.public_config.outputs.clerk_jwt_template }}
354 clerk_cli_oauth_client_id: ${{ steps.public_config.outputs.clerk_cli_oauth_client_id }}
355 relay_url: ${{ steps.public_config.outputs.relay_url }}
356 env:
357 CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
358 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
359 RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }}
360 RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }}
361 CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }}
362 CLERK_JWT_TEMPLATE: ${{ vars.CLERK_JWT_TEMPLATE }}
363 CLERK_CLI_OAUTH_CLIENT_ID: ${{ vars.CLERK_CLI_OAUTH_CLIENT_ID }}
364 steps:
365 - name: Checkout
366 uses: actions/checkout@v6
367 with:
368 ref: ${{ needs.resolve_commit.outputs.ref }}
369 sparse-checkout: |
370 /*
371 !/.repos/
372 sparse-checkout-cone-mode: false
373
374 - name: Setup Vite+
375 uses: voidzero-dev/setup-vp@v1
376 with:
377 node-version-file: package.json
378 cache: true
379 run-install: |
380 args:
381 - --filter=t3code-relay...
382
383 # The deployed stack's outputs, read from Alchemy's state store without
384 # planning or applying. Select the backend explicitly to avoid loading
385 # the stack's unrelated provider credentials; suppress informational logs
386 # and strip the credential-refresh progress prefix before parsing JSON.
387 # Redacted values are persisted as
388 # {"__redacted__": "<value>"}; the token is masked before it is written.
389 - name: Read production relay tracing config
390 shell: bash
391 working-directory: infra/relay
392 run: |
393 set -euo pipefail
394 output="$(npx alchemy state read T3CodeRelay/prod/output --backend cloudflare --log-level error --no-input | sed -n '/^{/,$p')"
395 field() {
396 jq -er --arg key "$1" '.[$key] | if type == "object" then .__redacted__ else . end | select(. != null and . != "")' <<<"$output" \
397 || { echo "Relay stack output is missing $1" >&2; exit 1; }
398 }
399 url="$(field clientTracingUrl)"
400 dataset="$(field clientTracingDataset)"
401 token="$(field clientTracingToken)"
402 echo "::add-mask::$token"
403 {
404 echo "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=$url"
405 echo "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=$dataset"
406 echo "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=$token"
407 } > "$RUNNER_TEMP/relay-client-tracing.env"
408
409 - name: Upload relay client tracing config
410 uses: actions/upload-artifact@v7
411 with:
412 name: relay-client-tracing-config
413 path: ${{ runner.temp }}/relay-client-tracing.env
414 if-no-files-found: error
415 retention-days: 1
416
417 - id: public_config
418 name: Resolve production relay public config
419 shell: bash
420 run: |
421 set -euo pipefail
422
423 relay_domain="${RELAY_DOMAIN:-}"
424 if [[ -z "$relay_domain" && -n "${RELAY_API_ZONE_NAME:-}" ]]; then
425 relay_domain="relay.$RELAY_API_ZONE_NAME"
426 fi
427 required=(
428 relay_domain
429 CLERK_PUBLISHABLE_KEY
430 CLERK_JWT_TEMPLATE
431 CLERK_CLI_OAUTH_CLIENT_ID
432 )
433 missing=()
434 for name in "${required[@]}"; do
435 if [[ -z "${!name:-}" ]]; then
436 missing+=("$name")
437 fi
438 done
439 if (( ${#missing[@]} > 0 )); then
440 printf 'Missing required relay deployment configuration: %s\n' "${missing[*]}" >&2
441 exit 1
442 fi
443
444 echo "clerk_publishable_key=$CLERK_PUBLISHABLE_KEY" >> "$GITHUB_OUTPUT"
445 echo "clerk_jwt_template=$CLERK_JWT_TEMPLATE" >> "$GITHUB_OUTPUT"
446 echo "clerk_cli_oauth_client_id=$CLERK_CLI_OAUTH_CLIENT_ID" >> "$GITHUB_OUTPUT"
447 echo "relay_url=https://$relay_domain" >> "$GITHUB_OUTPUT"
448
449 # The platform-independent JS (server bundle, web client, Electron main) is
450 # built exactly once here and handed to every platform job as `js-bundle`.
451 # The relay/Clerk values are baked into the bundle, so they belong to this
452 # job rather than to the packaging jobs.
453 build_bundle:
454 name: Build JS bundle
455 # Same gating as relay_public_config: only the release commit is needed, so
456 # this runs alongside preflight. See the condition comment there.
457 needs: [preflight, relay_public_config]
458 if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }}
459 runs-on: blacksmith-32vcpu-ubuntu-2404
460 timeout-minutes: 30
461 env:
462 T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }}
463 T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }}
464 T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }}
465 T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }}
466 steps:
467 - name: Checkout
468 uses: actions/checkout@v6
469 with:
470 ref: ${{ needs.preflight.outputs.ref }}
471 sparse-checkout: |
472 /*
473 !/.repos/
474 sparse-checkout-cone-mode: false
475
476 - name: Setup Vite+
477 uses: voidzero-dev/setup-vp@v1
478 with:
479 node-version-file: package.json
480 cache: true
481 run-install: false
482
483 # pnpm checks the lockfile and policy before reusing this result. A missing
484 # artifact leaves the cache empty, so installation runs the checks again.
485 - name: Download dependency verification
486 continue-on-error: true
487 uses: actions/download-artifact@v8
488 with:
489 name: release-dependency-verification
490 path: ${{ runner.temp }}/pnpm-metadata
491
492 - name: Install bundle dependencies
493 env:
494 pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata
495 run: vp install --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts...
496
497 - name: Download relay client tracing config
498 uses: actions/download-artifact@v8
499 with:
500 name: relay-client-tracing-config
501 path: ${{ runner.temp }}/relay-client-tracing
502
503 - name: Load relay client tracing config
504 shell: bash
505 run: |
506 config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env"
507 tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")"
508 echo "::add-mask::$tracing_token"
509 cat "$config_path" >> "$GITHUB_ENV"
510
511 - name: Align package versions to release version
512 run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"
513
514 - uses: ./.github/actions/setup-apt-mirrors
515
516 # @t3tools/desktop#build compiles the Linux browser secret helper on a
517 # Linux host before packing, and that needs libsecret headers.
518 - name: Install browser secret helper build libraries
519 run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config
520
521 # Runs t3#build (which depends on @t3tools/web#build) and
522 # @t3tools/desktop#build, so apps/server/dist holds the server bundle
523 # plus the web client and apps/desktop/dist-electron the Electron main.
524 - name: Build JS bundle
525 run: vp run build:desktop
526
527 # Two paths under apps/ so the artifact root is apps/; consumers download
528 # into `apps` to restore both at their original locations.
529 - name: Upload JS bundle
530 uses: actions/upload-artifact@v7
531 with:
532 name: js-bundle
533 path: |
534 apps/server/dist
535 apps/desktop/dist-electron
536 if-no-files-found: error
537 retention-days: 1
538
539 # One job per platform and architecture (see release-desktop.yml), each on
540 # hardware of its own architecture, and each gated only on the JS bundle.
541 # The Windows jobs also embed the same-arch Linux CLI archive as the WSL
542 # runtime; they wait for that artifact partway through instead of for the
543 # whole Linux job. Every job builds the desktop app; all but macOS x64 also
544 # build the CLI archive for their platform, so a target either ships fully or
545 # not at all.
546 desktop_mac_arm64:
547 name: Desktop macOS arm64
548 needs: [preflight, relay_public_config, build_bundle]
549 if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }}
550 uses: ./.github/workflows/release-desktop.yml
551 secrets: inherit
552 with:
553 version: ${{ needs.preflight.outputs.version }}
554 ref: ${{ needs.preflight.outputs.ref }}
555 release_channel: ${{ needs.preflight.outputs.release_channel }}
556 clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }}
557 clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }}
558 clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }}
559 relay_url: ${{ needs.relay_public_config.outputs.relay_url }}
560 label: macOS arm64
561 runner: blacksmith-12vcpu-macos-26
562 platform: mac
563 target: dmg
564 arch: arm64
565 rust_target: aarch64-apple-darwin
566 resource_key: darwin-arm64
567 cli_archive: true
568
569 desktop_mac_x64:
570 name: Desktop macOS x64
571 needs: [preflight, relay_public_config, build_bundle]
572 if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }}
573 uses: ./.github/workflows/release-desktop.yml
574 secrets: inherit
575 with:
576 version: ${{ needs.preflight.outputs.version }}
577 ref: ${{ needs.preflight.outputs.ref }}
578 release_channel: ${{ needs.preflight.outputs.release_channel }}
579 clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }}
580 clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }}
581 clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }}
582 relay_url: ${{ needs.relay_public_config.outputs.relay_url }}
583 label: macOS x64
584 runner: blacksmith-12vcpu-macos-26
585 platform: mac
586 target: dmg
587 arch: x64
588 rust_target: x86_64-apple-darwin
589 resource_key: darwin-x64
590 # No CLI archive: Node single-executables are unsupported on x64 macOS
591 # (the SEA docs list macOS as arm64 only) and the built binary segfaults
592 # on start. The x64 desktop app is Electron and unaffected.
593 cli_archive: false
594
595 desktop_linux_x64:
596 name: Desktop Linux x64
597 needs: [preflight, relay_public_config, build_bundle]
598 if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }}
599 uses: ./.github/workflows/release-desktop.yml
600 secrets: inherit
601 with:
602 version: ${{ needs.preflight.outputs.version }}
603 ref: ${{ needs.preflight.outputs.ref }}
604 release_channel: ${{ needs.preflight.outputs.release_channel }}
605 clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }}
606 clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }}
607 clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }}
608 relay_url: ${{ needs.relay_public_config.outputs.relay_url }}
609 label: Linux x64
610 runner: blacksmith-32vcpu-ubuntu-2404
611 platform: linux
612 target: AppImage
613 arch: x64
614 rust_target: x86_64-unknown-linux-gnu
615 resource_key: linux-x64
616 cli_archive: true
617
618 # node-pty has no Linux prebuild and compiles from source, so the arm64 app
619 # and archive are built on arm64 hardware rather than cross-built.
620 desktop_linux_arm64:
621 name: Desktop Linux arm64
622 needs: [preflight, relay_public_config, build_bundle]
623 if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }}
624 uses: ./.github/workflows/release-desktop.yml
625 secrets: inherit
626 with:
627 version: ${{ needs.preflight.outputs.version }}
628 ref: ${{ needs.preflight.outputs.ref }}
629 release_channel: ${{ needs.preflight.outputs.release_channel }}
630 clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }}
631 clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }}
632 clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }}
633 relay_url: ${{ needs.relay_public_config.outputs.relay_url }}
634 label: Linux arm64
635 runner: blacksmith-16vcpu-ubuntu-2404-arm
636 platform: linux
637 target: AppImage
638 arch: arm64
639 rust_target: aarch64-unknown-linux-gnu
640 resource_key: linux-arm64
641 cli_archive: true
642
643 # The Windows jobs start with the other platforms. Their checkout, install,
644 # and toolchain setup take longer than the same-arch Linux job needs to
645 # upload its CLI archive, so the wait inside is usually instant.
646 desktop_win_x64:
647 name: Desktop Windows x64
648 needs: [preflight, relay_public_config, build_bundle]
649 if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }}
650 uses: ./.github/workflows/release-desktop.yml
651 secrets: inherit
652 with:
653 version: ${{ needs.preflight.outputs.version }}
654 ref: ${{ needs.preflight.outputs.ref }}
655 release_channel: ${{ needs.preflight.outputs.release_channel }}
656 clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }}
657 clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }}
658 clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }}
659 relay_url: ${{ needs.relay_public_config.outputs.relay_url }}
660 label: Windows x64
661 runner: blacksmith-32vcpu-windows-2025
662 platform: win
663 target: nsis
664 arch: x64
665 rust_target: x86_64-pc-windows-msvc
666 resource_key: win32-x64
667 cli_archive: true
668
669 desktop_win_arm64:
670 name: Desktop Windows arm64
671 needs: [preflight, relay_public_config, build_bundle]
672 if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }}
673 uses: ./.github/workflows/release-desktop.yml
674 secrets: inherit
675 with:
676 version: ${{ needs.preflight.outputs.version }}
677 ref: ${{ needs.preflight.outputs.ref }}
678 release_channel: ${{ needs.preflight.outputs.release_channel }}
679 clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }}
680 clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }}
681 clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }}
682 relay_url: ${{ needs.relay_public_config.outputs.relay_url }}
683 label: Windows arm64
684 runner: windows-11-arm
685 platform: win
686 target: nsis
687 arch: arm64
688 rust_target: aarch64-pc-windows-msvc
689 resource_key: win32-arm64
690 cli_archive: true
691
692 # npm gets the same bytes as the GitHub Release: the launcher plus one
693 # package per CLI archive. Preview publishes too, under the `preview`
694 # dist-tag, which nothing resolves unless asked for by name.
695 publish_cli:
696 name: Publish CLI to npm
697 needs:
698 [
699 preflight,
700 relay_public_config,
701 quality,
702 test,
703 test_server,
704 desktop_mac_arm64,
705 desktop_linux_x64,
706 desktop_linux_arm64,
707 desktop_win_x64,
708 desktop_win_arm64,
709 ]
710 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.test.result == 'success' && needs.test_server.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }}
711 runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404
712 timeout-minutes: 15
713 permissions:
714 contents: read
715 id-token: write
716 steps:
717 - name: Checkout
718 uses: actions/checkout@v6
719 with:
720 ref: ${{ needs.preflight.outputs.ref }}
721 sparse-checkout: |
722 /*
723 !/.repos/
724 sparse-checkout-cone-mode: false
725
726 - name: Setup Vite+
727 uses: voidzero-dev/setup-vp@v1
728 with:
729 node-version-file: package.json
730 cache: true
731 run-install: |
732 args:
733 - --filter=t3...
734 - --filter=@t3tools/scripts...
735
736 - name: Download all CLI archives
737 uses: actions/download-artifact@v8
738 with:
739 pattern: cli-*
740 merge-multiple: true
741 path: release-cli
742
743 - name: Build npm packages from CLI archives
744 run: node scripts/build-npm-platform-packages.ts --archives-dir release-cli --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages
745
746 # A dry run of every package first: an auth or scope error here (the
747 # @t3code org missing, a package without a trusted publisher) fails
748 # before anything is live, instead of after some platforms already are.
749 - name: Check npm publish access (dry run)
750 run: |
751 if ! node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --dry-run --verbose; then
752 echo "::error::npm publish --dry-run failed. Make sure the @t3code npm org exists and that t3 and every @t3code/t3-<platform> package has a trusted publisher registered for .github/workflows/release.yml (see docs/operations/release.md)." >&2
753 exit 1
754 fi
755
756 - name: Publish CLI packages
757 run: node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --verbose
758
759 release:
760 name: Publish GitHub Release
761 needs:
762 [
763 preflight,
764 desktop_mac_arm64,
765 desktop_mac_x64,
766 desktop_linux_x64,
767 desktop_linux_arm64,
768 desktop_win_x64,
769 desktop_win_arm64,
770 publish_cli,
771 ]
772 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' && needs.publish_cli.result == 'success' }}
773 runs-on: blacksmith-8vcpu-ubuntu-2404
774 timeout-minutes: 30
775 permissions:
776 contents: write
777 steps:
778 - name: Checkout
779 uses: actions/checkout@v6
780 with:
781 ref: ${{ needs.preflight.outputs.ref }}
782 sparse-checkout: |
783 /*
784 !/.repos/
785 sparse-checkout-cone-mode: false
786
787 - name: Setup Vite+
788 uses: voidzero-dev/setup-vp@v1
789 with:
790 node-version-file: package.json
791 cache: true
792 run-install: |
793 args:
794 - --filter=@t3tools/scripts...
795
796 - name: Download all desktop artifacts
797 uses: actions/download-artifact@v8
798 with:
799 pattern: desktop-*
800 merge-multiple: true
801 path: release-assets
802
803 - name: Download all CLI archives
804 uses: actions/download-artifact@v8
805 with:
806 pattern: cli-*
807 merge-multiple: true
808 path: release-assets
809
810 # Installers verify archives against this file, so it is written from the
811 # signed bytes that get uploaded, never from an earlier stage.
812 - name: Write CLI archive checksums
813 shell: bash
814 run: |
815 set -euo pipefail
816 cd release-assets
817 shopt -s nullglob
818 archives=(t3-*.tar.gz t3-*.zip)
819 if [[ ${#archives[@]} -eq 0 ]]; then
820 echo "No CLI archives were produced." >&2
821 exit 1
822 fi
823 sha256sum "${archives[@]}" > SHA256SUMS
824 cat SHA256SUMS
825
826 # The desktop build omits the publish config for preview versions, so
827 # electron-builder emits no updater manifests for them. Refuse to publish
828 # if one shows up anyway: a `latest*.yml` or `nightly*.yml` on a preview
829 # release is what would let a stable or nightly install update onto it.
830 - name: Refuse updater metadata on preview releases
831 if: needs.preflight.outputs.release_channel == 'preview'
832 shell: bash
833 run: |
834 set -euo pipefail
835 shopt -s nullglob extglob
836 # builder-debug.yml is electron-builder's config dump, not a feed.
837 updater_files=(release-assets/!(builder-debug).yml release-assets/*.blockmap)
838 if [[ ${#updater_files[@]} -ne 0 ]]; then
839 printf 'Preview releases must not carry updater metadata, found: %s\n' "${updater_files[*]}" >&2
840 exit 1
841 fi
842
843 # electron-updater reads one manifest per platform and channel and picks
844 # the file entry whose name carries the running arch, so the per-arch
845 # manifests the build jobs wrote are merged back into that one file.
846 - name: Merge macOS updater manifests
847 if: needs.preflight.outputs.release_channel != 'preview'
848 run: |
849 shopt -s nullglob
850 for x64_manifest in release-assets/*-mac-x64.yml; do
851 arm64_manifest="${x64_manifest%-x64.yml}.yml"
852 if [[ -f "$arm64_manifest" ]]; then
853 node scripts/merge-update-manifests.ts --platform mac "$arm64_manifest" "$x64_manifest"
854 rm -f "$x64_manifest"
855 fi
856 done
857
858 - name: Merge Windows updater manifests
859 if: needs.preflight.outputs.release_channel != 'preview'
860 run: |
861 shopt -s nullglob
862 for x64_manifest in release-assets/*-win-x64.yml; do
863 arm64_manifest="${x64_manifest%-x64.yml}-arm64.yml"
864 merged_manifest="${x64_manifest%-win-x64.yml}.yml"
865 if [[ -f "$arm64_manifest" ]]; then
866 node scripts/merge-update-manifests.ts --platform win "$x64_manifest" "$arm64_manifest" "$merged_manifest"
867 rm -f "$x64_manifest" "$arm64_manifest"
868 else
869 mv "$x64_manifest" "$merged_manifest"
870 fi
871 done
872
873 # Updater manifests and blockmaps are what electron-updater consumes.
874 # They are only listed for channels an updater is meant to follow.
875 - id: release_files
876 name: Resolve release asset list
877 shell: bash
878 run: |
879 {
880 echo 'files<<EOF'
881 echo 'release-assets/*.dmg'
882 echo 'release-assets/*.zip'
883 echo 'release-assets/*.AppImage'
884 echo 'release-assets/*.deb'
885 echo 'release-assets/*.exe'
886 if [[ "${{ needs.preflight.outputs.release_channel }}" != "preview" ]]; then
887 echo 'release-assets/*.blockmap'
888 echo 'release-assets/*.yml'
889 fi
890 echo 'release-assets/*.tar.gz'
891 echo 'release-assets/SHA256SUMS'
892 echo 'EOF'
893 } >> "$GITHUB_OUTPUT"
894
895 # A preview release gets a warning instead of generated notes. Generated
896 # notes would list every commit since the previous preview, which is
897 # unmerged branch history no one should read as a changelog, and would
898 # make the release look like any other build to someone browsing the
899 # releases page.
900 - name: Write preview release notes
901 if: needs.preflight.outputs.release_channel == 'preview'
902 shell: bash
903 run: |
904 cat > release-notes.md <<'EOF'
905 > [!WARNING]
906 > **This is a preview build. Do not install it unless you know exactly why you are here.**
907 >
908 > Preview builds are cut by maintainers from unreleased branches to exercise the release pipeline. They can be broken, receive no fixes, are never offered as updates, and are not supported. If you want T3 Code, install the [latest release](https://github.com/pingdotgg/t3code/releases/latest) or a nightly instead.
909
910 Built from `${{ needs.preflight.outputs.ref }}`.
911 EOF
912
913 - name: Publish release
914 if: needs.preflight.outputs.previous_tag != ''
915 uses: softprops/action-gh-release@v3
916 with:
917 tag_name: ${{ needs.preflight.outputs.tag }}
918 target_commitish: ${{ needs.preflight.outputs.ref }}
919 name: ${{ needs.preflight.outputs.release_name }}
920 generate_release_notes: ${{ needs.preflight.outputs.release_channel != 'preview' }}
921 body_path: ${{ needs.preflight.outputs.release_channel == 'preview' && 'release-notes.md' || '' }}
922 previous_tag: ${{ needs.preflight.outputs.previous_tag }}
923 prerelease: ${{ needs.preflight.outputs.is_prerelease }}
924 make_latest: ${{ needs.preflight.outputs.make_latest }}
925 files: ${{ steps.release_files.outputs.files }}
926 fail_on_unmatched_files: true
927 token: ${{ github.token }}
928
929 - name: Publish first release
930 if: needs.preflight.outputs.previous_tag == ''
931 uses: softprops/action-gh-release@v3
932 with:
933 tag_name: ${{ needs.preflight.outputs.tag }}
934 target_commitish: ${{ needs.preflight.outputs.ref }}
935 name: ${{ needs.preflight.outputs.release_name }}
936 generate_release_notes: ${{ needs.preflight.outputs.release_channel != 'preview' }}
937 body_path: ${{ needs.preflight.outputs.release_channel == 'preview' && 'release-notes.md' || '' }}
938 prerelease: ${{ needs.preflight.outputs.is_prerelease }}
939 make_latest: ${{ needs.preflight.outputs.make_latest }}
940 files: ${{ steps.release_files.outputs.files }}
941 fail_on_unmatched_files: true
942 token: ${{ github.token }}
943
944 publish_aur:
945 name: Publish AUR package
946 needs: [preflight, release]
947 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }}
948 uses: ./.github/workflows/publish-aur.yml
949 with:
950 release_tag: ${{ needs.preflight.outputs.tag }}
951 secrets:
952 AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
953
954 # Vercel builds the hosted web app while the desktop jobs run. `--skip-domain`
955 # keeps the custom domains (the channel and router domains) on the previous
956 # deployment until deploy_web aliases them, after the GitHub Release. It does
957 # still move the project's own *.vercel.app production hostname at build time.
958 # That hostname sits behind Vercel SSO and is not a user channel, so the
959 # npm-before-client order in docs/operations/release.md holds for users.
960 build_web:
961 name: Build hosted web app
962 needs: [preflight, relay_public_config]
963 if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }}
964 runs-on: blacksmith-8vcpu-ubuntu-2404
965 timeout-minutes: 10
966 outputs:
967 deployment_url: ${{ steps.deploy.outputs.deployment_url }}
968 env:
969 T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }}
970 T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }}
971 T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }}
972 T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }}
973 VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
974 VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
975 VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
976 T3CODE_WEB_ROUTER_URL: ${{ vars.T3CODE_WEB_ROUTER_URL }}
977 VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }}
978 steps:
979 - name: Checkout
980 uses: actions/checkout@v6
981 with:
982 ref: ${{ needs.preflight.outputs.ref }}
983 sparse-checkout: |
984 /*
985 !/.repos/
986 sparse-checkout-cone-mode: false
987
988 - name: Setup Vite+
989 uses: voidzero-dev/setup-vp@v1
990 with:
991 node-version-file: package.json
992 cache: true
993 run-install: |
994 args:
995 - --filter=@t3tools/scripts...
996 - --filter=@t3tools/web...
997
998 - name: Download relay client tracing config
999 uses: actions/download-artifact@v8
1000 with:
1001 name: relay-client-tracing-config
1002 path: ${{ runner.temp }}/relay-client-tracing
1003
1004 - name: Load relay client tracing config
1005 shell: bash
1006 run: |
1007 config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env"
1008 tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")"
1009 echo "::add-mask::$tracing_token"
1010 cat "$config_path" >> "$GITHUB_ENV"
1011
1012 - name: Align package versions to release version
1013 run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"
1014
1015 - name: Refresh release lockfile
1016 run: vp install --lockfile-only --ignore-scripts
1017
1018 - id: deploy
1019 name: Build staged deployment
1020 shell: bash
1021 run: |
1022 set -euo pipefail
1023
1024 if [[ -z "${VERCEL_TOKEN:-}" || -z "${VERCEL_ORG_ID:-}" || -z "${VERCEL_PROJECT_ID:-}" ]]; then
1025 echo "Missing one or more required Vercel secrets: VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID." >&2
1026 exit 1
1027 fi
1028
1029 router_url="${T3CODE_WEB_ROUTER_URL:-https://app.t3.codes}"
1030 if [[ "${{ needs.preflight.outputs.release_channel }}" == "stable" ]]; then
1031 channel_name="latest"
1032 else
1033 channel_name="nightly"
1034 fi
1035
1036 echo "Building hosted web app for $channel_name channel."
1037 deployment_url="$(
1038 vp dlx vercel@53.1.1 deploy \
1039 --archive=tgz \
1040 --prod \
1041 --skip-domain \
1042 --yes \
1043 --token "$VERCEL_TOKEN" \
1044 --scope "${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}" \
1045 --build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \
1046 --build-env "T3CODE_CLERK_PUBLISHABLE_KEY=${T3CODE_CLERK_PUBLISHABLE_KEY:-}" \
1047 --build-env "T3CODE_CLERK_JWT_TEMPLATE=${T3CODE_CLERK_JWT_TEMPLATE:-}" \
1048 --build-env "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=${T3CODE_CLERK_CLI_OAUTH_CLIENT_ID:-}" \
1049 --build-env "T3CODE_RELAY_URL=${T3CODE_RELAY_URL:-}" \
1050 --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=${T3CODE_RELAY_CLIENT_OTLP_TRACES_URL:-}" \
1051 --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=${T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET:-}" \
1052 --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=${T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN:-}" \
1053 --build-env "VITE_HOSTED_APP_URL=$router_url" \
1054 --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name"
1055 )"
1056 echo "Staged $deployment_url."
1057 echo "deployment_url=$deployment_url" >> "$GITHUB_OUTPUT"
1058
1059 deploy_web:
1060 name: Deploy hosted web app
1061 needs: [preflight, build_web, release]
1062 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build_web.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }}
1063 runs-on: blacksmith-2vcpu-ubuntu-2404
1064 timeout-minutes: 10
1065 env:
1066 DEPLOYMENT_URL: ${{ needs.build_web.outputs.deployment_url }}
1067 VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
1068 VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
1069 VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
1070 T3CODE_WEB_ROUTER_URL: ${{ vars.T3CODE_WEB_ROUTER_URL }}
1071 T3CODE_WEB_LATEST_DOMAIN: ${{ vars.T3CODE_WEB_LATEST_DOMAIN }}
1072 T3CODE_WEB_NIGHTLY_DOMAIN: ${{ vars.T3CODE_WEB_NIGHTLY_DOMAIN }}
1073 VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }}
1074 steps:
1075 - name: Checkout
1076 uses: actions/checkout@v6
1077 with:
1078 ref: ${{ needs.preflight.outputs.ref }}
1079 sparse-checkout: |
1080 /*
1081 !/.repos/
1082 sparse-checkout-cone-mode: false
1083
1084 - name: Setup Vite+
1085 uses: voidzero-dev/setup-vp@v1
1086 with:
1087 node-version-file: package.json
1088 cache: false
1089 run-install: false
1090
1091 - name: Alias channel
1092 shell: bash
1093 run: |
1094 set -euo pipefail
1095
1096 if [[ -z "$DEPLOYMENT_URL" ]]; then
1097 echo "Build hosted web app did not report a deployment URL." >&2
1098 exit 1
1099 fi
1100
1101 router_url="${T3CODE_WEB_ROUTER_URL:-https://app.t3.codes}"
1102 latest_domain="${T3CODE_WEB_LATEST_DOMAIN:-latest.app.t3.codes}"
1103 nightly_domain="${T3CODE_WEB_NIGHTLY_DOMAIN:-nightly.app.t3.codes}"
1104 router_domain="${router_url#http://}"
1105 router_domain="${router_domain#https://}"
1106 router_domain="${router_domain%%/*}"
1107
1108 if [[ "${{ needs.preflight.outputs.release_channel }}" == "stable" ]]; then
1109 channel_domain="$latest_domain"
1110 channel_name="latest"
1111 else
1112 channel_domain="$nightly_domain"
1113 channel_name="nightly"
1114 fi
1115
1116 vercel_scope_args=(--scope "${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}")
1117
1118 echo "Aliasing $DEPLOYMENT_URL to $channel_domain."
1119 vp dlx vercel@53.1.1 alias set "$DEPLOYMENT_URL" "$channel_domain" \
1120 --token "$VERCEL_TOKEN" \
1121 "${vercel_scope_args[@]}"
1122
1123 if [[ "$channel_name" == "latest" && -n "$router_domain" && "$router_domain" != "$channel_domain" ]]; then
1124 echo "Aliasing $DEPLOYMENT_URL to router domain $router_domain."
1125 vp dlx vercel@53.1.1 alias set "$DEPLOYMENT_URL" "$router_domain" \
1126 --token "$VERCEL_TOKEN" \
1127 "${vercel_scope_args[@]}"
1128 fi
1129
1130 # Same split as the web app: build a production deployment early, then
1131 # promote it to t3.codes once the release it links to exists. As above, the
1132 # project's own *.vercel.app hostname moves at build time.
1133 build_marketing:
1134 name: Build marketing site
1135 needs: [preflight]
1136 if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.preflight.outputs.release_channel == 'nightly' }}
1137 runs-on: blacksmith-8vcpu-ubuntu-2404
1138 timeout-minutes: 10
1139 outputs:
1140 deployment_url: ${{ steps.deploy.outputs.deployment_url }}
1141 project_id: ${{ steps.deploy.outputs.project_id }}
1142 env:
1143 VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
1144 VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
1145 VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }}
1146 steps:
1147 - name: Checkout
1148 uses: actions/checkout@v6
1149 with:
1150 ref: ${{ needs.preflight.outputs.ref }}
1151 sparse-checkout: |
1152 /*
1153 !/.repos/
1154 sparse-checkout-cone-mode: false
1155
1156 - name: Setup Vite+
1157 uses: voidzero-dev/setup-vp@v1
1158 with:
1159 node-version-file: package.json
1160 cache: true
1161 run-install: |
1162 args:
1163 - --filter=@t3tools/marketing...
1164
1165 - id: deploy
1166 name: Build staged deployment
1167 shell: bash
1168 run: |
1169 set -euo pipefail
1170
1171 if [[ -z "${VERCEL_TOKEN:-}" || -z "${VERCEL_ORG_ID:-}" ]]; then
1172 echo "Missing one or more required Vercel secrets: VERCEL_TOKEN, VERCEL_ORG_ID." >&2
1173 exit 1
1174 fi
1175
1176 VERCEL_PROJECT_ID="$(
1177 curl --fail --silent --show-error \
1178 --header "Authorization: Bearer $VERCEL_TOKEN" \
1179 "https://api.vercel.com/v9/projects/t3code-marketing?teamId=$VERCEL_ORG_ID" \
1180 | jq --exit-status --raw-output '.id'
1181 )"
1182 export VERCEL_PROJECT_ID
1183
1184 deployment_url="$(
1185 vp dlx vercel@53.1.1 deploy \
1186 --archive=tgz \
1187 --prod \
1188 --skip-domain \
1189 --yes \
1190 --token "$VERCEL_TOKEN" \
1191 --scope "${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}"
1192 )"
1193 echo "Staged $deployment_url."
1194 echo "deployment_url=$deployment_url" >> "$GITHUB_OUTPUT"
1195 echo "project_id=$VERCEL_PROJECT_ID" >> "$GITHUB_OUTPUT"
1196
1197 deploy_marketing:
1198 name: Deploy marketing site
1199 needs: [preflight, build_marketing, release]
1200 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build_marketing.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'nightly' }}
1201 runs-on: blacksmith-2vcpu-ubuntu-2404
1202 timeout-minutes: 10
1203 env:
1204 DEPLOYMENT_URL: ${{ needs.build_marketing.outputs.deployment_url }}
1205 VERCEL_PROJECT_ID: ${{ needs.build_marketing.outputs.project_id }}
1206 VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
1207 VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
1208 VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }}
1209 steps:
1210 - name: Checkout
1211 uses: actions/checkout@v6
1212 with:
1213 ref: ${{ needs.preflight.outputs.ref }}
1214 sparse-checkout: |
1215 /*
1216 !/.repos/
1217 sparse-checkout-cone-mode: false
1218
1219 - name: Setup Vite+
1220 uses: voidzero-dev/setup-vp@v1
1221 with:
1222 node-version-file: package.json
1223 cache: false
1224 run-install: false
1225
1226 - name: Promote marketing site
1227 shell: bash
1228 run: |
1229 set -euo pipefail
1230
1231 if [[ -z "$DEPLOYMENT_URL" ]]; then
1232 echo "Build marketing site did not report a deployment URL." >&2
1233 exit 1
1234 fi
1235
1236 echo "Promoting $DEPLOYMENT_URL."
1237 vp dlx vercel@53.1.1 promote "$DEPLOYMENT_URL" \
1238 --yes \
1239 --token "$VERCEL_TOKEN" \
1240 --scope "${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}"
1241
1242 finalize:
1243 name: Finalize release
1244 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'stable' }}
1245 needs: [preflight, release]
1246 runs-on: blacksmith-8vcpu-ubuntu-2404
1247 timeout-minutes: 10
1248 steps:
1249 - id: app_token
1250 name: Mint release app token
1251 uses: actions/create-github-app-token@v2
1252 with:
1253 app-id: ${{ secrets.RELEASE_APP_ID }}
1254 private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
1255 owner: ${{ github.repository_owner }}
1256
1257 - name: Checkout
1258 uses: actions/checkout@v6
1259 with:
1260 ref: main
1261 fetch-depth: 0
1262 token: ${{ steps.app_token.outputs.token }}
1263 persist-credentials: true
1264 sparse-checkout: |
1265 /*
1266 !/.repos/
1267 sparse-checkout-cone-mode: false
1268
1269 - id: app_bot
1270 name: Resolve GitHub App bot identity
1271 env:
1272 GH_TOKEN: ${{ steps.app_token.outputs.token }}
1273 APP_SLUG: ${{ steps.app_token.outputs.app-slug }}
1274 run: |
1275 user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)"
1276 echo "name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT"
1277 echo "email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT"
1278
1279 - name: Setup Vite+
1280 uses: voidzero-dev/setup-vp@v1
1281 with:
1282 node-version-file: package.json
1283 cache: true
1284 run-install: |
1285 args:
1286 - --filter=@t3tools/scripts...
1287 - --filter=@t3tools/oxlint-plugin-t3code...
1288
1289 - id: update_versions
1290 name: Update version strings
1291 env:
1292 RELEASE_VERSION: ${{ needs.preflight.outputs.version }}
1293 run: node scripts/update-release-package-versions.ts "$RELEASE_VERSION" --github-output
1294
1295 - name: Format package.json files
1296 if: steps.update_versions.outputs.changed == 'true'
1297 run: vp fmt apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json
1298
1299 - name: Refresh lockfile
1300 if: steps.update_versions.outputs.changed == 'true'
1301 run: vp install --lockfile-only --ignore-scripts
1302
1303 - name: Commit and push version bump
1304 if: steps.update_versions.outputs.changed == 'true'
1305 shell: bash
1306 env:
1307 RELEASE_TAG: ${{ needs.preflight.outputs.tag }}
1308 run: |
1309 if git diff --quiet -- apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml; then
1310 echo "No version changes to commit."
1311 exit 0
1312 fi
1313
1314 git config user.name "${{ steps.app_bot.outputs.name }}"
1315 git config user.email "${{ steps.app_bot.outputs.email }}"
1316
1317 git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml
1318 git commit -m "chore(release): prepare $RELEASE_TAG"
1319 git push origin HEAD:main
1320
1321 announce_discord:
1322 name: Announce release on Discord
1323 if: |
1324 always() && !cancelled() &&
1325 needs.preflight.result == 'success' &&
1326 needs.preflight.outputs.release_channel != 'preview' &&
1327 needs.relay_public_config.result == 'success' &&
1328 needs.release.result == 'success' &&
1329 needs.deploy_web.result == 'success' &&
1330 (needs.finalize.result == 'success' || needs.finalize.result == 'skipped')
1331 needs: [preflight, relay_public_config, release, deploy_web, finalize]
1332 runs-on: blacksmith-8vcpu-ubuntu-2404
1333 timeout-minutes: 10
1334 steps:
1335 - name: Checkout
1336 uses: actions/checkout@v6
1337 with:
1338 ref: ${{ needs.preflight.outputs.ref }}
1339 sparse-checkout: |
1340 /*
1341 !/.repos/
1342 sparse-checkout-cone-mode: false
1343
1344 - name: Setup Vite+
1345 uses: voidzero-dev/setup-vp@v1
1346 with:
1347 node-version-file: package.json
1348 cache: true
1349 run-install: |
1350 args:
1351 - --filter=@t3tools/scripts...
1352
1353 - name: Read published release notes
1354 if: needs.preflight.outputs.is_prerelease == 'true'
1355 continue-on-error: true
1356 env:
1357 GH_TOKEN: ${{ github.token }}
1358 GH_REPO: ${{ github.repository }}
1359 RELEASE_TAG: ${{ needs.preflight.outputs.tag }}
1360 RELEASE_CHANNEL: ${{ needs.preflight.outputs.release_channel }}
1361 run: |
1362 if [ "$RELEASE_CHANNEL" = 'nightly' ]; then
1363 if ! gh release view "$RELEASE_TAG" --json body --jq '.body // ""' > "$RUNNER_TEMP/discord-release-notes.md"; then
1364 : > "$RUNNER_TEMP/discord-release-notes.md"
1365 echo '::warning::Could not read published release notes; announcing without a changelog.'
1366 fi
1367 else
1368 : > "$RUNNER_TEMP/discord-release-notes.md"
1369 fi
1370
1371 - name: Announce prerelease on Discord
1372 if: needs.preflight.outputs.is_prerelease == 'true'
1373 continue-on-error: true
1374 env:
1375 DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_RELEASE_NIGHTLY_ROLE_ID }}
1376 DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }}
1377 run: |
1378 node scripts/notify-discord-release.ts prerelease \
1379 --role-id "$DISCORD_MENTION_ROLE_ID" \
1380 --release-name "${{ needs.preflight.outputs.release_name }}" \
1381 --release-version "${{ needs.preflight.outputs.version }}" \
1382 --tag "${{ needs.preflight.outputs.tag }}" \
1383 --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}" \
1384 --release-notes-file "$RUNNER_TEMP/discord-release-notes.md"
1385
1386 - name: Announce latest release on Discord
1387 if: needs.preflight.outputs.make_latest == 'true'
1388 continue-on-error: true
1389 env:
1390 DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_RELEASE_LATEST_ROLE_ID }}
1391 DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }}
1392 run: |
1393 node scripts/notify-discord-release.ts latest \
1394 --role-id "$DISCORD_MENTION_ROLE_ID" \
1395 --release-name "${{ needs.preflight.outputs.release_name }}" \
1396 --release-version "${{ needs.preflight.outputs.version }}" \
1397 --tag "${{ needs.preflight.outputs.tag }}" \
1398 --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}"