From 1876fdb2973fba41c18b73b099312840404b9ceb Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:27:50 -0700 Subject: [PATCH] ci(release): build CLI archives for five targets, each on its own architecture macOS x64 is dropped: Node single-executables are unsupported on x64 macOS (the SEA docs list macOS as arm64 only) and the built binary segfaults on start, which the smoke test now reports with the signal. The x64 desktop app is Electron and unaffected. Windows arm64 gets its own job on a windows-11-arm runner, signed and smoke-tested there, and Linux arm64 its own build_linux_cli matrix entry on an arm64 runner because node-pty compiles from source on Linux. Nothing is cross-built. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 225 +++++++++++++++++++++++-- docs/operations/release.md | 2 +- packages/shared/src/cliRelease.test.ts | 7 +- packages/shared/src/cliRelease.ts | 19 ++- 4 files changed, 225 insertions(+), 28 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 05ee8b2bd452..8c920d010e7e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -359,13 +359,25 @@ jobs: # the Windows desktop entry embeds it as the WSL runtime. Building it once # here means the WSL backend runs the exact bytes a Linux user downloads. build_linux_cli: - name: Build CLI archive (linux-x64) + name: Build CLI archive (linux-${{ matrix.arch }}) # Same gating as relay_public_config: only the release commit is needed, so # this runs alongside preflight. See the condition comment there. needs: [resolve_commit, preflight, relay_public_config] if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} - runs-on: blacksmith-32vcpu-ubuntu-2404 - timeout-minutes: 20 + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - arch: x64 + runner: blacksmith-32vcpu-ubuntu-2404 + rust_target: x86_64-unknown-linux-gnu + # node-pty has no Linux prebuild and compiles from source, so the + # arm64 archive is built on arm64 hardware rather than cross-built. + - arch: arm64 + runner: ubuntu-24.04-arm + rust_target: aarch64-unknown-linux-gnu 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 }} @@ -396,18 +408,18 @@ jobs: id: resource_monitor_cache uses: actions/cache@v6 with: - path: native/resource-monitor/target/x86_64-unknown-linux-gnu/release/t3-resource-monitor - key: resource-monitor-x86_64-unknown-linux-gnu-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} + path: native/resource-monitor/target/${{ matrix.rust_target }}/release/t3-resource-monitor + key: resource-monitor-${{ matrix.rust_target }}-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} - name: Setup Rust if: steps.resource_monitor_cache.outputs.cache-hit != 'true' uses: dtolnay/rust-toolchain@stable with: - targets: x86_64-unknown-linux-gnu + targets: ${{ matrix.rust_target }} - name: Build resource monitor if: steps.resource_monitor_cache.outputs.cache-hit != 'true' - run: cargo build --locked --release --manifest-path native/resource-monitor/Cargo.toml --target x86_64-unknown-linux-gnu + run: cargo build --locked --release --manifest-path native/resource-monitor/Cargo.toml --target ${{ matrix.rust_target }} - name: Download relay client tracing config uses: actions/download-artifact@v8 @@ -442,15 +454,15 @@ jobs: - name: Stage resource monitor for the CLI archive run: | set -euo pipefail - target_dir="$RUNNER_TEMP/cli-resource-monitor/linux-x64" + target_dir="$RUNNER_TEMP/cli-resource-monitor/linux-${{ matrix.arch }}" mkdir -p "$target_dir" - cp native/resource-monitor/target/x86_64-unknown-linux-gnu/release/t3-resource-monitor "$target_dir/" + cp native/resource-monitor/target/${{ matrix.rust_target }}/release/t3-resource-monitor "$target_dir/" - name: Build CLI archive run: | node scripts/build-cli-archive.ts \ --platform linux \ - --arch x64 \ + --arch ${{ matrix.arch }} \ --version "${{ needs.preflight.outputs.version }}" \ --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ --output-dir release-cli @@ -461,7 +473,183 @@ jobs: - name: Upload CLI archive uses: actions/upload-artifact@v7 with: - name: cli-linux-x64 + name: cli-linux-${{ matrix.arch }} + path: release-cli/* + if-no-files-found: error + + # Windows arm64 has no desktop build yet (the NSIS arm64 row is still off), + # but the CLI archive is built here on arm64 hardware so it is signed and + # smoke-tested on the architecture it targets, like every other archive. + build_windows_arm64_cli: + name: Build CLI archive (win32-arm64) + needs: [resolve_commit, preflight, relay_public_config] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} + runs-on: windows-11-arm + 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 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: false + run-install: false + + - name: Download dependency verification + continue-on-error: true + uses: actions/download-artifact@v8 + with: + name: release-dependency-verification + path: ${{ runner.temp }}/pnpm-metadata + + - name: Install dependencies + env: + pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata + run: vp install --filter=t3... --filter=@t3tools/web... --filter=@t3tools/scripts... + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-pc-windows-msvc + + - name: Build resource monitor + run: cargo build --locked --release --manifest-path native/resource-monitor/Cargo.toml --target aarch64-pc-windows-msvc + + - 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 }}" + + # The t3 build task depends on @t3tools/web#build, so the web client is + # built as part of this step. + - name: Build CLI package + run: vp run --filter t3 build + + - name: Build CLI single-executable + shell: bash + env: + VP_NODE_VERSION: "26.8.2" + run: node apps/server/scripts/cli.ts build-exe --verbose + + - name: Prepare Azure Trusted Signing + 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 + + - name: Stage resource monitor for the CLI archive + shell: bash + run: | + set -euo pipefail + target_dir="$RUNNER_TEMP/cli-resource-monitor/win32-arm64" + mkdir -p "$target_dir" + cp native/resource-monitor/target/aarch64-pc-windows-msvc/release/t3-resource-monitor.exe "$target_dir/" + + - name: Build CLI archive + shell: bash + 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 }} + run: | + node scripts/build-cli-archive.ts \ + --platform win \ + --arch arm64 \ + --version "${{ needs.preflight.outputs.version }}" \ + --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ + --output-dir release-cli + + - name: Smoke-test CLI archive + shell: bash + run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ needs.preflight.outputs.version }}" + + - name: Upload CLI archive + uses: actions/upload-artifact@v7 + with: + name: cli-win-arm64 path: release-cli/* if-no-files-found: error @@ -488,11 +676,9 @@ jobs: matrix: include: # cli_archive: whether the job also builds the self-contained CLI - # archive. The executable is built on the runner's own Node, so only - # native runners qualify. macOS x64 has no native runner: a - # cross-built executable crashed under Rosetta in the smoke test and - # cannot be verified on real x64 hardware in CI, so it is skipped - # until it can be. + # archive for its own platform/arch, on this runner, and smoke-tests + # it here. Every archive is built on hardware of its own + # architecture: Linux and Windows arm64 have their own jobs below. - label: macOS arm64 runner: blacksmith-12vcpu-macos-26 platform: mac @@ -501,6 +687,9 @@ jobs: rust_target: aarch64-apple-darwin resource_key: darwin-arm64 cli_archive: true + # 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. - label: macOS x64 runner: blacksmith-12vcpu-macos-26 platform: mac @@ -1058,8 +1247,8 @@ jobs: release: name: Publish GitHub Release - needs: [preflight, build, publish_cli] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' && (needs.publish_cli.result == 'success' || (needs.preflight.outputs.release_channel == 'preview' && needs.publish_cli.result == 'skipped')) }} + needs: [preflight, build, build_windows_arm64_cli, publish_cli] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' && needs.build_windows_arm64_cli.result == 'success' && (needs.publish_cli.result == 'success' || (needs.preflight.outputs.release_channel == 'preview' && needs.publish_cli.result == 'skipped')) }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 30 permissions: diff --git a/docs/operations/release.md b/docs/operations/release.md index 54e3f5e55eae..abe9ae00a8d4 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -34,7 +34,7 @@ This document covers the unified release workflow for stable and nightly desktop - 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) on the same runners as the desktop artifacts and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel. Only native runners build one (macOS arm64, Linux x64, Windows x64); macOS x64 is skipped because a cross-built executable cannot be verified on real x64 hardware in CI. +- Builds a self-contained CLI archive per platform (`t3---.tar.gz`, `.zip` on Windows) on the same runners as the desktop artifacts 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 built, signed, and smoke-tested on hardware of its own architecture (`build_linux_cli` and `build_windows_arm64_cli` have their own runners). 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 npm package exists for people who run `npx t3` or `npm install -g t3` themselves; 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. diff --git a/packages/shared/src/cliRelease.test.ts b/packages/shared/src/cliRelease.test.ts index c5109ebee3a0..c92421db5ec2 100644 --- a/packages/shared/src/cliRelease.test.ts +++ b/packages/shared/src/cliRelease.test.ts @@ -23,11 +23,10 @@ describe("cliRelease", () => { expect(cliArchivePlatformKey("darwin", "arm64")).toBe("darwin-arm64"); expect(cliArchivePlatformKey("linux", "x64")).toBe("linux-x64"); expect(cliArchivePlatformKey("win32", "x64")).toBe("win32-x64"); - // Built but not published (macOS x64 segfaults under Rosetta when - // cross-injected; the arm64 Linux and Windows runners do not exist yet). + // Node single-executables are unsupported on x64 macOS. expect(cliArchivePlatformKey("darwin", "x64")).toBeUndefined(); - expect(cliArchivePlatformKey("linux", "arm64")).toBeUndefined(); - expect(cliArchivePlatformKey("win32", "arm64")).toBeUndefined(); + expect(cliArchivePlatformKey("linux", "arm64")).toBe("linux-arm64"); + expect(cliArchivePlatformKey("win32", "arm64")).toBe("win32-arm64"); expect(cliArchivePlatformKey("freebsd", "x64")).toBeUndefined(); expect(cliArchivePlatformKey("linux", "ia32")).toBeUndefined(); }); diff --git a/packages/shared/src/cliRelease.ts b/packages/shared/src/cliRelease.ts index 614b0d48bc03..99339ba395a7 100644 --- a/packages/shared/src/cliRelease.ts +++ b/packages/shared/src/cliRelease.ts @@ -11,12 +11,21 @@ export const CLI_RELEASE_CHECKSUMS_FILE = "SHA256SUMS"; export const CLI_RELEASE_BASE_URL_ENV = "T3CODE_RELEASE_BASE_URL"; /** - * The archives a release actually attaches. Kept in step with the - * `cli_archive` matrix flags in .github/workflows/release.yml: a key here - * without a build there produces download URLs that 404, and a build there - * without a key here is unreachable from every installer. + * The archives a release attaches. Kept in step with the build_linux_cli + * matrix, build_windows_arm64_cli, and the `cli_archive` rows in + * .github/workflows/release.yml: a key here without a build there produces + * download URLs that 404, and a build there without a key here is + * unreachable from every installer. */ -const CLI_ARCHIVE_PLATFORM_KEYS = ["darwin-arm64", "linux-x64", "win32-x64"] as const; +// No darwin-x64: Node single-executables are unsupported on x64 macOS (the +// SEA docs list macOS as arm64 only) and the binary segfaults on start. +const CLI_ARCHIVE_PLATFORM_KEYS = [ + "darwin-arm64", + "linux-arm64", + "linux-x64", + "win32-arm64", + "win32-x64", +] as const; export type CliArchivePlatformKey = (typeof CLI_ARCHIVE_PLATFORM_KEYS)[number]; export function cliArchivePlatformKey(