name: Release on: push: tags: - "v*.*.*" - "!v*-nightly.*" - "!v*-preview.*" schedule: # Avoid minute zero, when GitHub scheduled jobs are busiest. - cron: "8,38 * * * *" workflow_dispatch: inputs: channel: description: "Release channel" required: false default: preview type: choice options: - preview - stable - nightly version: description: "Stable version override (for example 1.2.3). Defaults to the version the latest nightly previewed." required: false type: string # Serialize nightlies (scheduled and manual) so overlapping runs cannot build # the same commit twice or publish out of order. Stable tag releases get their # own group so a nightly never blocks them. Running publishers are never # canceled, and queue: max keeps every pending run instead of the default # newest-wins single slot, so a queued stable tag can never be silently # dropped. Automatic nightlies recheck the release gap after leaving the queue. concurrency: group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly' || inputs.channel == 'preview') && 'nightly' || 'stable' }} cancel-in-progress: false queue: max permissions: # The Windows desktop jobs read this run's artifact list to wait for the # Linux CLI archive they embed (see release-desktop.yml). actions: read contents: read id-token: none jobs: # Picks the commit every later job builds. Nightlies and tag pushes build the # triggering commit. Manual stable releases build the commit of the latest # published nightly, so stable only ever ships a build that nightly users # have already run. Scheduled runs also decide here whether a nightly is due. resolve_commit: name: Resolve release commit if: github.repository == 'pingdotgg/t3code' runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 5 outputs: ref: ${{ steps.resolve.outputs.ref }} nightly_version: ${{ steps.resolve.outputs.nightly_version }} has_changes: ${{ steps.resolve.outputs.has_changes }} steps: - name: Checkout uses: actions/checkout@v6 with: sparse-checkout: .github/scripts - id: resolve name: Resolve release commit uses: actions/github-script@v8 env: DISPATCH_CHANNEL: ${{ inputs.channel }} with: script: | const { assertCommitOnDefaultBranch, assertReleaseSource, shouldReleaseNightly, resolveLatestNightlyCommit, } = require('./.github/scripts/check-nightly-release.cjs'); const dispatchChannel = process.env.DISPATCH_CHANNEL || 'preview'; const releaseChannel = context.eventName === 'schedule' ? 'nightly' : context.eventName === 'workflow_dispatch' ? dispatchChannel : 'stable'; await assertReleaseSource({ github, context, releaseChannel }); if (context.eventName === 'schedule') { core.setOutput('has_changes', await shouldReleaseNightly({ github, context, core })); core.setOutput('ref', context.sha); } else if (context.eventName === 'workflow_dispatch' && dispatchChannel === 'stable') { const { tag, sha, version } = await resolveLatestNightlyCommit({ github, context, core }); await assertCommitOnDefaultBranch({ github, context, sha }); core.notice(`Stable release builds ${sha}, the commit shipped by ${tag}.`); core.setOutput('ref', sha); core.setOutput('nightly_version', version); } else { core.setOutput('ref', context.sha); } preflight: name: Preflight needs: [resolve_commit] if: | needs.resolve_commit.result == 'success' && (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true') runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 outputs: release_channel: ${{ steps.release_meta.outputs.release_channel }} version: ${{ steps.release_meta.outputs.version }} tag: ${{ steps.release_meta.outputs.tag }} release_name: ${{ steps.release_meta.outputs.name }} short_sha: ${{ steps.release_meta.outputs.short_sha }} previous_tag: ${{ steps.previous_tag.outputs.previous_tag }} cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }} is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }} make_latest: ${{ steps.release_meta.outputs.make_latest }} ref: ${{ needs.resolve_commit.outputs.ref }} steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ needs.resolve_commit.outputs.ref }} fetch-depth: 0 sparse-checkout: | /* !/.repos/ sparse-checkout-cone-mode: false - name: Setup dependencies uses: ./.github/actions/setup-ci-deps with: task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - id: release_meta name: Resolve release version shell: bash env: DISPATCH_CHANNEL: ${{ github.event.inputs.channel }} DISPATCH_VERSION: ${{ github.event.inputs.version }} NIGHTLY_VERSION: ${{ needs.resolve_commit.outputs.nightly_version }} NIGHTLY_DATE: ${{ github.run_started_at }} NIGHTLY_SHA: ${{ needs.resolve_commit.outputs.ref }} NIGHTLY_RUN_NUMBER: ${{ github.run_number }} run: | if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-preview}" == "nightly" ) ]]; then nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" node scripts/resolve-nightly-release.ts \ --date "$nightly_date" \ --run-number "$NIGHTLY_RUN_NUMBER" \ --sha "$NIGHTLY_SHA" \ --github-output echo "release_channel=nightly" >> "$GITHUB_OUTPUT" echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT" echo "is_prerelease=true" >> "$GITHUB_OUTPUT" echo "make_latest=false" >> "$GITHUB_OUTPUT" elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-preview}" == "preview" ]]; then # Manual-only test train: exercises the whole release flow for a # commit end users must never receive. Never scheduled. # Same versioning as nightly under its own prerelease identifier. # A preview release is reachable only by asking for it: npm gets it # under the `preview` dist-tag, which nothing resolves by default, # its desktop builds carry no update feed, and no updater manifest # is attached to the release, so neither stable nor nightly # installs can ever be offered one. nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" node scripts/resolve-nightly-release.ts \ --channel preview \ --date "$nightly_date" \ --run-number "$NIGHTLY_RUN_NUMBER" \ --sha "$NIGHTLY_SHA" \ --github-output echo "release_channel=preview" >> "$GITHUB_OUTPUT" echo "cli_dist_tag=preview" >> "$GITHUB_OUTPUT" echo "is_prerelease=true" >> "$GITHUB_OUTPUT" echo "make_latest=false" >> "$GITHUB_OUTPUT" else if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then raw="${DISPATCH_VERSION:-$NIGHTLY_VERSION}" if [[ -z "$raw" ]]; then echo "workflow_dispatch stable releases need a version input or a published nightly." >&2 exit 1 fi else raw="${GITHUB_REF_NAME}" fi version="${raw#v}" if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then echo "Invalid release version: $raw" >&2 exit 1 fi echo "release_channel=stable" >> "$GITHUB_OUTPUT" echo "version=$version" >> "$GITHUB_OUTPUT" echo "tag=v$version" >> "$GITHUB_OUTPUT" echo "name=T3 Code v$version" >> "$GITHUB_OUTPUT" echo "cli_dist_tag=latest" >> "$GITHUB_OUTPUT" if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "is_prerelease=false" >> "$GITHUB_OUTPUT" echo "make_latest=true" >> "$GITHUB_OUTPUT" else echo "is_prerelease=true" >> "$GITHUB_OUTPUT" echo "make_latest=false" >> "$GITHUB_OUTPUT" fi fi - id: previous_tag name: Resolve previous release tag run: | node scripts/resolve-previous-release-tag.ts \ --channel "${{ steps.release_meta.outputs.release_channel }}" \ --current-tag "${{ steps.release_meta.outputs.tag }}" \ --github-output # Share only the verification results, not the large registry metadata cache. - name: Upload dependency verification continue-on-error: true uses: actions/upload-artifact@v7 with: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata/lockfile-verified.jsonl quality: name: Release quality checks needs: [preflight] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | /* !/.repos/ sparse-checkout-cone-mode: false - name: Setup dependencies uses: ./.github/actions/setup-ci-deps with: task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron - name: Check run: vp check - name: Typecheck run: vp run typecheck # Keep tests on their own runners, using the same package split and server # shards as CI (test, test_web, test_server in ci.yml). Keep the two in sync. test: name: Release tests needs: [preflight] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | /* !/.repos/ sparse-checkout-cone-mode: false - name: Setup dependencies uses: ./.github/actions/setup-ci-deps with: task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron - uses: ./.github/actions/setup-apt-mirrors - name: Install browser secret helper build libraries run: | sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config build-essential - name: Test run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' --filter '!@t3tools/web' test # Web browser tests time out when they share a runner with the other package # suites: Chromium gets too little CPU and diff workers miss their 10s start budget. test_web: name: Release web tests needs: [preflight] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | /* !/.repos/ sparse-checkout-cone-mode: false - name: Setup dependencies uses: ./.github/actions/setup-ci-deps with: task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Resolve Playwright version id: playwright_version run: echo "version=$(node -p "require('./apps/web/node_modules/playwright/package.json').version")" >> "$GITHUB_OUTPUT" - name: Cache Chromium id: chromium_cache uses: actions/cache@v6 with: path: ~/.cache/ms-playwright key: playwright-chromium-shell-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright_version.outputs.version }} - name: Install Chromium for web browser tests if: steps.chromium_cache.outputs.cache-hit != 'true' run: node apps/web/node_modules/playwright/cli.js install --only-shell chromium - name: Install missing Chromium system dependencies run: | browser=$(find "$HOME/.cache/ms-playwright" -name chrome-headless-shell -type f -print -quit) test -n "$browser" if ldd "$browser" | grep '=> not found'; then node apps/web/node_modules/playwright/cli.js install-deps chromium fi - name: Test run: vp run --filter @t3tools/web test # Browser tests run source modules; this boots the production chunk graph. - name: Boot the built web client run: | vp run --filter @t3tools/web build node apps/web/scripts/verify-built-boot.ts # Server test files run one at a time, so more small runners beat fewer big ones. test_server: name: Release server tests ${{ matrix.shard }} needs: [preflight] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 10 strategy: fail-fast: false matrix: shard: [1, 2, 3, 4, 5, 6, 7, 8] steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | /* !/.repos/ sparse-checkout-cone-mode: false - name: Setup dependencies uses: ./.github/actions/setup-ci-deps with: task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # No Electron setup here: `t3` (apps/server) has no Electron dependency # and none of its tests touch the runtime. Only the non-server `test` # job, which covers @t3tools/desktop, needs the download. - name: Install Chromium for server browser integration tests run: node apps/server/node_modules/playwright-core/cli.js install --with-deps --only-shell chromium - name: Test run: vp run --filter t3 test --shard ${{ matrix.shard }}/${{ strategy.job-total }} relay_public_config: name: Resolve T3 Connect public config # Consumes only the release commit, not preflight's resolved version, so it # runs alongside preflight instead of after it. The condition mirrors preflight's. needs: [resolve_commit] if: | needs.resolve_commit.result == 'success' && (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true') runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 5 environment: name: production outputs: clerk_publishable_key: ${{ steps.public_config.outputs.clerk_publishable_key }} clerk_jwt_template: ${{ steps.public_config.outputs.clerk_jwt_template }} clerk_cli_oauth_client_id: ${{ steps.public_config.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ steps.public_config.outputs.relay_url }} env: CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }} RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }} CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} CLERK_JWT_TEMPLATE: ${{ vars.CLERK_JWT_TEMPLATE }} CLERK_CLI_OAUTH_CLIENT_ID: ${{ vars.CLERK_CLI_OAUTH_CLIENT_ID }} steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ needs.resolve_commit.outputs.ref }} sparse-checkout: | /* !/.repos/ sparse-checkout-cone-mode: false - name: Setup dependencies uses: ./.github/actions/setup-ci-deps with: install-args: --filter=t3code-relay... # The deployed stack's outputs, read from Alchemy's state store without # planning or applying. Select the backend explicitly to avoid loading # the stack's unrelated provider credentials; suppress informational logs # and strip the credential-refresh progress prefix before parsing JSON. # Redacted values are persisted as # {"__redacted__": ""}; the token is masked before it is written. - name: Read production relay tracing config shell: bash working-directory: infra/relay run: | set -euo pipefail output="$(npx alchemy state read T3CodeRelay/prod/output --backend cloudflare --log-level error --no-input | sed -n '/^{/,$p')" field() { jq -er --arg key "$1" '.[$key] | if type == "object" then .__redacted__ else . end | select(. != null and . != "")' <<<"$output" \ || { echo "Relay stack output is missing $1" >&2; exit 1; } } url="$(field clientTracingUrl)" dataset="$(field clientTracingDataset)" token="$(field clientTracingToken)" echo "::add-mask::$token" { echo "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=$url" echo "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=$dataset" echo "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=$token" } > "$RUNNER_TEMP/relay-client-tracing.env" - name: Upload relay client tracing config uses: actions/upload-artifact@v7 with: name: relay-client-tracing-config path: ${{ runner.temp }}/relay-client-tracing.env if-no-files-found: error retention-days: 1 - id: public_config name: Resolve production relay public config shell: bash run: | set -euo pipefail relay_domain="${RELAY_DOMAIN:-}" if [[ -z "$relay_domain" && -n "${RELAY_API_ZONE_NAME:-}" ]]; then relay_domain="relay.$RELAY_API_ZONE_NAME" fi required=( relay_domain CLERK_PUBLISHABLE_KEY CLERK_JWT_TEMPLATE CLERK_CLI_OAUTH_CLIENT_ID ) missing=() for name in "${required[@]}"; do if [[ -z "${!name:-}" ]]; then missing+=("$name") fi done if (( ${#missing[@]} > 0 )); then printf 'Missing required relay deployment configuration: %s\n' "${missing[*]}" >&2 exit 1 fi echo "clerk_publishable_key=$CLERK_PUBLISHABLE_KEY" >> "$GITHUB_OUTPUT" echo "clerk_jwt_template=$CLERK_JWT_TEMPLATE" >> "$GITHUB_OUTPUT" echo "clerk_cli_oauth_client_id=$CLERK_CLI_OAUTH_CLIENT_ID" >> "$GITHUB_OUTPUT" echo "relay_url=https://$relay_domain" >> "$GITHUB_OUTPUT" # The platform-independent JS (server bundle, web client, Electron main) is # built exactly once here and handed to every platform job as `js-bundle`. # The relay/Clerk values are baked into the bundle, so they belong to this # job rather than to the packaging jobs. build_bundle: name: Build JS bundle # Same gating as relay_public_config: only the release commit is needed, so # this runs alongside preflight. See the condition comment there. needs: [preflight, relay_public_config] if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: 30 env: T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | /* !/.repos/ sparse-checkout-cone-mode: false # pnpm checks the lockfile and policy before reusing this result. A missing # artifact leaves the cache empty, so installation runs the checks again. - name: Download dependency verification continue-on-error: true uses: actions/download-artifact@v8 with: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata - name: Setup dependencies uses: ./.github/actions/setup-ci-deps with: install-args: --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - name: Download relay client tracing config uses: actions/download-artifact@v8 with: name: relay-client-tracing-config path: ${{ runner.temp }}/relay-client-tracing - name: Load relay client tracing config shell: bash run: | config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" echo "::add-mask::$tracing_token" cat "$config_path" >> "$GITHUB_ENV" - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - uses: ./.github/actions/setup-apt-mirrors # @t3tools/desktop#build compiles the Linux browser secret helper on a # Linux host before packing, and that needs libsecret headers. - name: Install browser secret helper build libraries run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config # Runs t3#build (which depends on @t3tools/web#build) and # @t3tools/desktop#build, so apps/server/dist holds the server bundle # plus the web client and apps/desktop/dist-electron the Electron main. # The release channel is already stable, nightly, or preview. Builds in # other repositories report "dev" telemetry. - name: Build JS bundle env: T3CODE_TELEMETRY_CHANNEL: ${{ github.repository == 'pingdotgg/t3code' && needs.preflight.outputs.release_channel || 'dev' }} run: vp run build:desktop # Two paths under apps/ so the artifact root is apps/; consumers download # into `apps` to restore both at their original locations. - name: Upload JS bundle uses: actions/upload-artifact@v7 with: name: js-bundle path: | apps/server/dist apps/desktop/dist-electron if-no-files-found: error retention-days: 1 # One job per platform and architecture (see release-desktop.yml), each # gated only on the JS bundle. Windows targets build on Linux and run their # native checks on a Windows runner of their own architecture. # The Windows jobs also embed the same-arch Linux CLI archive as the WSL # runtime; they wait for that artifact partway through instead of for the # whole Linux job. Every job builds the desktop app; all but macOS x64 also # build the CLI archive for their platform, so a target either ships fully or # not at all. desktop_mac_arm64: name: Desktop macOS arm64 needs: [preflight, relay_public_config, build_bundle] if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} uses: ./.github/workflows/release-desktop.yml secrets: inherit with: version: ${{ needs.preflight.outputs.version }} ref: ${{ needs.preflight.outputs.ref }} release_channel: ${{ needs.preflight.outputs.release_channel }} clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ needs.relay_public_config.outputs.relay_url }} label: macOS arm64 runner: blacksmith-12vcpu-macos-26 platform: mac target: dmg arch: arm64 rust_target: aarch64-apple-darwin resource_key: darwin-arm64 cli_archive: true launch_check: true desktop_mac_x64: name: Desktop macOS x64 needs: [preflight, relay_public_config, build_bundle] if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} uses: ./.github/workflows/release-desktop.yml secrets: inherit with: version: ${{ needs.preflight.outputs.version }} ref: ${{ needs.preflight.outputs.ref }} release_channel: ${{ needs.preflight.outputs.release_channel }} clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ needs.relay_public_config.outputs.relay_url }} label: macOS x64 runner: blacksmith-12vcpu-macos-26 platform: mac target: dmg arch: x64 rust_target: x86_64-apple-darwin resource_key: darwin-x64 # No CLI archive: Node single-executables are unsupported on x64 macOS # (the SEA docs list macOS as arm64 only) and the built binary segfaults # on start. The x64 desktop app is Electron and unaffected. cli_archive: false desktop_linux_x64: name: Desktop Linux x64 needs: [preflight, relay_public_config, build_bundle] if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} uses: ./.github/workflows/release-desktop.yml secrets: inherit with: version: ${{ needs.preflight.outputs.version }} ref: ${{ needs.preflight.outputs.ref }} release_channel: ${{ needs.preflight.outputs.release_channel }} clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ needs.relay_public_config.outputs.relay_url }} label: Linux x64 runner: blacksmith-32vcpu-ubuntu-2404 platform: linux target: AppImage arch: x64 rust_target: x86_64-unknown-linux-gnu resource_key: linux-x64 cli_archive: true launch_check: true # node-pty has no Linux prebuild and compiles from source, so the arm64 app # and archive are built on arm64 hardware rather than cross-built. desktop_linux_arm64: name: Desktop Linux arm64 needs: [preflight, relay_public_config, build_bundle] if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} uses: ./.github/workflows/release-desktop.yml secrets: inherit with: version: ${{ needs.preflight.outputs.version }} ref: ${{ needs.preflight.outputs.ref }} release_channel: ${{ needs.preflight.outputs.release_channel }} clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ needs.relay_public_config.outputs.relay_url }} label: Linux arm64 runner: blacksmith-16vcpu-ubuntu-2404-arm platform: linux target: AppImage arch: arm64 rust_target: aarch64-unknown-linux-gnu resource_key: linux-arm64 cli_archive: true # The Windows jobs start with the other platforms and wait for the same-arch # Linux CLI archive after their other setup. Packaging is mostly CPU and # small-file work, which Linux runners do faster than Windows ones, so both # Windows targets build and sign on Linux and only the native checks run on # Windows. desktop_win_x64: name: Desktop Windows x64 needs: [preflight, relay_public_config, build_bundle] if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} uses: ./.github/workflows/release-desktop.yml secrets: inherit with: version: ${{ needs.preflight.outputs.version }} ref: ${{ needs.preflight.outputs.ref }} release_channel: ${{ needs.preflight.outputs.release_channel }} clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ needs.relay_public_config.outputs.relay_url }} label: Windows x64 runner: blacksmith-16vcpu-ubuntu-2404 verify_runner: windows-2025 platform: win target: nsis arch: x64 rust_target: x86_64-pc-windows-msvc resource_key: win32-x64 cli_archive: true desktop_win_arm64: name: Desktop Windows arm64 needs: [preflight, relay_public_config, build_bundle] if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} uses: ./.github/workflows/release-desktop.yml secrets: inherit with: version: ${{ needs.preflight.outputs.version }} ref: ${{ needs.preflight.outputs.ref }} release_channel: ${{ needs.preflight.outputs.release_channel }} clerk_publishable_key: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} clerk_jwt_template: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ needs.relay_public_config.outputs.relay_url }} label: Windows arm64 runner: blacksmith-16vcpu-ubuntu-2404 verify_runner: windows-11-arm platform: win target: nsis arch: arm64 rust_target: aarch64-pc-windows-msvc resource_key: win32-arm64 cli_archive: true release: name: Publish GitHub Release needs: [ preflight, relay_public_config, quality, test, test_web, test_server, desktop_mac_arm64, desktop_mac_x64, desktop_linux_x64, desktop_linux_arm64, desktop_win_x64, desktop_win_arm64, ] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.test.result == 'success' && needs.test_web.result == 'success' && needs.test_server.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' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 30 permissions: contents: write steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | /* !/.repos/ sparse-checkout-cone-mode: false - name: Setup dependencies uses: ./.github/actions/setup-ci-deps with: install-args: --filter=@t3tools/scripts... - name: Download all desktop artifacts uses: actions/download-artifact@v8 with: pattern: desktop-* merge-multiple: true path: release-assets - name: Download all CLI archives uses: actions/download-artifact@v8 with: pattern: cli-* merge-multiple: true path: release-assets - name: Download internal Android APK for Nightly if: needs.preflight.outputs.release_channel == 'nightly' continue-on-error: true env: GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} PACKAGE_NAME: com.t3tools.t3code APK_OUTPUT_DIR: release-assets run: | if [ -n "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" ]; then node .github/scripts/mirror-play-apk.cjs fi # Installers verify archives against this file, so it is written from the # signed bytes that get uploaded, never from an earlier stage. - name: Write CLI archive checksums shell: bash run: | set -euo pipefail cd release-assets shopt -s nullglob archives=(t3-*.tar.gz t3-*.zip) if [[ ${#archives[@]} -eq 0 ]]; then echo "No CLI archives were produced." >&2 exit 1 fi sha256sum "${archives[@]}" > SHA256SUMS cat SHA256SUMS # The desktop build omits the publish config for preview versions, so # electron-builder emits no updater manifests for them. Refuse to publish # if one shows up anyway: a `latest*.yml` or `nightly*.yml` on a preview # release is what would let a stable or nightly install update onto it. - name: Refuse updater metadata on preview releases if: needs.preflight.outputs.release_channel == 'preview' shell: bash run: | set -euo pipefail shopt -s nullglob extglob # builder-debug.yml is electron-builder's config dump, not a feed. updater_files=(release-assets/!(builder-debug).yml release-assets/*.blockmap) if [[ ${#updater_files[@]} -ne 0 ]]; then printf 'Preview releases must not carry updater metadata, found: %s\n' "${updater_files[*]}" >&2 exit 1 fi # electron-updater reads one manifest per platform and channel and picks # the file entry whose name carries the running arch, so the per-arch # manifests the build jobs wrote are merged back into that one file. - name: Merge macOS updater manifests if: needs.preflight.outputs.release_channel != 'preview' run: | shopt -s nullglob for x64_manifest in release-assets/*-mac-x64.yml; do arm64_manifest="${x64_manifest%-x64.yml}.yml" if [[ -f "$arm64_manifest" ]]; then node scripts/merge-update-manifests.ts --platform mac "$arm64_manifest" "$x64_manifest" rm -f "$x64_manifest" fi done - name: Merge Windows updater manifests if: needs.preflight.outputs.release_channel != 'preview' run: | shopt -s nullglob for x64_manifest in release-assets/*-win-x64.yml; do arm64_manifest="${x64_manifest%-x64.yml}-arm64.yml" merged_manifest="${x64_manifest%-win-x64.yml}.yml" if [[ -f "$arm64_manifest" ]]; then node scripts/merge-update-manifests.ts --platform win "$x64_manifest" "$arm64_manifest" "$merged_manifest" rm -f "$x64_manifest" "$arm64_manifest" else mv "$x64_manifest" "$merged_manifest" fi done # Updater manifests and blockmaps are what electron-updater consumes. # They are only listed for channels an updater is meant to follow. - id: release_files name: Resolve release asset list shell: bash run: | { echo 'files<> "$GITHUB_OUTPUT" # A preview release gets a warning instead of generated notes. Generated # notes would list every commit since the previous preview, which is # unmerged branch history no one should read as a changelog, and would # make the release look like any other build to someone browsing the # releases page. - name: Write preview release notes if: needs.preflight.outputs.release_channel == 'preview' shell: bash run: | cat > release-notes.md <<'EOF' > [!WARNING] > **This is a preview build. Do not install it unless you know exactly why you are here.** > > 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. Built from `${{ needs.preflight.outputs.ref }}`. EOF - name: Publish release if: needs.preflight.outputs.previous_tag != '' uses: softprops/action-gh-release@v3 with: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} name: ${{ needs.preflight.outputs.release_name }} generate_release_notes: ${{ needs.preflight.outputs.release_channel != 'preview' }} body_path: ${{ needs.preflight.outputs.release_channel == 'preview' && 'release-notes.md' || '' }} previous_tag: ${{ needs.preflight.outputs.previous_tag }} prerelease: ${{ needs.preflight.outputs.is_prerelease }} make_latest: ${{ needs.preflight.outputs.make_latest }} files: ${{ steps.release_files.outputs.files }} fail_on_unmatched_files: true token: ${{ github.token }} - name: Publish first release if: needs.preflight.outputs.previous_tag == '' uses: softprops/action-gh-release@v3 with: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} name: ${{ needs.preflight.outputs.release_name }} generate_release_notes: ${{ needs.preflight.outputs.release_channel != 'preview' }} body_path: ${{ needs.preflight.outputs.release_channel == 'preview' && 'release-notes.md' || '' }} prerelease: ${{ needs.preflight.outputs.is_prerelease }} make_latest: ${{ needs.preflight.outputs.make_latest }} files: ${{ steps.release_files.outputs.files }} fail_on_unmatched_files: true token: ${{ github.token }} # The `t3` npm package is a launcher that downloads this version's CLI # archive from the GitHub Release, so it publishes only once that release # exists. Preview publishes too, under the `preview` dist-tag, which # nothing resolves unless asked for by name. publish_cli: name: Publish CLI to npm needs: [preflight, release] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }} runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 15 permissions: contents: read id-token: write steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | /* !/.repos/ sparse-checkout-cone-mode: false - name: Setup dependencies uses: ./.github/actions/setup-ci-deps with: install-args: --filter=t3... --filter=@t3tools/scripts... - name: Build the npm launcher run: node scripts/build-npm-launcher.ts --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages - name: Publish the npm launcher run: | if ! node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --verbose; then echo "::error::npm publish failed. Make sure package t3 has a trusted publisher registered for .github/workflows/release.yml (see docs/operations/release.md)." >&2 exit 1 fi publish_aur: name: Publish AUR package needs: [preflight, release] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} uses: ./.github/workflows/publish-aur.yml with: release_tag: ${{ needs.preflight.outputs.tag }} secrets: AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} # Vercel builds the hosted web app while the desktop jobs run. `--skip-domain` # keeps the custom domains (the channel and router domains) on the previous # deployment until deploy_web aliases them, after the GitHub Release. It does # still move the project's own *.vercel.app production hostname at build time. # That hostname sits behind Vercel SSO and is not a user channel, so the # npm-before-client order in docs/operations/release.md holds for users. build_web: name: Build hosted web app needs: [preflight, relay_public_config] if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 outputs: deployment_url: ${{ steps.deploy.outputs.deployment_url }} env: T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} T3CODE_WEB_ROUTER_URL: ${{ vars.T3CODE_WEB_ROUTER_URL }} VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | /* !/.repos/ sparse-checkout-cone-mode: false - name: Setup dependencies uses: ./.github/actions/setup-ci-deps with: install-args: --filter=@t3tools/scripts... --filter=@t3tools/web... - name: Download relay client tracing config uses: actions/download-artifact@v8 with: name: relay-client-tracing-config path: ${{ runner.temp }}/relay-client-tracing - name: Load relay client tracing config shell: bash run: | config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" echo "::add-mask::$tracing_token" cat "$config_path" >> "$GITHUB_ENV" - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - name: Refresh release lockfile run: vp install --lockfile-only --ignore-scripts - id: deploy name: Build staged deployment shell: bash run: | set -euo pipefail if [[ -z "${VERCEL_TOKEN:-}" || -z "${VERCEL_ORG_ID:-}" || -z "${VERCEL_PROJECT_ID:-}" ]]; then echo "Missing one or more required Vercel secrets: VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID." >&2 exit 1 fi router_url="${T3CODE_WEB_ROUTER_URL:-https://app.t3.codes}" if [[ "${{ needs.preflight.outputs.release_channel }}" == "stable" ]]; then channel_name="latest" else channel_name="nightly" fi echo "Building hosted web app for $channel_name channel." deployment_output="$( vp dlx vercel@53.1.1 deploy \ --archive=tgz \ --prod \ --skip-domain \ --yes \ --token "$VERCEL_TOKEN" \ --scope "${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}" \ --build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \ --build-env "T3CODE_CLERK_PUBLISHABLE_KEY=${T3CODE_CLERK_PUBLISHABLE_KEY:-}" \ --build-env "T3CODE_CLERK_JWT_TEMPLATE=${T3CODE_CLERK_JWT_TEMPLATE:-}" \ --build-env "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=${T3CODE_CLERK_CLI_OAUTH_CLIENT_ID:-}" \ --build-env "T3CODE_RELAY_URL=${T3CODE_RELAY_URL:-}" \ --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=${T3CODE_RELAY_CLIENT_OTLP_TRACES_URL:-}" \ --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=${T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET:-}" \ --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=${T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN:-}" \ --build-env "VITE_HOSTED_APP_URL=$router_url" \ --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name" )" printf '%s\n' "$deployment_output" # Vercel can print lockfile warnings to stdout before the deployment URL. deployment_url="$(printf '%s\n' "$deployment_output" | grep -E '^https://[[:alnum:].-]+\.vercel\.app$')" if [[ "$deployment_url" == *$'\n'* ]]; then echo "Vercel returned multiple deployment URLs." >&2 exit 1 fi echo "Staged $deployment_url." echo "deployment_url=$deployment_url" >> "$GITHUB_OUTPUT" deploy_web: name: Deploy hosted web app needs: [preflight, build_web, release, publish_cli] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build_web.result == 'success' && needs.release.result == 'success' && needs.publish_cli.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} runs-on: blacksmith-2vcpu-ubuntu-2404 timeout-minutes: 10 env: DEPLOYMENT_URL: ${{ needs.build_web.outputs.deployment_url }} VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} T3CODE_WEB_ROUTER_URL: ${{ vars.T3CODE_WEB_ROUTER_URL }} T3CODE_WEB_LATEST_DOMAIN: ${{ vars.T3CODE_WEB_LATEST_DOMAIN }} T3CODE_WEB_NIGHTLY_DOMAIN: ${{ vars.T3CODE_WEB_NIGHTLY_DOMAIN }} VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | /* !/.repos/ sparse-checkout-cone-mode: false - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps with: install: false - name: Alias channel shell: bash run: | set -euo pipefail if [[ -z "$DEPLOYMENT_URL" ]]; then echo "Build hosted web app did not report a deployment URL." >&2 exit 1 fi router_url="${T3CODE_WEB_ROUTER_URL:-https://app.t3.codes}" latest_domain="${T3CODE_WEB_LATEST_DOMAIN:-latest.app.t3.codes}" nightly_domain="${T3CODE_WEB_NIGHTLY_DOMAIN:-nightly.app.t3.codes}" router_domain="${router_url#http://}" router_domain="${router_domain#https://}" router_domain="${router_domain%%/*}" if [[ "${{ needs.preflight.outputs.release_channel }}" == "stable" ]]; then channel_domain="$latest_domain" channel_name="latest" else channel_domain="$nightly_domain" channel_name="nightly" fi vercel_scope_args=(--scope "${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}") echo "Aliasing $DEPLOYMENT_URL to $channel_domain." vp dlx vercel@53.1.1 alias set "$DEPLOYMENT_URL" "$channel_domain" \ --token "$VERCEL_TOKEN" \ "${vercel_scope_args[@]}" if [[ "$channel_name" == "latest" && -n "$router_domain" && "$router_domain" != "$channel_domain" ]]; then echo "Aliasing $DEPLOYMENT_URL to router domain $router_domain." vp dlx vercel@53.1.1 alias set "$DEPLOYMENT_URL" "$router_domain" \ --token "$VERCEL_TOKEN" \ "${vercel_scope_args[@]}" fi # Same split as the web app: build a production deployment early, then # promote it to t3.codes once the release it links to exists. As above, the # project's own *.vercel.app hostname moves at build time. build_marketing: name: Build marketing site needs: [preflight] if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.preflight.outputs.release_channel == 'nightly' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 outputs: deployment_url: ${{ steps.deploy.outputs.deployment_url }} project_id: ${{ steps.deploy.outputs.project_id }} env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | /* !/.repos/ sparse-checkout-cone-mode: false - name: Setup dependencies uses: ./.github/actions/setup-ci-deps with: install-args: --filter=@t3tools/marketing... - id: deploy name: Build staged deployment shell: bash run: | set -euo pipefail if [[ -z "${VERCEL_TOKEN:-}" || -z "${VERCEL_ORG_ID:-}" ]]; then echo "Missing one or more required Vercel secrets: VERCEL_TOKEN, VERCEL_ORG_ID." >&2 exit 1 fi VERCEL_PROJECT_ID="$( curl --fail --silent --show-error \ --header "Authorization: Bearer $VERCEL_TOKEN" \ "https://api.vercel.com/v9/projects/t3code-marketing?teamId=$VERCEL_ORG_ID" \ | jq --exit-status --raw-output '.id' )" export VERCEL_PROJECT_ID deployment_output="$( vp dlx vercel@53.1.1 deploy \ --archive=tgz \ --prod \ --skip-domain \ --yes \ --token "$VERCEL_TOKEN" \ --scope "${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}" )" printf '%s\n' "$deployment_output" # Vercel can print lockfile warnings to stdout before the deployment URL. deployment_url="$(printf '%s\n' "$deployment_output" | grep -E '^https://[[:alnum:].-]+\.vercel\.app$')" if [[ "$deployment_url" == *$'\n'* ]]; then echo "Vercel returned multiple deployment URLs." >&2 exit 1 fi echo "Staged $deployment_url." echo "deployment_url=$deployment_url" >> "$GITHUB_OUTPUT" echo "project_id=$VERCEL_PROJECT_ID" >> "$GITHUB_OUTPUT" deploy_marketing: name: Deploy marketing site needs: [preflight, build_marketing, release] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build_marketing.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'nightly' }} runs-on: blacksmith-2vcpu-ubuntu-2404 timeout-minutes: 10 env: DEPLOYMENT_URL: ${{ needs.build_marketing.outputs.deployment_url }} VERCEL_PROJECT_ID: ${{ needs.build_marketing.outputs.project_id }} VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | /* !/.repos/ sparse-checkout-cone-mode: false - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps with: install: false - name: Promote marketing site shell: bash run: | set -euo pipefail if [[ -z "$DEPLOYMENT_URL" ]]; then echo "Build marketing site did not report a deployment URL." >&2 exit 1 fi echo "Promoting $DEPLOYMENT_URL." vp dlx vercel@53.1.1 promote "$DEPLOYMENT_URL" \ --yes \ --token "$VERCEL_TOKEN" \ --scope "${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}" finalize: name: Finalize release if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'stable' }} needs: [preflight, release] runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: - id: app_token name: Mint release app token uses: actions/create-github-app-token@v2 with: app-id: ${{ secrets.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} - name: Checkout uses: actions/checkout@v6 with: ref: main fetch-depth: 0 token: ${{ steps.app_token.outputs.token }} persist-credentials: true sparse-checkout: | /* !/.repos/ sparse-checkout-cone-mode: false - id: app_bot name: Resolve GitHub App bot identity env: GH_TOKEN: ${{ steps.app_token.outputs.token }} APP_SLUG: ${{ steps.app_token.outputs.app-slug }} run: | user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)" echo "name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT" echo "email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT" - name: Setup dependencies uses: ./.github/actions/setup-ci-deps with: install-args: --filter=@t3tools/scripts... --filter=@t3tools/oxlint-plugin-t3code... - id: update_versions name: Update version strings env: RELEASE_VERSION: ${{ needs.preflight.outputs.version }} run: node scripts/update-release-package-versions.ts "$RELEASE_VERSION" --github-output - name: Format package.json files if: steps.update_versions.outputs.changed == 'true' run: vp fmt apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json - name: Refresh lockfile if: steps.update_versions.outputs.changed == 'true' run: vp install --lockfile-only --ignore-scripts - name: Commit and push version bump if: steps.update_versions.outputs.changed == 'true' shell: bash env: RELEASE_TAG: ${{ needs.preflight.outputs.tag }} run: | if git diff --quiet -- apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml; then echo "No version changes to commit." exit 0 fi git config user.name "${{ steps.app_bot.outputs.name }}" git config user.email "${{ steps.app_bot.outputs.email }}" git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml git commit -m "chore(release): prepare $RELEASE_TAG" git push origin HEAD:main announce_discord: name: Announce release on Discord if: | always() && !cancelled() && needs.preflight.result == 'success' && needs.preflight.outputs.release_channel != 'preview' && needs.relay_public_config.result == 'success' && needs.release.result == 'success' && needs.deploy_web.result == 'success' && (needs.finalize.result == 'success' || needs.finalize.result == 'skipped') needs: [preflight, relay_public_config, release, deploy_web, finalize] runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | /* !/.repos/ sparse-checkout-cone-mode: false - name: Setup dependencies uses: ./.github/actions/setup-ci-deps with: install-args: --filter=@t3tools/scripts... - name: Read published release notes if: needs.preflight.outputs.is_prerelease == 'true' continue-on-error: true env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} RELEASE_TAG: ${{ needs.preflight.outputs.tag }} RELEASE_CHANNEL: ${{ needs.preflight.outputs.release_channel }} run: | if [ "$RELEASE_CHANNEL" = 'nightly' ]; then if ! gh release view "$RELEASE_TAG" --json body --jq '.body // ""' > "$RUNNER_TEMP/discord-release-notes.md"; then : > "$RUNNER_TEMP/discord-release-notes.md" echo '::warning::Could not read published release notes; announcing without a changelog.' fi else : > "$RUNNER_TEMP/discord-release-notes.md" fi - name: Announce prerelease on Discord if: needs.preflight.outputs.is_prerelease == 'true' continue-on-error: true env: DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_RELEASE_NIGHTLY_ROLE_ID }} DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} run: | node scripts/notify-discord-release.ts prerelease \ --role-id "$DISCORD_MENTION_ROLE_ID" \ --release-name "${{ needs.preflight.outputs.release_name }}" \ --release-version "${{ needs.preflight.outputs.version }}" \ --tag "${{ needs.preflight.outputs.tag }}" \ --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}" \ --release-notes-file "$RUNNER_TEMP/discord-release-notes.md" - name: Announce latest release on Discord if: needs.preflight.outputs.make_latest == 'true' continue-on-error: true env: DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_RELEASE_LATEST_ROLE_ID }} DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} run: | node scripts/notify-discord-release.ts latest \ --role-id "$DISCORD_MENTION_ROLE_ID" \ --release-name "${{ needs.preflight.outputs.release_name }}" \ --release-version "${{ needs.preflight.outputs.version }}" \ --tag "${{ needs.preflight.outputs.tag }}" \ --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}"