From cc32afaa7a786e7639386a390d4fb83bee20a0ff Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:47:03 -0700 Subject: [PATCH 1/3] ci(release): build the JS bundle once and run every platform job in parallel The server bundle and web client were built in six jobs per release (the Linux CLI job, each of the four desktop jobs, and the npm publish job), and the whole desktop matrix waited on the Linux CLI job even though only the Windows desktop consumes it as the WSL runtime. A build_bundle job now builds apps/server/dist and apps/desktop/dist-electron once and uploads them; every platform job downloads that and runs with --skip-build. The desktop matrix is split into four jobs calling a reusable workflow (release-desktop.yml) so each declares only what it needs: macOS and Linux desktops gate on the bundle alone, Windows additionally on the linux-x64 CLI archive. The Linux CLI archives, the desktop jobs, and the npm publish all start as soon as the bundle exists. Co-Authored-By: Claude Fable 5 --- .github/workflows/release-desktop.yml | 528 +++++++++++++++++ .github/workflows/release.yml | 795 ++++++++------------------ docs/operations/release.md | 5 +- 3 files changed, 772 insertions(+), 556 deletions(-) create mode 100644 .github/workflows/release-desktop.yml diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml new file mode 100644 index 000000000000..5057d9dce9d5 --- /dev/null +++ b/.github/workflows/release-desktop.yml @@ -0,0 +1,528 @@ +name: Release desktop build + +# One desktop platform build, called four times from release.yml so each +# platform is its own job with its own `needs`. The JS bundle (server, web +# client, Electron main) comes from the `js-bundle` artifact that build_bundle +# produced; this job only packages it, builds the native helpers, and, where +# `cli_archive` is set, the self-contained CLI archive for its platform. + +on: + workflow_call: + inputs: + label: + required: true + type: string + runner: + required: true + type: string + platform: + required: true + type: string + target: + required: true + type: string + arch: + required: true + type: string + rust_target: + required: true + type: string + resource_key: + required: true + type: string + # Whether the job also builds the self-contained CLI archive for its own + # platform/arch, on this runner, and smoke-tests it here. Every archive + # is built on hardware of its own architecture. + cli_archive: + required: false + default: false + type: boolean + version: + required: true + type: string + ref: + required: true + type: string + release_channel: + required: true + type: string + clerk_publishable_key: + required: true + type: string + clerk_jwt_template: + required: true + type: string + clerk_cli_oauth_client_id: + required: true + type: string + relay_url: + required: true + type: string + +permissions: + contents: read + +jobs: + build: + name: Build ${{ inputs.label }} + runs-on: ${{ inputs.runner }} + timeout-minutes: 30 + env: + T3CODE_CLERK_PUBLISHABLE_KEY: ${{ inputs.clerk_publishable_key }} + T3CODE_CLERK_JWT_TEMPLATE: ${{ inputs.clerk_jwt_template }} + T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ inputs.clerk_cli_oauth_client_id }} + T3CODE_RELAY_URL: ${{ inputs.relay_url }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.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: ${{ inputs.platform != 'win' }} + run-install: false + + - name: Resolve Windows package cache path + if: inputs.platform == 'win' + id: package_cache_path + shell: pwsh + run: '"path=$(vp pm cache dir)" >> $env:GITHUB_OUTPUT' + + - name: Cache Windows packages + if: inputs.platform == 'win' + uses: actions/cache@v6 + with: + path: ${{ steps.package_cache_path.outputs.path }} + key: windows-release-packages-v1-${{ inputs.arch }}-${{ hashFiles('pnpm-lock.yaml') }} + + # 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: Install desktop dependencies + env: + pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata + run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... + + - name: Cache resource monitor + id: resource_monitor_cache + uses: actions/cache@v6 + with: + path: native/resource-monitor/target/${{ inputs.rust_target }}/release/t3-resource-monitor${{ inputs.platform == 'win' && '.exe' || '' }} + key: resource-monitor-${{ inputs.rust_target }}-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} + + - name: Cache Linux capture helpers + if: inputs.platform == 'linux' + id: capture_helper_cache + uses: actions/cache@v6 + with: + path: | + native/kde-snap-shot/target/${{ inputs.rust_target }}/release/t3-kde-snap-shot + native/hyprland-snap-shot/target/${{ inputs.rust_target }}/release/t3-hyprland-snap-shot + key: linux-capture-helpers-${{ inputs.rust_target }}-${{ hashFiles('native/kde-snap-shot/Cargo.lock', 'native/kde-snap-shot/Cargo.toml', 'native/kde-snap-shot/src/**', 'native/hyprland-snap-shot/Cargo.lock', 'native/hyprland-snap-shot/Cargo.toml', 'native/hyprland-snap-shot/src/**', 'native/hyprland-snap-shot/protocols/**') }} + + - name: Setup Rust + if: steps.resource_monitor_cache.outputs.cache-hit != 'true' || (inputs.platform == 'linux' && steps.capture_helper_cache.outputs.cache-hit != 'true') + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ inputs.rust_target }} + + - 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 "${{ inputs.version }}" + + # The artifact root is `apps/` (upload-artifact keeps the least common + # ancestor of its paths), so extracting into `apps` restores + # apps/server/dist and apps/desktop/dist-electron at their build paths. + - name: Download JS bundle + uses: actions/download-artifact@v8 + with: + name: js-bundle + path: apps + + # The WSL backend runs the Linux CLI archive inside the distro, so the + # Windows desktop embeds the same archive the release attaches. + - name: Download Linux CLI archive for WSL + if: inputs.platform == 'win' + uses: actions/download-artifact@v8 + with: + name: cli-linux-x64 + path: wsl-runtime + + - name: Install Spectre-mitigated MSVC libs + if: inputs.platform == 'win' + 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.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 + } + + - uses: ./.github/actions/setup-apt-mirrors + if: inputs.platform == 'linux' + + - name: Install Linux desktop build libraries + if: inputs.platform == 'linux' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y libsecret-1-dev pkg-config + if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then + sudo apt-get install -y imagemagick + fi + + if command -v magick >/dev/null 2>&1; then + magick -version + else + convert -version + fi + + - name: Prepare Azure Trusted Signing + if: inputs.platform == 'win' + 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: Build desktop artifact + shell: bash + env: + pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata + T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} + T3CODE_DESKTOP_REUSE_LINUX_CAPTURE_HELPERS: ${{ steps.capture_helper_cache.outputs.cache-hit == 'true' }} + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} + MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} + T3CODE_CLERK_PASSKEY_RP_DOMAINS: ${{ vars.CLERK_PASSKEY_RP_DOMAINS }} + 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: | + args=( + --platform "${{ inputs.platform }}" + --target "${{ inputs.target }}" + --arch "${{ inputs.arch }}" + --build-version "${{ inputs.version }}" + --skip-build + --verbose + ) + + has_all() { + for value in "$@"; do + if [[ -z "$value" ]]; then + return 1 + fi + done + return 0 + } + + if [[ "${{ inputs.platform }}" == "mac" ]]; then + if has_all "$CSC_LINK" "$CSC_KEY_PASSWORD" "$APPLE_API_KEY" "$APPLE_API_KEY_ID" "$APPLE_API_ISSUER"; then + if ! has_all "$APPLE_TEAM_ID" "$MACOS_PROVISIONING_PROFILE"; then + echo "macOS signing is configured, but APPLE_TEAM_ID or MACOS_PROVISIONING_PROFILE is missing." >&2 + exit 1 + fi + + key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" + printf '%s' "$APPLE_API_KEY" > "$key_path" + export APPLE_API_KEY="$key_path" + + profile_path="$RUNNER_TEMP/t3code.provisionprofile" + printf '%s' "$MACOS_PROVISIONING_PROFILE" | base64 -D > "$profile_path" + security cms -D -i "$profile_path" >/dev/null + export T3CODE_APPLE_TEAM_ID="$APPLE_TEAM_ID" + export T3CODE_MACOS_PROVISIONING_PROFILE="$profile_path" + + echo "macOS signing enabled." + args+=(--signed) + else + echo "macOS signing disabled (missing one or more Apple signing secrets)." + fi + elif [[ "${{ inputs.platform }}" == "win" ]]; then + # Embed the Linux CLI archive built by build_linux_cli as the WSL + # runtime. Required for a working WSL backend on Windows. + args+=(--wsl-runtime "$GITHUB_WORKSPACE"/wsl-runtime/t3-*-linux-x64.tar.gz) + if has_all \ + "$AZURE_TENANT_ID" \ + "$AZURE_CLIENT_ID" \ + "$AZURE_CLIENT_SECRET" \ + "$AZURE_TRUSTED_SIGNING_ENDPOINT" \ + "$AZURE_TRUSTED_SIGNING_ACCOUNT_NAME" \ + "$AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME" \ + "$AZURE_TRUSTED_SIGNING_PUBLISHER_NAME"; then + echo "Windows signing enabled (Azure Trusted Signing)." + args+=(--signed) + else + echo "Windows signing disabled (missing one or more Azure Trusted Signing secrets)." + fi + else + echo "Signing disabled for ${{ inputs.platform }}." + fi + + vp run dist:desktop:artifact "${args[@]}" + + # The single-executable is built with a Node that supports --build-sea + # (25.7+); the repo itself stays on the engines.node version. It always + # 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, and a cross-built macOS binary cannot be smoke-tested. + - name: Build CLI single-executable + if: inputs.cli_archive + shell: bash + env: + # The exact version, not a major: vp downloads it from nodejs.org/dist on + # the runner, and only exact versions have a dist directory. Keep in + # step with SEA_NODE_VERSION in apps/server/vite.config.ts. + VP_NODE_VERSION: "26.8.2" + run: node apps/server/scripts/cli.ts build-exe --verbose + + - name: Import macOS signing certificate for the CLI archive + if: inputs.cli_archive && inputs.platform == 'mac' + shell: bash + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + run: | + set -euo pipefail + if [[ -z "$CSC_LINK" || -z "$CSC_KEY_PASSWORD" ]]; then + echo "macOS CLI signing disabled (missing CSC_LINK); the archive is signed ad hoc." + exit 0 + fi + keychain="$RUNNER_TEMP/t3-cli-signing.keychain-db" + keychain_password="$(openssl rand -hex 16)" + cert_path="$RUNNER_TEMP/t3-cli-signing.p12" + printf '%s' "$CSC_LINK" | base64 --decode > "$cert_path" + security create-keychain -p "$keychain_password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + security import "$cert_path" -k "$keychain" -P "$CSC_KEY_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain" >/dev/null + security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') + identity="$(security find-identity -v -p codesigning "$keychain" | sed -n 's/.*"\(Developer ID Application: [^"]*\)".*/\1/p' | head -n 1)" + if [[ -z "$identity" ]]; then + echo "No Developer ID Application identity found in CSC_LINK." >&2 + exit 1 + fi + echo "::add-mask::$keychain_password" + echo "T3CODE_CLI_MAC_SIGN_IDENTITY=$identity" >> "$GITHUB_ENV" + echo "macOS CLI signing enabled." + + - name: Stage resource monitor for the CLI archive + if: inputs.cli_archive + shell: bash + run: | + set -euo pipefail + binary_name="t3-resource-monitor" + if [[ "${{ inputs.platform }}" == "win" ]]; then + binary_name="${binary_name}.exe" + fi + target_dir="$RUNNER_TEMP/cli-resource-monitor/${{ inputs.resource_key }}" + mkdir -p "$target_dir" + cp "native/resource-monitor/target/${{ inputs.rust_target }}/release/${binary_name}" "$target_dir/$binary_name" + + - name: Build CLI archive + if: inputs.cli_archive + shell: bash + env: + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + 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: | + set -euo pipefail + if [[ "${{ inputs.platform }}" == "mac" && -n "${APPLE_API_KEY:-}" ]]; then + key_path="$RUNNER_TEMP/AuthKey_cli_${APPLE_API_KEY_ID}.p8" + printf '%s' "$APPLE_API_KEY" > "$key_path" + export APPLE_API_KEY="$key_path" + fi + node scripts/build-cli-archive.ts \ + --platform "${{ inputs.platform }}" \ + --arch "${{ inputs.arch }}" \ + --version "${{ inputs.version }}" \ + --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ + --output-dir release-cli + + - name: Smoke-test CLI archive + if: inputs.cli_archive + shell: bash + run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ inputs.version }}" + + - name: Upload CLI archive + if: inputs.cli_archive + uses: actions/upload-artifact@v7 + with: + name: cli-${{ inputs.platform }}-${{ inputs.arch }} + path: release-cli/* + if-no-files-found: error + + - name: Collect release assets + shell: bash + run: | + set -euo pipefail + mkdir -p release-publish + + shopt -s nullglob + patterns=( + "release/*.dmg" + "release/*.zip" + "release/*.AppImage" + "release/*.exe" + ) + # Preview builds have no publish config, so electron-builder writes + # no feed manifest for them, but it still emits blockmaps beside the + # installers. Neither belongs on a release no updater may follow. + if [[ "${{ inputs.release_channel }}" != "preview" ]]; then + patterns+=("release/*.blockmap" "release/*.yml") + fi + for pattern in "${patterns[@]}"; do + for file in $pattern; do + cp "$file" release-publish/ + done + done + + if [[ "${{ inputs.platform }}" == "mac" && "${{ inputs.arch }}" != "arm64" ]]; then + shopt -s nullglob + for manifest in release-publish/*-mac.yml; do + mv "$manifest" "${manifest%.yml}-${{ inputs.arch }}.yml" + done + fi + + # Enable if Windows arm64 builds are enabled. + # Windows updater metadata is channel-specific (for example + # "latest.yml" or "nightly.yml"). Suffix each per-arch copy so the + # release job can merge matching arm64/x64 manifests back into one + # canonical manifest per channel. + # if [[ "${{ inputs.platform }}" == "win" ]]; then + # shopt -s nullglob + # for manifest in release-publish/*.yml; do + # mv "$manifest" "${manifest%.yml}-win-${{ inputs.arch }}.yml" + # done + # fi + + - name: Collect resource monitor + shell: bash + run: | + set -euo pipefail + binary_name="t3-resource-monitor" + if [[ "${{ inputs.platform }}" == "win" ]]; then + binary_name="${binary_name}.exe" + fi + source_path="native/resource-monitor/target/${{ inputs.rust_target }}/release/${binary_name}" + target_dir="resource-monitor-publish/${{ inputs.resource_key }}" + mkdir -p "$target_dir" + cp "$source_path" "$target_dir/$binary_name" + + - name: Upload build artifacts + uses: actions/upload-artifact@v7 + with: + name: desktop-${{ inputs.platform }}-${{ inputs.arch }} + path: release-publish/* + if-no-files-found: error + + - name: Upload resource monitor + uses: actions/upload-artifact@v7 + with: + name: resource-monitor-${{ inputs.resource_key }} + path: resource-monitor-publish/${{ inputs.resource_key }}/* + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c920d010e7e..4bb1b3f6a998 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -348,22 +348,109 @@ jobs: 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 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: 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: Install bundle dependencies + env: + pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata + run: vp install --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... + + - 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. + - name: Build JS bundle + 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 + # node-pty publishes no Linux prebuilt and the WSL backend runs under the # distro's own (Linux) Node, which can't load the Windows/Electron binary. We # build the Linux pty.node here, on Linux, and hand it to the Windows packaging # job — the Windows artifact then ships a ready WSL backend binary with no # cross-compiling and no first-launch compiler/node-gyp/network on the user's # machine. node-pty is N-API, so one binary works across all WSL Node versions. - # The Linux CLI archive is built ahead of the desktop matrix because two - # consumers need it: the Linux desktop entry attaches it to the release, and - # 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. + # The Linux CLI archive has two consumers: the release attaches it, and the + # Windows desktop job 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-${{ 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' }} + needs: [preflight, relay_public_config, build_bundle] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} runs-on: ${{ matrix.runner }} timeout-minutes: 30 strategy: @@ -401,7 +488,6 @@ jobs: run-install: | args: - --filter=t3... - - --filter=@t3tools/web... - --filter=@t3tools/scripts... - name: Cache resource monitor @@ -438,10 +524,14 @@ jobs: - 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 + # The artifact root is `apps/` (upload-artifact keeps the least common + # ancestor of its paths), so extracting into `apps` restores + # apps/server/dist and apps/desktop/dist-electron at their build paths. + - name: Download JS bundle + uses: actions/download-artifact@v8 + with: + name: js-bundle + path: apps - name: Build CLI single-executable env: @@ -482,8 +572,8 @@ jobs: # 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' }} + needs: [preflight, relay_public_config, build_bundle] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} runs-on: windows-11-arm timeout-minutes: 30 env: @@ -518,7 +608,7 @@ jobs: - name: Install dependencies env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - run: vp install --filter=t3... --filter=@t3tools/web... --filter=@t3tools/scripts... + run: vp install --filter=t3... --filter=@t3tools/scripts... - name: Setup Rust uses: dtolnay/rust-toolchain@stable @@ -545,10 +635,14 @@ jobs: - 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 + # The artifact root is `apps/` (upload-artifact keeps the least common + # ancestor of its paths), so extracting into `apps` restores + # apps/server/dist and apps/desktop/dist-electron at their build paths. + - name: Download JS bundle + uses: actions/download-artifact@v8 + with: + name: js-bundle + path: apps - name: Build CLI single-executable shell: bash @@ -653,524 +747,118 @@ jobs: path: release-cli/* if-no-files-found: error - build: - name: Build ${{ matrix.label }} - # build_linux_cli stays in `needs` so it runs first and its artifact is - # available to download, but only the Windows matrix entry consumes it (as - # the WSL runtime). The job is gated on preflight + relay WITHOUT requiring - # build_linux_cli, so a failed Linux archive doesn't skip the macOS builds. - # `!cancelled()` (not `!failure()`) lets the job run even when - # build_linux_cli failed; the Windows-only download step below then fails - # that single platform if the archive is missing. - needs: [preflight, relay_public_config, build_linux_cli] - if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} - runs-on: ${{ matrix.runner }} - 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 }} - strategy: - fail-fast: false - matrix: - include: - # cli_archive: whether the job also builds the self-contained CLI - # 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 - target: dmg - arch: arm64 - 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 - target: dmg - arch: x64 - rust_target: x86_64-apple-darwin - resource_key: darwin-x64 - cli_archive: false - # The Linux CLI archive is produced by build_linux_cli, not here. - - 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: false - - label: Windows x64 - runner: blacksmith-32vcpu-windows-2025 - platform: win - target: nsis - arch: x64 - rust_target: x86_64-pc-windows-msvc - resource_key: win32-x64 - cli_archive: true - # - label: Windows arm64 - # runner: windows-11-arm - # platform: win - # target: nsis - # arch: arm64 - 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: ${{ matrix.platform != 'win' }} - run-install: false - - - name: Resolve Windows package cache path - if: matrix.platform == 'win' - id: package_cache_path - shell: pwsh - run: '"path=$(vp pm cache dir)" >> $env:GITHUB_OUTPUT' - - - name: Cache Windows packages - if: matrix.platform == 'win' - uses: actions/cache@v6 - with: - path: ${{ steps.package_cache_path.outputs.path }} - key: windows-release-packages-v1-${{ matrix.arch }}-${{ hashFiles('pnpm-lock.yaml') }} - - # 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: Install desktop dependencies - env: - pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... - - - name: Cache resource monitor - id: resource_monitor_cache - uses: actions/cache@v6 - with: - path: native/resource-monitor/target/${{ matrix.rust_target }}/release/t3-resource-monitor${{ matrix.platform == 'win' && '.exe' || '' }} - key: resource-monitor-${{ matrix.rust_target }}-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} - - - name: Cache Linux capture helpers - if: matrix.platform == 'linux' - id: capture_helper_cache - uses: actions/cache@v6 - with: - path: | - native/kde-snap-shot/target/${{ matrix.rust_target }}/release/t3-kde-snap-shot - native/hyprland-snap-shot/target/${{ matrix.rust_target }}/release/t3-hyprland-snap-shot - key: linux-capture-helpers-${{ matrix.rust_target }}-${{ hashFiles('native/kde-snap-shot/Cargo.lock', 'native/kde-snap-shot/Cargo.toml', 'native/kde-snap-shot/src/**', 'native/hyprland-snap-shot/Cargo.lock', 'native/hyprland-snap-shot/Cargo.toml', 'native/hyprland-snap-shot/src/**', 'native/hyprland-snap-shot/protocols/**') }} - - - name: Setup Rust - if: steps.resource_monitor_cache.outputs.cache-hit != 'true' || (matrix.platform == 'linux' && steps.capture_helper_cache.outputs.cache-hit != 'true') - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.rust_target }} - - - 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 WSL backend runs the Linux CLI archive inside the distro, so the - # Windows desktop embeds the same archive the release attaches. - - name: Download Linux CLI archive for WSL - if: matrix.platform == 'win' - uses: actions/download-artifact@v8 - with: - name: cli-linux-x64 - path: wsl-runtime - - - name: Install Spectre-mitigated MSVC libs - if: matrix.platform == 'win' - 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.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 - } - - - uses: ./.github/actions/setup-apt-mirrors - if: matrix.platform == 'linux' - - - name: Install Linux desktop build libraries - if: matrix.platform == 'linux' - shell: bash - run: | - sudo apt-get update - sudo apt-get install -y libsecret-1-dev pkg-config - if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then - sudo apt-get install -y imagemagick - fi - - if command -v magick >/dev/null 2>&1; then - magick -version - else - convert -version - fi - - - name: Prepare Azure Trusted Signing - if: matrix.platform == 'win' - 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: Build desktop artifact - shell: bash - env: - pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} - T3CODE_DESKTOP_REUSE_LINUX_CAPTURE_HELPERS: ${{ steps.capture_helper_cache.outputs.cache-hit == 'true' }} - CSC_LINK: ${{ secrets.CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} - MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} - T3CODE_CLERK_PASSKEY_RP_DOMAINS: ${{ vars.CLERK_PASSKEY_RP_DOMAINS }} - 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: | - args=( - --platform "${{ matrix.platform }}" - --target "${{ matrix.target }}" - --arch "${{ matrix.arch }}" - --build-version "${{ needs.preflight.outputs.version }}" - --verbose - ) - - has_all() { - for value in "$@"; do - if [[ -z "$value" ]]; then - return 1 - fi - done - return 0 - } - - if [[ "${{ matrix.platform }}" == "mac" ]]; then - if has_all "$CSC_LINK" "$CSC_KEY_PASSWORD" "$APPLE_API_KEY" "$APPLE_API_KEY_ID" "$APPLE_API_ISSUER"; then - if ! has_all "$APPLE_TEAM_ID" "$MACOS_PROVISIONING_PROFILE"; then - echo "macOS signing is configured, but APPLE_TEAM_ID or MACOS_PROVISIONING_PROFILE is missing." >&2 - exit 1 - fi - - key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" - printf '%s' "$APPLE_API_KEY" > "$key_path" - export APPLE_API_KEY="$key_path" - - profile_path="$RUNNER_TEMP/t3code.provisionprofile" - printf '%s' "$MACOS_PROVISIONING_PROFILE" | base64 -D > "$profile_path" - security cms -D -i "$profile_path" >/dev/null - export T3CODE_APPLE_TEAM_ID="$APPLE_TEAM_ID" - export T3CODE_MACOS_PROVISIONING_PROFILE="$profile_path" - - echo "macOS signing enabled." - args+=(--signed) - else - echo "macOS signing disabled (missing one or more Apple signing secrets)." - fi - elif [[ "${{ matrix.platform }}" == "win" ]]; then - # Embed the Linux CLI archive built by build_linux_cli as the WSL - # runtime. Required for a working WSL backend on Windows. - args+=(--wsl-runtime "$GITHUB_WORKSPACE"/wsl-runtime/t3-*-linux-x64.tar.gz) - if has_all \ - "$AZURE_TENANT_ID" \ - "$AZURE_CLIENT_ID" \ - "$AZURE_CLIENT_SECRET" \ - "$AZURE_TRUSTED_SIGNING_ENDPOINT" \ - "$AZURE_TRUSTED_SIGNING_ACCOUNT_NAME" \ - "$AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME" \ - "$AZURE_TRUSTED_SIGNING_PUBLISHER_NAME"; then - echo "Windows signing enabled (Azure Trusted Signing)." - args+=(--signed) - else - echo "Windows signing disabled (missing one or more Azure Trusted Signing secrets)." - fi - else - echo "Signing disabled for ${{ matrix.platform }}." - fi - - vp run dist:desktop:artifact "${args[@]}" - - # The single-executable is built with a Node that supports --build-sea - # (25.7+); the repo itself stays on the engines.node version. It always - # 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, and a cross-built macOS binary cannot be smoke-tested. - - name: Build CLI single-executable - if: matrix.cli_archive - shell: bash - env: - # The exact version, not a major: vp downloads it from nodejs.org/dist on - # the runner, and only exact versions have a dist directory. Keep in - # step with SEA_NODE_VERSION in apps/server/vite.config.ts. - VP_NODE_VERSION: "26.8.2" - run: node apps/server/scripts/cli.ts build-exe --verbose - - - name: Import macOS signing certificate for the CLI archive - if: matrix.cli_archive && matrix.platform == 'mac' - shell: bash - env: - CSC_LINK: ${{ secrets.CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} - run: | - set -euo pipefail - if [[ -z "$CSC_LINK" || -z "$CSC_KEY_PASSWORD" ]]; then - echo "macOS CLI signing disabled (missing CSC_LINK); the archive is signed ad hoc." - exit 0 - fi - keychain="$RUNNER_TEMP/t3-cli-signing.keychain-db" - keychain_password="$(openssl rand -hex 16)" - cert_path="$RUNNER_TEMP/t3-cli-signing.p12" - printf '%s' "$CSC_LINK" | base64 --decode > "$cert_path" - security create-keychain -p "$keychain_password" "$keychain" - security set-keychain-settings -lut 21600 "$keychain" - security unlock-keychain -p "$keychain_password" "$keychain" - security import "$cert_path" -k "$keychain" -P "$CSC_KEY_PASSWORD" -T /usr/bin/codesign - security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain" >/dev/null - security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') - identity="$(security find-identity -v -p codesigning "$keychain" | sed -n 's/.*"\(Developer ID Application: [^"]*\)".*/\1/p' | head -n 1)" - if [[ -z "$identity" ]]; then - echo "No Developer ID Application identity found in CSC_LINK." >&2 - exit 1 - fi - echo "::add-mask::$keychain_password" - echo "T3CODE_CLI_MAC_SIGN_IDENTITY=$identity" >> "$GITHUB_ENV" - echo "macOS CLI signing enabled." - - - name: Stage resource monitor for the CLI archive - if: matrix.cli_archive - shell: bash - run: | - set -euo pipefail - binary_name="t3-resource-monitor" - if [[ "${{ matrix.platform }}" == "win" ]]; then - binary_name="${binary_name}.exe" - fi - target_dir="$RUNNER_TEMP/cli-resource-monitor/${{ matrix.resource_key }}" - mkdir -p "$target_dir" - cp "native/resource-monitor/target/${{ matrix.rust_target }}/release/${binary_name}" "$target_dir/$binary_name" - - - name: Build CLI archive - if: matrix.cli_archive - shell: bash - env: - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - 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: | - set -euo pipefail - if [[ "${{ matrix.platform }}" == "mac" && -n "${APPLE_API_KEY:-}" ]]; then - key_path="$RUNNER_TEMP/AuthKey_cli_${APPLE_API_KEY_ID}.p8" - printf '%s' "$APPLE_API_KEY" > "$key_path" - export APPLE_API_KEY="$key_path" - fi - node scripts/build-cli-archive.ts \ - --platform "${{ matrix.platform }}" \ - --arch "${{ matrix.arch }}" \ - --version "${{ needs.preflight.outputs.version }}" \ - --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ - --output-dir release-cli - - - name: Smoke-test CLI archive - if: matrix.cli_archive - shell: bash - run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ needs.preflight.outputs.version }}" - - - name: Upload CLI archive - if: matrix.cli_archive - uses: actions/upload-artifact@v7 - with: - name: cli-${{ matrix.platform }}-${{ matrix.arch }} - path: release-cli/* - if-no-files-found: error - - - name: Collect release assets - shell: bash - run: | - set -euo pipefail - mkdir -p release-publish - - shopt -s nullglob - patterns=( - "release/*.dmg" - "release/*.zip" - "release/*.AppImage" - "release/*.exe" - ) - # Preview builds have no publish config, so electron-builder writes - # no feed manifest for them, but it still emits blockmaps beside the - # installers. Neither belongs on a release no updater may follow. - if [[ "${{ needs.preflight.outputs.release_channel }}" != "preview" ]]; then - patterns+=("release/*.blockmap" "release/*.yml") - fi - for pattern in "${patterns[@]}"; do - for file in $pattern; do - cp "$file" release-publish/ - done - done - - if [[ "${{ matrix.platform }}" == "mac" && "${{ matrix.arch }}" != "arm64" ]]; then - shopt -s nullglob - for manifest in release-publish/*-mac.yml; do - mv "$manifest" "${manifest%.yml}-${{ matrix.arch }}.yml" - done - fi - - # Enable if Windows arm64 builds are enabled. - # Windows updater metadata is channel-specific (for example - # "latest.yml" or "nightly.yml"). Suffix each per-arch copy so the - # release job can merge matching arm64/x64 manifests back into one - # canonical manifest per channel. - # if [[ "${{ matrix.platform }}" == "win" ]]; then - # shopt -s nullglob - # for manifest in release-publish/*.yml; do - # mv "$manifest" "${manifest%.yml}-win-${{ matrix.arch }}.yml" - # done - # fi - - - name: Collect resource monitor - shell: bash - run: | - set -euo pipefail - binary_name="t3-resource-monitor" - if [[ "${{ matrix.platform }}" == "win" ]]; then - binary_name="${binary_name}.exe" - fi - source_path="native/resource-monitor/target/${{ matrix.rust_target }}/release/${binary_name}" - target_dir="resource-monitor-publish/${{ matrix.resource_key }}" - mkdir -p "$target_dir" - cp "$source_path" "$target_dir/$binary_name" - - - name: Upload build artifacts - uses: actions/upload-artifact@v7 - with: - name: desktop-${{ matrix.platform }}-${{ matrix.arch }} - path: release-publish/* - if-no-files-found: error - - - name: Upload resource monitor - uses: actions/upload-artifact@v7 - with: - name: resource-monitor-${{ matrix.resource_key }} - path: resource-monitor-publish/${{ matrix.resource_key }}/* - if-no-files-found: error + # One job per desktop platform (see release-desktop.yml) so each is gated + # only on what it consumes: every platform needs the JS bundle, and only the + # Windows job also needs the Linux CLI archive it embeds as the WSL runtime. + # A failed Linux archive therefore never holds back or skips the other three. + 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 + + 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 + + # The Linux CLI archive is produced by build_linux_cli, not here. + 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: false + + # The only desktop job that consumes build_linux_cli: it embeds the linux-x64 + # archive as the WSL runtime. `!cancelled()` (not `!failure()`) still lets it + # start when build_linux_cli failed; the download step inside then fails this + # single platform if the archive is missing. + desktop_win_x64: + name: Desktop Windows x64 + needs: [preflight, relay_public_config, build_bundle, build_linux_cli] + 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-32vcpu-windows-2025 + platform: win + target: nsis + arch: x64 + rust_target: x86_64-pc-windows-msvc + resource_key: win32-x64 + cli_archive: true + # Windows arm64 desktop: no runner yet. + # desktop_win_arm64: runner windows-11-arm, platform win, target nsis, arch arm64 # Preview releases never reach npm: the archive on the GitHub Release is the # only way to obtain one, so no dist-tag can ever resolve to a preview build. publish_cli: name: Publish CLI to npm - needs: [preflight, relay_public_config, quality, build] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.build.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} + needs: [preflight, relay_public_config, quality, build_bundle] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.build_bundle.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 permissions: @@ -1199,30 +887,19 @@ jobs: run-install: | args: - --filter=t3... - - --filter=@t3tools/web... - --filter=@t3tools/scripts... - - 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 (once) as part of this step. - - name: Build CLI package - run: vp run --filter t3 build + # The artifact root is `apps/` (upload-artifact keeps the least common + # ancestor of its paths), so extracting into `apps` restores + # apps/server/dist and apps/desktop/dist-electron at their build paths. + - name: Download JS bundle + uses: actions/download-artifact@v8 + with: + name: js-bundle + path: apps - name: Download resource monitors uses: actions/download-artifact@v8 @@ -1247,8 +924,18 @@ jobs: release: name: Publish GitHub Release - 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')) }} + needs: + [ + preflight, + desktop_mac_arm64, + desktop_mac_x64, + desktop_linux_x64, + desktop_win_x64, + build_linux_cli, + build_windows_arm64_cli, + publish_cli, + ] + 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_win_x64.result == 'success' && needs.build_linux_cli.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 abe9ae00a8d4..24dcb0643b6b 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -23,7 +23,8 @@ This document covers the unified release workflow for stable and nightly desktop the commit to ship is not the latest nightly, such as a cherry-picked fix on a release branch. - 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 four artifacts in parallel for both channels: +- 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 four desktop artifacts in parallel for both channels, each as its own job (`desktop__`, one call of `release-desktop.yml`) gated only on the bundle, plus the Linux CLI archive for the Windows job: - macOS `arm64` DMG - macOS `x64` DMG - Linux `x64` AppImage @@ -413,7 +414,7 @@ Checklist: 4. Verify workflow steps: - preflight passes - release quality checks pass - - all matrix builds pass + - `build_bundle` and all platform builds pass - `publish_cli` publishes the exact release version before the release job - release job uploads expected files 5. Smoke test downloaded artifacts. From 9632ee794022faddeedd2c79e1e267538348f41d Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:08:40 -0700 Subject: [PATCH 2/3] ci(release): build every platform and architecture except macOS x64 as one uniform graph Each desktop__ job builds the desktop app and, where Node supports a single-executable there, the CLI archive too, all on hardware of its own architecture. Linux arm64 and Windows arm64 desktop builds join the four existing targets; the standalone Linux and Windows arm64 CLI jobs go away. Windows updater manifests get per-arch names and are merged back in the release job; the site's Linux pick pins x86_64 now that an arm64 AppImage exists. Co-Authored-By: Claude Fable 5 --- .github/workflows/release-desktop.yml | 36 +-- .github/workflows/release.yml | 406 +++++--------------------- apps/marketing/src/pages/index.astro | 2 +- docs/operations/release.md | 15 +- 4 files changed, 108 insertions(+), 351 deletions(-) diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 5057d9dce9d5..824f049e50e7 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -1,7 +1,7 @@ name: Release desktop build -# One desktop platform build, called four times from release.yml so each -# platform is its own job with its own `needs`. The JS bundle (server, web +# One desktop platform/arch build, called once per target from release.yml so +# each target is its own job with its own `needs`. The JS bundle (server, web # client, Electron main) comes from the `js-bundle` artifact that build_bundle # produced; this job only packages it, builds the native helpers, and, where # `cli_archive` is set, the self-contained CLI archive for its platform. @@ -166,12 +166,12 @@ jobs: path: apps # The WSL backend runs the Linux CLI archive inside the distro, so the - # Windows desktop embeds the same archive the release attaches. + # Windows desktop embeds the same-arch archive the release attaches. - name: Download Linux CLI archive for WSL if: inputs.platform == 'win' uses: actions/download-artifact@v8 with: - name: cli-linux-x64 + name: cli-linux-${{ inputs.arch }} path: wsl-runtime - name: Install Spectre-mitigated MSVC libs @@ -183,7 +183,7 @@ jobs: $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.x86.x64.Spectre", "--quiet", "--norestart" ` + "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 } @@ -335,9 +335,9 @@ jobs: echo "macOS signing disabled (missing one or more Apple signing secrets)." fi elif [[ "${{ inputs.platform }}" == "win" ]]; then - # Embed the Linux CLI archive built by build_linux_cli as the WSL - # runtime. Required for a working WSL backend on Windows. - args+=(--wsl-runtime "$GITHUB_WORKSPACE"/wsl-runtime/t3-*-linux-x64.tar.gz) + # Embed the Linux CLI archive built by the same-arch Linux job as + # the WSL runtime. Required for a working WSL backend on Windows. + args+=(--wsl-runtime "$GITHUB_WORKSPACE"/wsl-runtime/t3-*-linux-${{ inputs.arch }}.tar.gz) if has_all \ "$AZURE_TENANT_ID" \ "$AZURE_CLIENT_ID" \ @@ -488,17 +488,17 @@ jobs: done fi - # Enable if Windows arm64 builds are enabled. # Windows updater metadata is channel-specific (for example - # "latest.yml" or "nightly.yml"). Suffix each per-arch copy so the - # release job can merge matching arm64/x64 manifests back into one - # canonical manifest per channel. - # if [[ "${{ inputs.platform }}" == "win" ]]; then - # shopt -s nullglob - # for manifest in release-publish/*.yml; do - # mv "$manifest" "${manifest%.yml}-win-${{ inputs.arch }}.yml" - # done - # fi + # "latest.yml" or "nightly.yml") and carries no arch, so the x64 and + # arm64 jobs would upload the same name. Suffix each per-arch copy; + # the release job merges them back into one manifest per channel. + # builder-debug.yml is electron-builder's config dump, not a feed. + if [[ "${{ inputs.platform }}" == "win" ]]; then + shopt -s nullglob extglob + for manifest in release-publish/!(builder-debug).yml; do + mv "$manifest" "${manifest%.yml}-win-${{ inputs.arch }}.yml" + done + fi - name: Collect resource monitor shell: bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4bb1b3f6a998..aff55e16c6d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -438,319 +438,12 @@ jobs: if-no-files-found: error retention-days: 1 - # node-pty publishes no Linux prebuilt and the WSL backend runs under the - # distro's own (Linux) Node, which can't load the Windows/Electron binary. We - # build the Linux pty.node here, on Linux, and hand it to the Windows packaging - # job — the Windows artifact then ships a ready WSL backend binary with no - # cross-compiling and no first-launch compiler/node-gyp/network on the user's - # machine. node-pty is N-API, so one binary works across all WSL Node versions. - # The Linux CLI archive has two consumers: the release attaches it, and the - # Windows desktop job 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-${{ matrix.arch }}) - needs: [preflight, relay_public_config, build_bundle] - if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.result == 'success' }} - 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 }} - 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: true - run-install: | - args: - - --filter=t3... - - --filter=@t3tools/scripts... - - - name: Cache resource monitor - id: resource_monitor_cache - uses: actions/cache@v6 - with: - 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: ${{ 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 ${{ matrix.rust_target }} - - - 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 artifact root is `apps/` (upload-artifact keeps the least common - # ancestor of its paths), so extracting into `apps` restores - # apps/server/dist and apps/desktop/dist-electron at their build paths. - - name: Download JS bundle - uses: actions/download-artifact@v8 - with: - name: js-bundle - path: apps - - - name: Build CLI single-executable - env: - # The exact version, not a major: vp downloads it from nodejs.org/dist on - # the runner, and only exact versions have a dist directory. Keep in - # step with SEA_NODE_VERSION in apps/server/vite.config.ts. - VP_NODE_VERSION: "26.8.2" - run: node apps/server/scripts/cli.ts build-exe --verbose - - - name: Stage resource monitor for the CLI archive - run: | - set -euo pipefail - target_dir="$RUNNER_TEMP/cli-resource-monitor/linux-${{ matrix.arch }}" - mkdir -p "$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 ${{ matrix.arch }} \ - --version "${{ needs.preflight.outputs.version }}" \ - --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ - --output-dir release-cli - - - name: Smoke-test CLI archive - 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-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: [preflight, relay_public_config, build_bundle] - if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build_bundle.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/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 artifact root is `apps/` (upload-artifact keeps the least common - # ancestor of its paths), so extracting into `apps` restores - # apps/server/dist and apps/desktop/dist-electron at their build paths. - - name: Download JS bundle - uses: actions/download-artifact@v8 - with: - name: js-bundle - path: apps - - - 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 - - # One job per desktop platform (see release-desktop.yml) so each is gated - # only on what it consumes: every platform needs the JS bundle, and only the - # Windows job also needs the Linux CLI archive it embeds as the WSL runtime. - # A failed Linux archive therefore never holds back or skips the other three. + # One job per platform and architecture (see release-desktop.yml), each on + # hardware of its own architecture, and each gated only on what it consumes: + # every platform needs the JS bundle, and the Windows jobs also need the + # same-arch Linux job, whose CLI archive they embed as the WSL runtime. 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] @@ -800,7 +493,6 @@ jobs: # on start. The x64 desktop app is Electron and unaffected. cli_archive: false - # The Linux CLI archive is produced by build_linux_cli, not here. desktop_linux_x64: name: Desktop Linux x64 needs: [preflight, relay_public_config, build_bundle] @@ -822,15 +514,40 @@ jobs: arch: x64 rust_target: x86_64-unknown-linux-gnu resource_key: linux-x64 - cli_archive: false + cli_archive: 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: ubuntu-24.04-arm + platform: linux + target: AppImage + arch: arm64 + rust_target: aarch64-unknown-linux-gnu + resource_key: linux-arm64 + cli_archive: true - # The only desktop job that consumes build_linux_cli: it embeds the linux-x64 - # archive as the WSL runtime. `!cancelled()` (not `!failure()`) still lets it - # start when build_linux_cli failed; the download step inside then fails this - # single platform if the archive is missing. + # The Windows jobs embed the same-arch Linux CLI archive as the WSL runtime. + # `!cancelled()` (not `!failure()`) still lets them start when that Linux job + # failed; the download step inside then fails this single platform if the + # archive is missing. desktop_win_x64: name: Desktop Windows x64 - needs: [preflight, relay_public_config, build_bundle, build_linux_cli] + needs: [preflight, relay_public_config, build_bundle, desktop_linux_x64] 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 @@ -850,8 +567,29 @@ jobs: rust_target: x86_64-pc-windows-msvc resource_key: win32-x64 cli_archive: true - # Windows arm64 desktop: no runner yet. - # desktop_win_arm64: runner windows-11-arm, platform win, target nsis, arch arm64 + + desktop_win_arm64: + name: Desktop Windows arm64 + needs: [preflight, relay_public_config, build_bundle, desktop_linux_arm64] + 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: windows-11-arm + platform: win + target: nsis + arch: arm64 + rust_target: aarch64-pc-windows-msvc + resource_key: win32-arm64 + cli_archive: true # Preview releases never reach npm: the archive on the GitHub Release is the # only way to obtain one, so no dist-tag can ever resolve to a preview build. @@ -930,12 +668,12 @@ jobs: desktop_mac_arm64, desktop_mac_x64, desktop_linux_x64, + desktop_linux_arm64, desktop_win_x64, - build_linux_cli, - build_windows_arm64_cli, + desktop_win_arm64, publish_cli, ] - 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_win_x64.result == 'success' && needs.build_linux_cli.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')) }} + 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' || (needs.preflight.outputs.release_channel == 'preview' && needs.publish_cli.result == 'skipped')) }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 30 permissions: @@ -1006,6 +744,9 @@ jobs: 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: | @@ -1018,6 +759,21 @@ jobs: 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 diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index cb90f3687184..669bdce8a72d 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -444,7 +444,7 @@ const mobileEndorsementRows = [ return assets.find((a) => a.name.endsWith("-arm64.dmg"))?.browser_download_url ?? null; } if (platform.os === "linux") { - return assets.find((a) => a.name.endsWith(".AppImage"))?.browser_download_url ?? null; + return assets.find((a) => a.name.endsWith("-x86_64.AppImage"))?.browser_download_url ?? null; } return null; } diff --git a/docs/operations/release.md b/docs/operations/release.md index 24dcb0643b6b..1b74c27b903f 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -24,18 +24,18 @@ This document covers the unified release workflow for stable and nightly desktop - 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 four desktop artifacts in parallel for both channels, each as its own job (`desktop__`, one call of `release-desktop.yml`) gated only on the bundle, plus the Linux CLI archive for the Windows job: +- Builds six desktop artifacts in parallel for both channels, each as its own job (`desktop__`, one call of `release-desktop.yml`) on hardware of its own architecture, gated only on the bundle (the Windows jobs also wait for the same-arch Linux job, whose CLI archive they embed as the WSL runtime): - macOS `arm64` DMG - macOS `x64` DMG - - Linux `x64` AppImage - - Windows `x64` NSIS installer + - Linux `x64` and `arm64` AppImage + - Windows `x64` and `arm64` NSIS installer - Publishes one GitHub Release with all produced files. - Stable tags with a suffix after `X.Y.Z` (for example `1.2.3-alpha.1`) are published as GitHub prereleases. - Only plain stable `X.Y.Z` releases are marked as the repository's latest release. - 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, 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. +- 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 built, signed, and smoke-tested on hardware of its own architecture. 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. @@ -254,9 +254,10 @@ executables declared as unpacked by that archive must be present at the matching paths below `resources/server.asar.unpacked`. The Windows-native backend reads the archive in place through Electron. Packaged Windows builds also ship `resources/wsl-runtime.tar.gz` plus its SHA-256 sidecar: the Linux CLI archive -(`t3--linux-x64.tar.gz`) built by the `build_linux_cli` job and handed -to the Windows desktop build as `--wsl-runtime`, copied in verbatim so WSL runs -the exact bytes a Linux user downloads. WSL verifies and extracts that archive +(`t3--linux-.tar.gz`, the same arch as the Windows host) built +by the Linux desktop job and handed to the Windows desktop build as +`--wsl-runtime`, copied in verbatim so WSL runs the exact bytes a Linux user +downloads. WSL verifies and extracts that archive into `~/.t3/wsl-runtime/sha256-` inside the selected distro, then reuses it for later launches of the same update. From 02eb55df109996f5b6ebae2f98d2bf718980209f Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:22:38 -0700 Subject: [PATCH 3/3] fix(release): drop the extglob that broke the asset collection step bash parses the whole if block before the shopt inside it runs, so the !(...) pattern was a syntax error on every platform. Co-Authored-By: Claude Fable 5 --- .github/workflows/release-desktop.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 824f049e50e7..5d790e49a061 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -494,8 +494,8 @@ jobs: # the release job merges them back into one manifest per channel. # builder-debug.yml is electron-builder's config dump, not a feed. if [[ "${{ inputs.platform }}" == "win" ]]; then - shopt -s nullglob extglob - for manifest in release-publish/!(builder-debug).yml; do + for manifest in release-publish/*.yml; do + [[ "$manifest" == */builder-debug.yml ]] && continue mv "$manifest" "${manifest%.yml}-win-${{ inputs.arch }}.yml" done fi