From 969b3c7ee9c7d42dd3e90121254ff6dc8c0f52aa Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 20 Oct 2026 17:55:20 -0700 Subject: [PATCH 1/2] ci(release): build Windows x64 on Linux like arm64 The Windows x64 desktop job was the last target still packaging on a Windows runner. Build and sign it on Linux with jsign, and run the packaged natives and CLI archive on windows-2025 through verify_runner. No release target builds on a Windows host now, so drop the Windows-only setup steps. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release-desktop.yml | 103 ++------------------------ .github/workflows/release.yml | 18 +++-- docs/operations/release.md | 6 +- 3 files changed, 20 insertions(+), 107 deletions(-) diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index cbd61e0604ef..a1f33d1ad884 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -136,17 +136,11 @@ jobs: git sparse-checkout set --no-cone '/*' '!/.repos/' git -c checkout.workers=0 checkout --detach FETCH_HEAD - # Windows installs straight from the registry. There, linking the packages - # takes the time, not downloading them, so a ~700 MB package cache did not - # pay for its own restore. Across recent nightlies, restore plus install - # took 183-233s on arm64 and 116-172s on x64, while a plain install took - # 142-210s and 84-160s. The arm64 entries also filled 4 GB of the - # repository's 10 GB Actions cache. - name: Setup Vite+ uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 with: node-version-file: package.json - cache: ${{ runner.os != 'Windows' }} + cache: true run-install: false # pnpm checks the lockfile and policy before reusing this result. A missing @@ -215,28 +209,8 @@ jobs: name: js-bundle path: apps - # Only the resource monitor build needs these libraries. With a cached - # monitor, the desktop build's preflight skips its MSVC check, and node-pty - # ships prebuilt Windows binaries, so the 30-60s install is skipped too. - - name: Install Spectre-mitigated MSVC libs - if: runner.os == 'Windows' && steps.resource_monitor_cache.outputs.cache-hit != 'true' - shell: pwsh - run: | - $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" - $installPath = & $vswhere -products * -latest -property installationPath - $setupExe = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\setup.exe" - $proc = Start-Process -FilePath $setupExe ` - -ArgumentList "modify", "--installPath", "`"$installPath`"", "--add", ` - "Microsoft.VisualStudio.Component.VC.Runtimes.${{ inputs.arch == 'arm64' && 'ARM64' || 'x86.x64' }}.Spectre", "--quiet", "--norestart" ` - -Wait -PassThru -NoNewWindow - if ($null -eq $proc -or $proc.ExitCode -ne 0) { - $code = if ($null -ne $proc) { $proc.ExitCode } else { 1 } - Write-Error "Visual Studio Installer failed with exit code $code" - exit $code - } - - name: Install cargo-xwin - if: runner.os != 'Windows' && inputs.platform == 'win' && steps.resource_monitor_cache.outputs.cache-hit != 'true' + if: inputs.platform == 'win' && steps.resource_monitor_cache.outputs.cache-hit != 'true' uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2 with: tool: cargo-xwin@0.23.1 @@ -246,74 +220,12 @@ jobs: - name: Build resource monitor if: steps.resource_monitor_cache.outputs.cache-hit != 'true' shell: bash - run: cargo ${{ runner.os != 'Windows' && inputs.platform == 'win' && 'xwin build' || 'build' }} --locked --release --manifest-path native/resource-monitor/Cargo.toml --target "${{ inputs.rust_target }}" - - - name: Prepare Azure Trusted Signing - if: inputs.platform == 'win' && runner.os == 'Windows' - shell: pwsh - env: - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} - run: | - $ErrorActionPreference = "Stop" - - $requiredSecrets = @( - $env:AZURE_TENANT_ID, - $env:AZURE_CLIENT_ID, - $env:AZURE_CLIENT_SECRET, - $env:AZURE_TRUSTED_SIGNING_ENDPOINT, - $env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME, - $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME, - $env:AZURE_TRUSTED_SIGNING_PUBLISHER_NAME - ) - if ($requiredSecrets | Where-Object { [string]::IsNullOrWhiteSpace($_) }) { - Write-Host "Azure Trusted Signing disabled; skipping TrustedSigning module preparation." - exit 0 - } - - try { - Install-PackageProvider ` - -Name NuGet ` - -MinimumVersion 2.8.5.201 ` - -Force ` - -Scope CurrentUser ` - -ErrorAction Stop - } catch { - Write-Warning "Could not bootstrap NuGet package provider. Continuing because the runner may already have a usable provider. $($_.Exception.Message)" - } - - Install-Module ` - -Name TrustedSigning ` - -MinimumVersion 0.5.0 ` - -Force ` - -AllowClobber ` - -Repository PSGallery ` - -Scope CurrentUser ` - -ErrorAction Stop - - Import-Module TrustedSigning -MinimumVersion 0.5.0 -Force - Get-Command Invoke-TrustedSigning -ErrorAction Stop - - $moduleRoots = @( - [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "PowerShell", "Modules"), - [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "WindowsPowerShell", "Modules"), - [System.IO.Path]::Combine($env:ProgramFiles, "PowerShell", "Modules"), - [System.IO.Path]::Combine($env:ProgramFiles, "WindowsPowerShell", "Modules") - ) - $modulePathEntries = @($moduleRoots + ($env:PSModulePath -split ";")) | - Where-Object { $_ -and (Test-Path $_) } | - Select-Object -Unique - "PSModulePath=$($modulePathEntries -join ';')" >> $env:GITHUB_ENV + run: cargo ${{ inputs.platform == 'win' && 'xwin build' || 'build' }} --locked --release --manifest-path native/resource-monitor/Cargo.toml --target "${{ inputs.rust_target }}" # Off Windows, scripts/lib/windows-jsign.ts signs through jsign instead of # the TrustedSigning PowerShell module. - name: Install jsign - if: inputs.platform == 'win' && runner.os != 'Windows' + if: inputs.platform == 'win' shell: bash run: | set -euo pipefail @@ -338,9 +250,8 @@ jobs: # The single-executable is built with a Node that supports --build-sea # (25.7+); the repo itself stays on the engines.node version. A native - # build injects into the runner's own Node: tsdown's cross-target download - # path runs `tar` on a drive-letter path on Windows, which GNU tar reads as - # a remote host. Off Windows, a Windows target downloads its node.exe. + # build injects into the runner's own Node; a Windows target downloads its + # node.exe. - name: Build CLI single-executable if: inputs.cli_archive shell: bash @@ -352,7 +263,7 @@ jobs: # The executable bundles the server again, so it needs the same stamp # as release.yml's JS bundle. T3CODE_TELEMETRY_CHANNEL: ${{ github.repository == 'pingdotgg/t3code' && inputs.release_channel || 'dev' }} - run: node apps/server/scripts/cli.ts build-exe --verbose ${{ runner.os != 'Windows' && inputs.platform == 'win' && format('--target win-{0}', inputs.arch) || '' }} + run: node apps/server/scripts/cli.ts build-exe --verbose ${{ inputs.platform == 'win' && format('--target win-{0}', inputs.arch) || '' }} - name: Import macOS signing certificate for the CLI archive if: inputs.cli_archive && inputs.platform == 'mac' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d387de16b83..d680d1d319b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -573,8 +573,9 @@ jobs: if-no-files-found: error retention-days: 1 - # One job per platform and architecture (see release-desktop.yml), each on - # hardware of its own architecture, and each gated only on the JS bundle. + # 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 @@ -677,9 +678,11 @@ jobs: resource_key: linux-arm64 cli_archive: true - # The Windows jobs start with the other platforms. Their checkout, install, - # and toolchain setup take longer than the same-arch Linux job needs to - # upload its CLI archive, so the wait inside is usually instant. + # 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] @@ -695,7 +698,8 @@ jobs: 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-32vcpu-windows-2025 + runner: blacksmith-16vcpu-ubuntu-2404 + verify_runner: windows-2025 platform: win target: nsis arch: x64 @@ -703,8 +707,6 @@ jobs: resource_key: win32-x64 cli_archive: true - # Windows arm64 runners have 4 vCPUs, and the packaging is mostly CPU-bound, - # so the build runs on Linux and only the native checks run on arm64. desktop_win_arm64: name: Desktop Windows arm64 needs: [preflight, relay_public_config, build_bundle] diff --git a/docs/operations/release.md b/docs/operations/release.md index c6e151434ca3..c6725efdcdad 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -31,7 +31,7 @@ the version. - Runs lint, typecheck, and tests alongside artifact builds. Publishing waits for every check. - Reads the shared production T3 Connect relay URL and Clerk client configuration before packaging clients. - Builds the platform-independent JS (server bundle, web client, Electron main) once in the `build_bundle` job and hands it to every platform job as the `js-bundle` artifact; the platform jobs only package it, so no runner rebuilds it. -- Builds six desktop artifacts in parallel for both channels, each as its own job (`desktop__`, one call of `release-desktop.yml`), gated only on the bundle. Every target builds on hardware of its own architecture except Windows arm64: its runners have 4 vCPUs, so it builds and signs (with jsign) on a Linux runner, and a `windows-11-arm` job runs the packaged natives and the CLI archive (`verify_runner`). The Windows jobs embed the same-arch Linux CLI archive as the WSL runtime and wait for that artifact partway through, not for the whole Linux job: +- Builds six desktop artifacts in parallel for both channels, each as its own job (`desktop__`, one call of `release-desktop.yml`), gated only on the bundle. macOS and Linux targets build on hardware of their own architecture. Both Windows targets build and sign (with jsign) on a Linux runner, which packages faster than Windows runners, and a Windows job of the target's architecture (`windows-2025` or `windows-11-arm`, the `verify_runner`) runs the packaged natives and the CLI archive. The Windows jobs embed the same-arch Linux CLI archive as the WSL runtime and wait for that artifact partway through, not for the whole Linux job: - macOS `arm64` DMG - macOS `x64` DMG - Linux `x64` and `arm64` AppImage and `.deb`, from one electron-builder run. The `.deb` updates in the app through electron-updater, which installs it with `dpkg`. @@ -42,11 +42,11 @@ the version. - Nightly runs are always GitHub prereleases and never marked latest. - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag. - Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets. -- Builds a self-contained CLI archive per platform (`t3---.tar.gz`, `.zip` on Windows) in the same job as that target's desktop artifact and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel, for five targets: macOS arm64, Linux x64 and arm64, Windows x64 and arm64. Every archive is smoke-tested on hardware of its own architecture, and every one but Windows arm64 is also built and signed there. There is no macOS x64 archive: Node single-executables are unsupported on x64 macOS (the SEA docs list macOS as arm64 only) and the binary segfaults on start; the x64 desktop app is Electron and unaffected. +- Builds a self-contained CLI archive per platform (`t3---.tar.gz`, `.zip` on Windows) in the same job as that target's desktop artifact and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel, for five targets: macOS arm64, Linux x64 and arm64, Windows x64 and arm64. Every archive is smoke-tested on hardware of its own architecture, and every one but the Windows archives is also built and signed there. There is no macOS x64 archive: Node single-executables are unsupported on x64 macOS (the SEA docs list macOS as arm64 only) and the binary segfaults on start; the x64 desktop app is Electron and unaffected. - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the only form in which T3 Code manages a runtime: the desktop's SSH environments, the boot service, `t3 update`, and the install scripts all download and verify this archive against `SHA256SUMS`. The `t3` npm package exists for people who run `npx t3` or `npm install -g t3` themselves, and it downloads this same archive too; nothing in the product installs from npm. The `curl | sh` installers are `scripts/install.sh` and `scripts/install.ps1`; the marketing site copies them into its `public/` at build time (`apps/marketing/scripts/stage-install-scripts.mjs`) and serves them at `t3.codes/install.sh` and `/install.ps1`. - The executable is built with a Node that supports `--build-sea` (`VP_NODE_VERSION=26.8.2`, kept in step with `SEA_NODE_VERSION` in `apps/server/vite.config.ts`), while the repo stays on `engines.node`. - macOS archives are signed with the Developer ID certificate and notarized when the Apple secrets are present (ad hoc otherwise, which still runs from `curl`/`tar` installs). Windows executables use the same Azure Trusted Signing setup as the installer. Every native addon in the macOS archive is signed too, since the hardened runtime refuses unsigned libraries. - - Each archive is extracted and executed (`scripts/smoke-cli-archive.ts`) on its build runner, or on the `verify_runner` for Windows arm64. + - Each archive is extracted and executed (`scripts/smoke-cli-archive.ts`) on its build runner, or on the `verify_runner` for Windows. - After the GitHub Release is published, publishes the `t3` npm package with OIDC trusted publishing from the same workflow file. It is only a launcher (`scripts/npm-launcher/t3.mjs`): on first run it downloads its exact version's CLI archive from the GitHub Release, verifies it against `SHA256SUMS`, and unpacks it into `/runtime/versions/`, the layout the install scripts and `t3 update` share. The executables are not on npm. They used to be, as `@t3code/t3--` optional dependencies, but npm's publish-time scanning held those 80-250 MB packages back for up to an hour after the launcher was live, and every install in that window got a launcher with no executable. Those packages stay on npm at their old versions and get no new ones. Because the launcher reads from the release, `publish_cli` runs after `release`, never before. - stable releases publish npm dist-tag `latest` - nightly releases publish npm dist-tag `nightly` From 7027b1f067689e76173f8b674f5f303f0004cbf9 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 20 Oct 2026 17:58:29 -0700 Subject: [PATCH 2/2] ci(release): stage win-unpacked for the x64 verify job electron-builder omits the default arch from the unpacked directory name. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release-desktop.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index a1f33d1ad884..65cfa08321c8 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -99,6 +99,11 @@ on: required: true type: string +# electron-builder leaves the default arch (x64) out of the unpacked app's +# directory name. +env: + UNPACKED_DIR: ${{ inputs.platform }}-${{ inputs.arch != 'x64' && format('{0}-', inputs.arch) || '' }}unpacked + permissions: # Windows builds read this run's artifact list to wait for the Linux CLI archive. actions: read @@ -525,7 +530,7 @@ jobs: run: | set -euo pipefail mkdir -p verify - unpacked=$(ls -d "${TMPDIR:-/tmp}"/t3code-desktop-${{ inputs.platform }}-stage-*/app/dist/${{ inputs.platform }}-${{ inputs.arch }}-unpacked) + unpacked=$(ls -d "${TMPDIR:-/tmp}"/t3code-desktop-${{ inputs.platform }}-stage-*/app/dist/"$UNPACKED_DIR") tar -C "$(dirname "$unpacked")" -cf verify/unpacked.tar "$(basename "$unpacked")" esbuild=scripts/node_modules/.bin/esbuild banner="import{createRequire as __cr}from'node:module';const require=__cr(import.meta.url);" @@ -645,7 +650,7 @@ jobs: set -euo pipefail tar -C verify -xf verify/unpacked.tar node verify/verify-windows-desktop-artifact.mjs \ - --app-dir "$(cygpath -w "$PWD/verify/${{ inputs.platform }}-${{ inputs.arch }}-unpacked")" \ + --app-dir "$(cygpath -w "$PWD/verify/$UNPACKED_DIR")" \ --arch "${{ inputs.arch }}" --build-version "${{ inputs.version }}" --verbose - name: Smoke-test CLI archive @@ -660,7 +665,7 @@ jobs: - name: Smoke-test resource monitor shell: bash env: - MONITOR: verify/${{ inputs.platform }}-${{ inputs.arch }}-unpacked/resources/resource-monitor/t3-resource-monitor.exe + MONITOR: verify/${{ env.UNPACKED_DIR }}/resources/resource-monitor/t3-resource-monitor.exe EXPECTED_ARCH: ${{ inputs.arch == 'arm64' && 'aarch64' || 'x86_64' }} run: | node --input-type=module <<'EOF'