name: Release desktop build # 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. on: workflow_call: secrets: CSC_LINK: required: false CSC_KEY_PASSWORD: required: false APPLE_API_KEY: required: false APPLE_API_KEY_ID: required: false APPLE_API_ISSUER: required: false MACOS_PROVISIONING_PROFILE: required: false AZURE_TENANT_ID: required: false AZURE_CLIENT_ID: required: false AZURE_CLIENT_SECRET: required: false AZURE_TRUSTED_SIGNING_ENDPOINT: required: false AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: required: false AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: required: false AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: required: false 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 # Whether the job opens the packaged app against a seeded database and # opens a thread before uploading it. Linux AppImage or macOS DMG. launch_check: required: false default: false type: boolean # A runner of the target's own platform and architecture, for a `runner` # that cannot execute the target. The build job skips everything that # runs the packaged binaries, and a second job runs it there. verify_runner: required: false default: "" type: string # Whether this job saves the workspace pnpm store. Release's two Macs # install the same store on the same runner type, so only one saves it. save_pnpm_store: required: false default: true type: boolean version: required: true type: string ref: required: true type: string release_channel: required: true type: string # Whether a `relay-client-tracing-config` artifact from the production # relay state is expected. PR previews carry no tracing config. relay_client_tracing: required: false default: true type: boolean 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 # electron-builder leaves the default arch (x64) out of the unpacked app's # directory name. env: UNPACKED_DIR: ${{ inputs.platform }}-${{ inputs.arch != 'x64' && format('{0}-', inputs.arch) || '' }}unpacked permissions: # Windows builds read this run's artifact list to wait for the Linux CLI archive. actions: read contents: read jobs: build: name: Build ${{ inputs.label }} runs-on: ${{ inputs.runner }} # Windows waits for the Linux CLI archive inside its own budget (see # "Wait for Linux CLI archive"), so it gets that wait on top of the usual 30. timeout-minutes: ${{ inputs.platform == 'win' && 70 || 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: # This repository is public, so Git needs no credentials. checkout's # credential cleanup runs submodule foreach even with submodules disabled, # which fails on the orphaned gitlinks in our vendored .repos tree. # checkout.workers=0 writes the files on every core. Alternating both ways # in one job on the Windows runners, it wrote the files about 10s faster # (about 40% on arm64). - name: Checkout shell: bash env: CHECKOUT_REF: ${{ inputs.ref }} GIT_TERMINAL_PROMPT: "0" run: | set -euo pipefail git init . git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" git sparse-checkout set --no-cone '/*' '!/.repos/' git -c checkout.workers=0 checkout --detach FETCH_HEAD # 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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata - name: Setup dependencies uses: ./.github/actions/setup-ci-deps with: install-args: --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... save-pnpm-store: ${{ inputs.save_pnpm_store }} env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata # The CLI archive and the staged app install without a lockfile, so they # ask the registry about every package and fetch platform packages the # workspace store lacks. They share this registry metadata and store. A # new week starts cold, which bounds how stale cached ranges can get. - name: Resolve staged install cache id: stage_pnpm shell: bash run: | echo "dir=$RUNNER_TEMP/desktop-stage-pnpm" >> "$GITHUB_OUTPUT" echo "prefix=desktop-stage-pnpm-v2-${{ inputs.platform }}-${{ inputs.arch }}-$(date -u +%G-%V)-" >> "$GITHUB_OUTPUT" - name: Restore staged install cache id: stage_pnpm_cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.stage_pnpm.outputs.dir }} key: ${{ steps.stage_pnpm.outputs.prefix }}${{ github.run_id }}-${{ github.run_attempt }} restore-keys: ${{ steps.stage_pnpm.outputs.prefix }} - name: Mark staged install cache shell: bash run: mkdir -p "${{ steps.stage_pnpm.outputs.dir }}" && touch "$RUNNER_TEMP/desktop-stage-pnpm.marker" - name: Cache resource monitor id: resource_monitor_cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # 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@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # 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: Ensure Rosetta for macOS x64 bytecode generation if: inputs.platform == 'mac' && inputs.arch == 'x64' shell: bash run: | if [[ "$(uname -m)" == "arm64" ]] && ! /usr/bin/arch -x86_64 /usr/bin/true; then sudo /usr/sbin/softwareupdate --install-rosetta --agree-to-license fi - 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@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: toolchain: stable targets: ${{ inputs.rust_target }} - name: Download relay client tracing config if: inputs.relay_client_tracing uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: relay-client-tracing-config path: ${{ runner.temp }}/relay-client-tracing - name: Load relay client tracing config if: inputs.relay_client_tracing 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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: js-bundle path: apps - name: Install cargo-xwin if: inputs.platform == 'win' && steps.resource_monitor_cache.outputs.cache-hit != 'true' uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2 with: tool: cargo-xwin@0.23.1 # Built here rather than inside the desktop build so the CLI archive, # which bundles the same binary, can be built and uploaded first. - name: Build resource monitor if: steps.resource_monitor_cache.outputs.cache-hit != 'true' shell: bash run: cargo ${{ inputs.platform == 'win' && 'xwin build' || 'build' }} --locked --release --manifest-path native/resource-monitor/Cargo.toml --target "${{ inputs.rust_target }}" # Off Windows, scripts/lib/windows-jsign.ts signs through jsign instead of # the TrustedSigning PowerShell module. - name: Install jsign if: inputs.platform == 'win' shell: bash run: | set -euo pipefail dir="$RUNNER_TEMP/jsign" mkdir -p "$dir" curl -fsSL --retry 3 -o "$dir/jsign.jar" https://github.com/ebourg/jsign/releases/download/7.6/jsign-7.6.jar echo "6cf58aa080e7ff82656f320cff8d4a3bff30408df8766806dcba3740dd839fa5 $dir/jsign.jar" | sha256sum -c - printf '#!/bin/sh\nexec java -jar "%s" "$@"\n' "$dir/jsign.jar" > "$dir/jsign" chmod +x "$dir/jsign" echo "T3CODE_JSIGN=$dir/jsign" >> "$GITHUB_ENV" # The desktop build applies the same branding to apps/server/dist/client # (resolveDesktopWebAssetBrand in scripts/build-desktop-artifact.ts); the # CLI archive below ships that client and is now built first. - name: Apply web client branding if: inputs.cli_archive shell: bash run: | brand=production if [[ "${{ inputs.version }}" =~ -nightly\.[0-9]{8}\.[0-9]+$ ]]; then brand=nightly; fi node scripts/apply-web-brand-assets.ts "$brand" apps/server/dist/client - name: Resolve target Node.js for the CLI executable id: target_node if: inputs.cli_archive && inputs.platform == 'win' shell: bash run: echo "version=$(node -p 'require("./package.json").engines.node')" >> "$GITHUB_OUTPUT" # The download only changes with engines.node, so a cached copy keeps # releases off nodejs.org, which stalls on the runners some days. - name: Restore target Node.js for the CLI executable id: target_node_cache if: inputs.cli_archive && inputs.platform == 'win' uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ runner.temp }}/node-download key: node-download-v${{ steps.target_node.outputs.version }}-win-${{ inputs.arch }} # tsdown downloads the target Node with one fetch and no retry, and # nodejs.org drops that download often enough to fail releases. Seed # tsdown's cache with a retried, checksum-verified copy instead. A transfer # under 100 KB/s for 30s counts as failed, so a stall retries. - name: Download target Node.js for the CLI executable if: inputs.cli_archive && inputs.platform == 'win' && steps.target_node_cache.outputs.cache-hit != 'true' shell: bash env: VERSION: ${{ steps.target_node.outputs.version }} run: | set -euo pipefail name="node-v$VERSION-win-${{ inputs.arch }}" dir="$RUNNER_TEMP/node-download" mkdir -p "$dir" fetch=(curl -fsSL --retry 5 --retry-all-errors --connect-timeout 10 --speed-limit 102400 --speed-time 30) "${fetch[@]}" -o "$dir/$name.zip" "https://nodejs.org/dist/v$VERSION/$name.zip" "${fetch[@]}" -o "$dir/SHASUMS256.txt" "https://nodejs.org/dist/v$VERSION/SHASUMS256.txt" (cd "$dir" && grep " $name.zip\$" SHASUMS256.txt | sha256sum -c -) # Saved right away so a later failure in this job still keeps the download. - name: Save target Node.js for the CLI executable if: inputs.cli_archive && inputs.platform == 'win' && steps.target_node_cache.outputs.cache-hit != 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ runner.temp }}/node-download key: ${{ steps.target_node_cache.outputs.cache-primary-key }} - name: Install target Node.js for the CLI executable if: inputs.cli_archive && inputs.platform == 'win' shell: bash env: VERSION: ${{ steps.target_node.outputs.version }} run: | set -euo pipefail name="node-v$VERSION-win-${{ inputs.arch }}" dir="$RUNNER_TEMP/node-download" dest="${XDG_CACHE_HOME:-$HOME/.cache}/tsdown/node/v$VERSION/win-${{ inputs.arch }}" mkdir -p "$dest" (cd "$dir" && grep " $name.zip\$" SHASUMS256.txt | sha256sum -c -) unzip -p "$dir/$name.zip" "$name/node.exe" > "$dest/node.exe" # Setup Vite+ selects the exact engines.node version from package.json. # Native builds embed that executable; cross-target builds download the # same version for their target. - name: Build CLI single-executable if: inputs.cli_archive shell: bash env: # The executable bundles the server again, so it needs the same stamp # as release.yml's JS bundle. T3CODE_TELEMETRY_CHANNEL: ${{ github.repository == 'pingdotgg/t3code' && inputs.release_channel || 'dev' }} run: node apps/server/scripts/cli.ts build-exe --verbose ${{ inputs.platform == 'win' && format('--target win-{0}', inputs.arch) || '' }} - name: Import macOS signing certificate for the CLI archive if: inputs.cli_archive && inputs.platform == 'mac' 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" - uses: ./.github/actions/setup-apt-mirrors if: inputs.platform == 'linux' # The Linux archive ships libatomic.so.1 and points t3's RUNPATH at it. - name: Install patchelf if: inputs.cli_archive && inputs.platform == 'linux' shell: bash run: command -v patchelf || { sudo apt-get update && sudo apt-get install -y patchelf; } - name: Build CLI archive id: build_cli if: inputs.cli_archive shell: bash env: pnpm_config_cache_dir: ${{ steps.stage_pnpm.outputs.dir }}/cache pnpm_config_store_dir: ${{ steps.stage_pnpm.outputs.dir }}/store pnpm_config_prefer_offline: "true" 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 extra_args=() if [[ "${{ inputs.platform }}" == "mac" ]]; then if [[ -n "${APPLE_API_KEY:-}" ]]; then key_dir="$(mktemp -d "$RUNNER_TEMP/t3-cli-notary.XXXXXXXX")" trap 'rm -rf "$key_dir"' EXIT key_path="$key_dir/AuthKey.p8" (umask 077 && printf '%s' "$APPLE_API_KEY" > "$key_path") export APPLE_API_KEY="$key_path" fi # Older release refs lack deferred notarization and still wait here. cli_help="$(node scripts/build-cli-archive.ts --help)" if [[ "$cli_help" == *--notary-submission-file* ]]; then extra_args=(--notary-submission-file "$RUNNER_TEMP/t3-cli-notary-submission") echo "deferred_notarization=true" >> "$GITHUB_OUTPUT" fi 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 \ ${extra_args[@]+"${extra_args[@]}"} - name: Smoke-test CLI archive if: inputs.cli_archive && inputs.verify_runner == '' shell: bash run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ inputs.version }}" # macOS uploads its archive after "Await CLI notarization" instead. - name: Upload CLI archive if: inputs.cli_archive && inputs.platform != 'mac' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: cli-${{ inputs.platform }}-${{ inputs.arch }} path: release-cli/* if-no-files-found: error # The archive is already gzip or zip; deflating it again saves under 2%. compression-level: 0 - 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 libgtk-3-0t64 libnss3 libasound2t64 libgbm1 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 # The WSL backend runs the Linux CLI archive inside the distro, so the # Windows desktop embeds the same-arch archive the release attaches. # This job starts alongside that Linux job instead of after it, and the # Linux job uploads the archive partway through, so the wait is placed # after all other setup. It stops early if the Linux job ended without # uploading the archive. - name: Wait for Linux CLI archive if: inputs.platform == 'win' shell: pwsh timeout-minutes: 40 env: GITHUB_TOKEN: ${{ github.token }} ARTIFACT: cli-linux-${{ inputs.arch }} LINUX_JOB: Build Linux ${{ inputs.arch }} run: | $headers = @{ Authorization = "Bearer $env:GITHUB_TOKEN"; Accept = "application/vnd.github+json" } $run = "$env:GITHUB_API_URL/repos/$env:GITHUB_REPOSITORY/actions/runs/$env:GITHUB_RUN_ID" function Test-Archive { (Invoke-RestMethod -Headers $headers -Uri "$run/artifacts?name=$env:ARTIFACT").total_count -gt 0 } $missingSince = $null while ($true) { try { if (Test-Archive) { Write-Host "$env:ARTIFACT is ready." exit 0 } $jobs = Invoke-RestMethod -Headers $headers -Uri "$run/attempts/$env:GITHUB_RUN_ATTEMPT/jobs?per_page=100" $linux = $jobs.jobs | Where-Object { $_.name.EndsWith($env:LINUX_JOB) } | Select-Object -First 1 if (-not $linux) { # Both jobs start together, so a Linux job still missing after a # few minutes means a renamed or skipped job, not a slow one. if (-not $missingSince) { $missingSince = Get-Date } elseif (((Get-Date) - $missingSince).TotalMinutes -ge 5) { Write-Host "::error::No job named '$env:LINUX_JOB' in this run, so $env:ARTIFACT will not arrive." exit 1 } } elseif ($linux.conclusion) { # The upload may have landed between the two requests above. if (Test-Archive) { Write-Host "$env:ARTIFACT is ready." exit 0 } Write-Host "::error::$env:LINUX_JOB ended ($($linux.conclusion)) without uploading $env:ARTIFACT." exit 1 } } catch { Write-Warning "Could not read this run's artifacts or jobs: $($_.Exception.Message)" } Write-Host "Waiting for $env:ARTIFACT..." Start-Sleep -Seconds 10 } - name: Download Linux CLI archive for WSL if: inputs.platform == 'win' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: cli-linux-${{ inputs.arch }} path: wsl-runtime - name: Build desktop artifact shell: bash env: # pnpm only goes to the registry for packages or versions the cache lacks. pnpm_config_cache_dir: ${{ steps.stage_pnpm.outputs.dir }}/cache pnpm_config_store_dir: ${{ steps.stage_pnpm.outputs.dir }}/store pnpm_config_prefer_offline: "true" T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: 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 ) if [[ -n "${{ inputs.verify_runner }}" ]]; then args+=(--keep-stage) fi 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 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" \ "$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 CLI was submitted without waiting so Apple reviews it while the # desktop builds. Notarization does not change the archive bytes, but # nothing uploads them, so nothing publishes them, until Apple accepts. # With signing configured, a missing submission id fails this step. - name: Await CLI notarization if: inputs.cli_archive && inputs.platform == 'mac' && steps.build_cli.outputs.deferred_notarization == 'true' 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 }} run: | set -euo pipefail if [[ -n "${APPLE_API_KEY:-}" ]]; then key_dir="$(mktemp -d "$RUNNER_TEMP/t3-cli-notary.XXXXXXXX")" trap 'rm -rf "$key_dir"' EXIT key_path="$key_dir/AuthKey.p8" (umask 077 && printf '%s' "$APPLE_API_KEY" > "$key_path") export APPLE_API_KEY="$key_path" fi node scripts/build-cli-archive.ts await-notarization \ --submission-file "$RUNNER_TEMP/t3-cli-notary-submission" - name: Upload notarized CLI archive if: inputs.cli_archive && inputs.platform == 'mac' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: cli-${{ inputs.platform }}-${{ inputs.arch }} path: release-cli/* if-no-files-found: error # The archive is already gzip or zip; deflating it again saves under 2%. compression-level: 0 # Warm installs only touch the store index, so new content means a fetch. - name: Check staged install cache for new content id: stage_pnpm_changed shell: bash run: | dir="${{ steps.stage_pnpm.outputs.dir }}" new=$(find "$dir/cache/v12" "$dir/store/v11/files" -type f -newer "$RUNNER_TEMP/desktop-stage-pnpm.marker" 2>/dev/null | wc -l | tr -d ' ') echo "Staged installs added $new cached files." echo "changed=$([[ "$new" -gt 0 ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" - name: Save staged install cache if: steps.stage_pnpm_changed.outputs.changed == 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.stage_pnpm.outputs.dir }} key: ${{ steps.stage_pnpm_cache.outputs.cache-primary-key }} # A build can package fine and still not open (a missing module in the # backend or the bundle). This launches the app from the shipped # AppImage or DMG. - name: Launch check if: inputs.launch_check shell: bash run: | set -euo pipefail check=(node scripts/smoke-desktop-app.ts --artifacts-dir "$RUNNER_TEMP/desktop-launch-check") if [[ "${{ inputs.platform }}" == "mac" ]]; then mount="$RUNNER_TEMP/desktop-launch-dmg" hdiutil attach -nobrowse -readonly -mountpoint "$mount" "$(ls "$GITHUB_WORKSPACE"/release/*.dmg)" trap 'hdiutil detach "$mount" -force >/dev/null || true' EXIT app=$(ls -d "$mount"/*.app) executable=$(/usr/libexec/PlistBuddy -c "Print :CFBundleExecutable" "$app/Contents/Info.plist") "${check[@]}" --executable "$app/Contents/MacOS/$executable" else sudo apt-get install -y xvfb appimage=$(ls "$GITHUB_WORKSPACE"/release/*.AppImage) chmod +x "$appimage" (cd "$RUNNER_TEMP" && "$appimage" --appimage-extract >/dev/null) xvfb-run -a "${check[@]}" --executable "$RUNNER_TEMP/squashfs-root/t3code" fi - name: Upload launch check output if: failure() && inputs.launch_check uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: desktop-launch-check-${{ inputs.platform }}-${{ inputs.arch }} path: ${{ runner.temp }}/desktop-launch-check if-no-files-found: ignore # The verify job runs what this host could not: the packaged natives and # the CLI. Its scripts are bundled so that job needs no install. - name: Stage verification inputs if: inputs.verify_runner != '' shell: bash run: | set -euo pipefail mkdir -p verify unpacked=$(ls -d "${TMPDIR:-/tmp}"/t3code-desktop-${{ inputs.platform }}-stage-*/app/dist/"$UNPACKED_DIR") tar -C "$(dirname "$unpacked")" -cf verify/unpacked.tar "$(basename "$unpacked")" esbuild=scripts/node_modules/.bin/esbuild banner="import{createRequire as __cr}from'node:module';const require=__cr(import.meta.url);" # build-desktop-artifact.ts's own import.meta.main guard must not run # its CLI inside the verify bundle. "$esbuild" scripts/verify-windows-desktop-artifact.ts --bundle --platform=node --format=esm \ --target=node24 --define:import.meta.main=false --banner:js="$banner" \ --outfile=verify/verify-windows-desktop-artifact.mjs "$esbuild" scripts/smoke-cli-archive.ts --bundle --platform=node --format=esm \ --target=node24 --banner:js="$banner" --outfile=verify/smoke-cli-archive.mjs - name: Upload verification inputs if: inputs.verify_runner != '' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: verify-${{ inputs.platform }}-${{ inputs.arch }} path: verify/* if-no-files-found: error retention-days: 1 compression-level: 1 - 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/*.deb" "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 # Windows updater metadata is channel-specific (for example # "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 for manifest in release-publish/*.yml; do [[ "$manifest" == */builder-debug.yml ]] && continue 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: desktop-${{ inputs.platform }}-${{ inputs.arch }} path: release-publish/* if-no-files-found: error # Installers, zips and blockmaps are already compressed; deflating them again saves under 0.3%. compression-level: 0 - name: Upload resource monitor uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: resource-monitor-${{ inputs.resource_key }} path: resource-monitor-publish/${{ inputs.resource_key }}/* if-no-files-found: error verify: name: Verify needs: [build] if: inputs.verify_runner != '' runs-on: ${{ inputs.verify_runner }} timeout-minutes: 15 steps: - name: Download verification inputs uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: verify-${{ inputs.platform }}-${{ inputs.arch }} path: verify - name: Download CLI archive if: inputs.cli_archive uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: cli-${{ inputs.platform }}-${{ inputs.arch }} path: release-cli - name: Verify packaged natives shell: bash run: | set -euo pipefail tar -C verify -xf verify/unpacked.tar node verify/verify-windows-desktop-artifact.mjs \ --app-dir "$(cygpath -w "$PWD/verify/$UNPACKED_DIR")" \ --arch "${{ inputs.arch }}" --build-version "${{ inputs.version }}" --verbose - name: Smoke-test CLI archive if: inputs.cli_archive shell: bash run: node verify/smoke-cli-archive.mjs --archive release-cli/* --expect-version "${{ inputs.version }}" # cargo-xwin links this binary on Linux, and nothing else runs it before users do. # Node rather than pwsh: PowerShell on Windows ARM crashes intermittently # (https://github.com/dotnet/runtime/issues/131636), which is why this # target stopped building on Windows. - name: Smoke-test resource monitor shell: bash env: MONITOR: verify/${{ env.UNPACKED_DIR }}/resources/resource-monitor/t3-resource-monitor.exe EXPECTED_ARCH: ${{ inputs.arch == 'arm64' && 'aarch64' || 'x86_64' }} run: | node --input-type=module <<'EOF' import { spawn } from "node:child_process"; const monitor = spawn(process.env.MONITOR, { stdio: ["pipe", "pipe", "inherit"] }); monitor.stdout.setEncoding("utf8"); let output = ""; const exited = new Promise((resolve) => monitor.on("close", resolve)); const hello = await new Promise((resolve, reject) => { monitor.on("error", reject); monitor.on("close", () => reject(new Error(`Resource monitor exited before hello: ${output}`))); monitor.stdout.on("data", (chunk) => { output += chunk; const end = output.indexOf("\n"); if (end !== -1) resolve(JSON.parse(output.slice(0, end))); }); }); if (hello.type !== "hello" || hello.arch !== process.env.EXPECTED_ARCH) { throw new Error(`Unexpected hello: ${JSON.stringify(hello)}`); } const send = (message) => monitor.stdin.write(`${JSON.stringify({ version: hello.version, ...message })}\n`); send({ type: "configure", rootPid: process.pid, sampleIntervalMs: 1000 }); send({ type: "sampleNow", requestId: "verify" }); monitor.stdin.end(); const exitCode = await exited; const sample = output .split("\n") .filter((line) => line.trim()) .map((line) => JSON.parse(line)) .find((message) => message.requestId === "verify"); if (exitCode !== 0 || sample?.type !== "snapshot" || !sample.processes.some((p) => p.pid === process.pid)) { throw new Error(`Resource monitor exited ${exitCode} with: ${output}`); } console.log(`Resource monitor sampled ${sample.scannedProcessCount} processes.`); EOF