Skip to content
Source
Sign inSign up

release-desktop.yml

920 lines41.9 KB

1name: Release desktop build
2​
3# One desktop platform/arch build, called once per target from release.yml so
4# each target is its own job with its own `needs`. The JS bundle (server, web
5# client, Electron main) comes from the `js-bundle` artifact that build_bundle
6# produced; this job only packages it, builds the native helpers, and, where
7# `cli_archive` is set, the self-contained CLI archive for its platform.
8​
9on:
10 workflow_call:
11 secrets:
12 CSC_LINK:
13 required: false
14 CSC_KEY_PASSWORD:
15 required: false
16 APPLE_API_KEY:
17 required: false
18 APPLE_API_KEY_ID:
19 required: false
20 APPLE_API_ISSUER:
21 required: false
22 MACOS_PROVISIONING_PROFILE:
23 required: false
24 AZURE_TENANT_ID:
25 required: false
26 AZURE_CLIENT_ID:
27 required: false
28 AZURE_CLIENT_SECRET:
29 required: false
30 AZURE_TRUSTED_SIGNING_ENDPOINT:
31 required: false
32 AZURE_TRUSTED_SIGNING_ACCOUNT_NAME:
33 required: false
34 AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME:
35 required: false
36 AZURE_TRUSTED_SIGNING_PUBLISHER_NAME:
37 required: false
38 inputs:
39 label:
40 required: true
41 type: string
42 runner:
43 required: true
44 type: string
45 platform:
46 required: true
47 type: string
48 target:
49 required: true
50 type: string
51 arch:
52 required: true
53 type: string
54 rust_target:
55 required: true
56 type: string
57 resource_key:
58 required: true
59 type: string
60 # Whether the job also builds the self-contained CLI archive for its own
61 # platform/arch, on this runner, and smoke-tests it here. Every archive
62 # is built on hardware of its own architecture.
63 cli_archive:
64 required: false
65 default: false
66 type: boolean
67 # Whether the job opens the packaged app against a seeded database and
68 # opens a thread before uploading it. Linux AppImage or macOS DMG.
69 launch_check:
70 required: false
71 default: false
72 type: boolean
73 # A runner of the target's own platform and architecture, for a `runner`
74 # that cannot execute the target. The build job skips everything that
75 # runs the packaged binaries, and a second job runs it there.
76 verify_runner:
77 required: false
78 default: ""
79 type: string
80 # Whether this job saves the workspace pnpm store. Release's two Macs
81 # install the same store on the same runner type, so only one saves it.
82 save_pnpm_store:
83 required: false
84 default: true
85 type: boolean
86 version:
87 required: true
88 type: string
89 ref:
90 required: true
91 type: string
92 release_channel:
93 required: true
94 type: string
95 # Whether a `relay-client-tracing-config` artifact from the production
96 # relay state is expected. PR previews carry no tracing config.
97 relay_client_tracing:
98 required: false
99 default: true
100 type: boolean
101 clerk_publishable_key:
102 required: true
103 type: string
104 clerk_jwt_template:
105 required: true
106 type: string
107 clerk_cli_oauth_client_id:
108 required: true
109 type: string
110 relay_url:
111 required: true
112 type: string
113​
114# electron-builder leaves the default arch (x64) out of the unpacked app's
115# directory name.
116env:
117 UNPACKED_DIR: ${{ inputs.platform }}-${{ inputs.arch != 'x64' && format('{0}-', inputs.arch) || '' }}unpacked
118​
119permissions:
120 # Windows builds read this run's artifact list to wait for the Linux CLI archive.
121 actions: read
122 contents: read
123​
124jobs:
125 build:
126 name: Build ${{ inputs.label }}
127 runs-on: ${{ inputs.runner }}
128 # Windows waits for the Linux CLI archive inside its own budget (see
129 # "Wait for Linux CLI archive"), so it gets that wait on top of the usual 30.
130 timeout-minutes: ${{ inputs.platform == 'win' && 70 || 30 }}
131 env:
132 T3CODE_CLERK_PUBLISHABLE_KEY: ${{ inputs.clerk_publishable_key }}
133 T3CODE_CLERK_JWT_TEMPLATE: ${{ inputs.clerk_jwt_template }}
134 T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ inputs.clerk_cli_oauth_client_id }}
135 T3CODE_RELAY_URL: ${{ inputs.relay_url }}
136 steps:
137 # This repository is public, so Git needs no credentials. checkout's
138 # credential cleanup runs submodule foreach even with submodules disabled,
139 # which fails on the orphaned gitlinks in our vendored .repos tree.
140 # checkout.workers=0 writes the files on every core. Alternating both ways
141 # in one job on the Windows runners, it wrote the files about 10s faster
142 # (about 40% on arm64).
143 - name: Checkout
144 shell: bash
145 env:
146 CHECKOUT_REF: ${{ inputs.ref }}
147 GIT_TERMINAL_PROMPT: "0"
148 run: |
149 set -euo pipefail
150 git init .
151 git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
152 git fetch --no-tags --depth=1 origin "$CHECKOUT_REF"
153 git sparse-checkout set --no-cone '/*' '!/.repos/'
154 git -c checkout.workers=0 checkout --detach FETCH_HEAD
155​
156 # pnpm checks the lockfile and policy before reusing this result. A missing
157 # artifact leaves the cache empty, so installation runs the checks again.
158 - name: Download dependency verification
159 continue-on-error: true
160 uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
161 with:
162 name: release-dependency-verification
163 path: ${{ runner.temp }}/pnpm-metadata
164​
165 - name: Setup dependencies
166 uses: ./.github/actions/setup-ci-deps
167 with:
168 install-args: --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts...
169 save-pnpm-store: ${{ inputs.save_pnpm_store }}
170 env:
171 pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata
172​
173 # The CLI archive and the staged app install without a lockfile, so they
174 # ask the registry about every package and fetch platform packages the
175 # workspace store lacks. They share this registry metadata and store. A
176 # new week starts cold, which bounds how stale cached ranges can get.
177 - name: Resolve staged install cache
178 id: stage_pnpm
179 shell: bash
180 run: |
181 echo "dir=$RUNNER_TEMP/desktop-stage-pnpm" >> "$GITHUB_OUTPUT"
182 echo "prefix=desktop-stage-pnpm-v2-${{ inputs.platform }}-${{ inputs.arch }}-$(date -u +%G-%V)-" >> "$GITHUB_OUTPUT"
183​
184 - name: Restore staged install cache
185 id: stage_pnpm_cache
186 uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
187 with:
188 path: ${{ steps.stage_pnpm.outputs.dir }}
189 key: ${{ steps.stage_pnpm.outputs.prefix }}${{ github.run_id }}-${{ github.run_attempt }}
190 restore-keys: ${{ steps.stage_pnpm.outputs.prefix }}
191​
192 - name: Mark staged install cache
193 shell: bash
194 run: mkdir -p "${{ steps.stage_pnpm.outputs.dir }}" && touch "$RUNNER_TEMP/desktop-stage-pnpm.marker"
195​
196 - name: Cache resource monitor
197 id: resource_monitor_cache
198 uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
199 with:
200 path: native/resource-monitor/target/${{ inputs.rust_target }}/release/t3-resource-monitor${{ inputs.platform == 'win' && '.exe' || '' }}
201 key: resource-monitor-${{ inputs.rust_target }}-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }}
202​
203 - name: Cache Linux capture helpers
204 if: inputs.platform == 'linux'
205 id: capture_helper_cache
206 uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
207 with:
208 path: |
209 native/kde-snap-shot/target/${{ inputs.rust_target }}/release/t3-kde-snap-shot
210 native/hyprland-snap-shot/target/${{ inputs.rust_target }}/release/t3-hyprland-snap-shot
211 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/**') }}
212​
213 # Every Linux build makes electron-builder fetch the AppImage runtime, FPM
214 # for the deb, and the 7-Zip that unpacks FPM from GitHub releases. It
215 # checks an archive found here against the SHA-256 pinned in those toolset
216 # files and downloads again on a mismatch, so only raw archives are cached.
217 - name: Cache Linux packaging tool archives
218 if: inputs.platform == 'linux'
219 uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
220 with:
221 path: |
222 ~/.cache/electron-builder/appimage@*/appimage-tools-runtime-*.tar.gz
223 ~/.cache/electron-builder/fpm@*/fpm-*.7z
224 ~/.cache/electron-builder/7zip@*/7zip-linux-*.tar.gz
225 key: electron-builder-linux-tools-${{ inputs.arch }}-${{ hashFiles('node_modules/.pnpm/app-builder-lib@*/node_modules/app-builder-lib/out/toolsets/linux.js', 'node_modules/.pnpm/app-builder-lib@*/node_modules/app-builder-lib/out/toolsets/7zip.js') }}
226​
227 - name: Ensure Rosetta for macOS x64 bytecode generation
228 if: inputs.platform == 'mac' && inputs.arch == 'x64'
229 shell: bash
230 run: |
231 if [[ "$(uname -m)" == "arm64" ]] && ! /usr/bin/arch -x86_64 /usr/bin/true; then
232 sudo /usr/sbin/softwareupdate --install-rosetta --agree-to-license
233 fi
234​
235 - name: Setup Rust
236 if: steps.resource_monitor_cache.outputs.cache-hit != 'true' || (inputs.platform == 'linux' && steps.capture_helper_cache.outputs.cache-hit != 'true')
237 uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
238 with:
239 toolchain: stable
240 targets: ${{ inputs.rust_target }}
241​
242 - name: Download relay client tracing config
243 if: inputs.relay_client_tracing
244 uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
245 with:
246 name: relay-client-tracing-config
247 path: ${{ runner.temp }}/relay-client-tracing
248​
249 - name: Load relay client tracing config
250 if: inputs.relay_client_tracing
251 shell: bash
252 run: |
253 config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env"
254 tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")"
255 echo "::add-mask::$tracing_token"
256 cat "$config_path" >> "$GITHUB_ENV"
257​
258 - name: Align package versions to release version
259 run: node scripts/update-release-package-versions.ts "${{ inputs.version }}"
260​
261 # The artifact root is `apps/` (upload-artifact keeps the least common
262 # ancestor of its paths), so extracting into `apps` restores
263 # apps/server/dist and apps/desktop/dist-electron at their build paths.
264 - name: Download JS bundle
265 uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
266 with:
267 name: js-bundle
268 path: apps
269​
270 - name: Install cargo-xwin
271 if: inputs.platform == 'win' && steps.resource_monitor_cache.outputs.cache-hit != 'true'
272 uses: taiki-e/install-action@cfdb7ed4ccc7c7dce37759eae89dc5674b469b39 # v2.90.3
273 with:
274 tool: cargo-xwin@0.23.1
275​
276 # Built here rather than inside the desktop build so the CLI archive,
277 # which bundles the same binary, can be built and uploaded first.
278 - name: Build resource monitor
279 if: steps.resource_monitor_cache.outputs.cache-hit != 'true'
280 shell: bash
281 run: cargo ${{ inputs.platform == 'win' && 'xwin build' || 'build' }} --locked --release --manifest-path native/resource-monitor/Cargo.toml --target "${{ inputs.rust_target }}"
282​
283 # Off Windows, scripts/lib/windows-jsign.ts signs through jsign instead of
284 # the TrustedSigning PowerShell module.
285 - name: Install jsign
286 if: inputs.platform == 'win'
287 shell: bash
288 run: |
289 set -euo pipefail
290 dir="$RUNNER_TEMP/jsign"
291 mkdir -p "$dir"
292 curl -fsSL --retry 3 -o "$dir/jsign.jar" https://github.com/ebourg/jsign/releases/download/7.6/jsign-7.6.jar
293 echo "6cf58aa080e7ff82656f320cff8d4a3bff30408df8766806dcba3740dd839fa5 $dir/jsign.jar" | sha256sum -c -
294 printf '#!/bin/sh\nexec java -jar "%s" "$@"\n' "$dir/jsign.jar" > "$dir/jsign"
295 chmod +x "$dir/jsign"
296 echo "T3CODE_JSIGN=$dir/jsign" >> "$GITHUB_ENV"
297​
298 # The desktop build applies the same branding to apps/server/dist/client
299 # (resolveDesktopWebAssetBrand in scripts/build-desktop-artifact.ts); the
300 # CLI archive below ships that client and is now built first.
301 - name: Apply web client branding
302 if: inputs.cli_archive
303 shell: bash
304 run: |
305 brand=production
306 if [[ "${{ inputs.version }}" =~ -nightly\.[0-9]{8}\.[0-9]+$ ]]; then brand=nightly; fi
307 node scripts/apply-web-brand-assets.ts "$brand" apps/server/dist/client
308​
309 - name: Resolve target Node.js for the CLI executable
310 id: target_node
311 if: inputs.cli_archive && inputs.platform == 'win'
312 shell: bash
313 run: echo "version=$(node -p 'require("./package.json").engines.node')" >> "$GITHUB_OUTPUT"
314​
315 # The download only changes with engines.node, so a cached copy keeps
316 # releases off nodejs.org, which stalls on the runners some days.
317 - name: Restore target Node.js for the CLI executable
318 id: target_node_cache
319 if: inputs.cli_archive && inputs.platform == 'win'
320 uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
321 with:
322 path: ${{ runner.temp }}/node-download
323 key: node-download-v${{ steps.target_node.outputs.version }}-win-${{ inputs.arch }}
324​
325 # tsdown downloads the target Node with one fetch and no retry, and
326 # nodejs.org drops that download often enough to fail releases. Seed
327 # tsdown's cache with a retried, checksum-verified copy instead. A transfer
328 # under 100 KB/s for 30s counts as failed, so a stall retries.
329 - name: Download target Node.js for the CLI executable
330 if: inputs.cli_archive && inputs.platform == 'win' && steps.target_node_cache.outputs.cache-hit != 'true'
331 shell: bash
332 env:
333 VERSION: ${{ steps.target_node.outputs.version }}
334 run: |
335 set -euo pipefail
336 name="node-v$VERSION-win-${{ inputs.arch }}"
337 dir="$RUNNER_TEMP/node-download"
338 mkdir -p "$dir"
339 fetch=(curl -fsSL --retry 5 --retry-all-errors --connect-timeout 10 --speed-limit 102400 --speed-time 30)
340 "${fetch[@]}" -o "$dir/$name.zip" "https://nodejs.org/dist/v$VERSION/$name.zip"
341 "${fetch[@]}" -o "$dir/SHASUMS256.txt" "https://nodejs.org/dist/v$VERSION/SHASUMS256.txt"
342 (cd "$dir" && grep " $name.zip\$" SHASUMS256.txt | sha256sum -c -)
343​
344 # Saved right away so a later failure in this job still keeps the download.
345 - name: Save target Node.js for the CLI executable
346 if: inputs.cli_archive && inputs.platform == 'win' && steps.target_node_cache.outputs.cache-hit != 'true'
347 uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
348 with:
349 path: ${{ runner.temp }}/node-download
350 key: ${{ steps.target_node_cache.outputs.cache-primary-key }}
351​
352 - name: Install target Node.js for the CLI executable
353 if: inputs.cli_archive && inputs.platform == 'win'
354 shell: bash
355 env:
356 VERSION: ${{ steps.target_node.outputs.version }}
357 run: |
358 set -euo pipefail
359 name="node-v$VERSION-win-${{ inputs.arch }}"
360 dir="$RUNNER_TEMP/node-download"
361 dest="${XDG_CACHE_HOME:-$HOME/.cache}/tsdown/node/v$VERSION/win-${{ inputs.arch }}"
362 mkdir -p "$dest"
363 (cd "$dir" && grep " $name.zip\$" SHASUMS256.txt | sha256sum -c -)
364 unzip -p "$dir/$name.zip" "$name/node.exe" > "$dest/node.exe"
365​
366 # Setup Vite+ selects the exact engines.node version from package.json.
367 # Native builds embed that executable; cross-target builds download the
368 # same version for their target.
369 - name: Build CLI single-executable
370 if: inputs.cli_archive
371 shell: bash
372 env:
373 # The executable bundles the server again, so it needs the same stamp
374 # as release.yml's JS bundle.
375 T3CODE_TELEMETRY_CHANNEL: ${{ github.repository == 'pingdotgg/t3code' && inputs.release_channel || 'dev' }}
376 run: node apps/server/scripts/cli.ts build-exe --verbose ${{ inputs.platform == 'win' && format('--target win-{0}', inputs.arch) || '' }}
377​
378 - name: Import macOS signing certificate for the CLI archive
379 if: inputs.cli_archive && inputs.platform == 'mac'
380 shell: bash
381 env:
382 CSC_LINK: ${{ secrets.CSC_LINK }}
383 CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
384 run: |
385 set -euo pipefail
386 if [[ -z "$CSC_LINK" || -z "$CSC_KEY_PASSWORD" ]]; then
387 echo "macOS CLI signing disabled (missing CSC_LINK); the archive is signed ad hoc."
388 exit 0
389 fi
390 keychain="$RUNNER_TEMP/t3-cli-signing.keychain-db"
391 keychain_password="$(openssl rand -hex 16)"
392 cert_path="$RUNNER_TEMP/t3-cli-signing.p12"
393 printf '%s' "$CSC_LINK" | base64 --decode > "$cert_path"
394 security create-keychain -p "$keychain_password" "$keychain"
395 security set-keychain-settings -lut 21600 "$keychain"
396 security unlock-keychain -p "$keychain_password" "$keychain"
397 security import "$cert_path" -k "$keychain" -P "$CSC_KEY_PASSWORD" -T /usr/bin/codesign
398 security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain" >/dev/null
399 security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"')
400 identity="$(security find-identity -v -p codesigning "$keychain" | sed -n 's/.*"\(Developer ID Application: [^"]*\)".*/\1/p' | head -n 1)"
401 if [[ -z "$identity" ]]; then
402 echo "No Developer ID Application identity found in CSC_LINK." >&2
403 exit 1
404 fi
405 echo "::add-mask::$keychain_password"
406 echo "T3CODE_CLI_MAC_SIGN_IDENTITY=$identity" >> "$GITHUB_ENV"
407 echo "macOS CLI signing enabled."
408​
409 - name: Stage resource monitor for the CLI archive
410 if: inputs.cli_archive
411 shell: bash
412 run: |
413 set -euo pipefail
414 binary_name="t3-resource-monitor"
415 if [[ "${{ inputs.platform }}" == "win" ]]; then
416 binary_name="${binary_name}.exe"
417 fi
418 target_dir="$RUNNER_TEMP/cli-resource-monitor/${{ inputs.resource_key }}"
419 mkdir -p "$target_dir"
420 cp "native/resource-monitor/target/${{ inputs.rust_target }}/release/${binary_name}" "$target_dir/$binary_name"
421​
422 - uses: ./.github/actions/setup-apt-mirrors
423 if: inputs.platform == 'linux'
424​
425 # The Linux archive ships libatomic.so.1 and points t3's RUNPATH at it.
426 - name: Install patchelf
427 if: inputs.cli_archive && inputs.platform == 'linux'
428 shell: bash
429 run: command -v patchelf || { sudo apt-get update && sudo apt-get install -y patchelf; }
430​
431 - name: Build CLI archive
432 id: build_cli
433 if: inputs.cli_archive
434 shell: bash
435 env:
436 pnpm_config_cache_dir: ${{ steps.stage_pnpm.outputs.dir }}/cache
437 pnpm_config_store_dir: ${{ steps.stage_pnpm.outputs.dir }}/store
438 pnpm_config_prefer_offline: "true"
439 APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
440 APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
441 APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
442 AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
443 AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
444 AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
445 AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
446 AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
447 AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }}
448 run: |
449 set -euo pipefail
450 extra_args=()
451 if [[ "${{ inputs.platform }}" == "mac" ]]; then
452 if [[ -n "${APPLE_API_KEY:-}" ]]; then
453 key_dir="$(mktemp -d "$RUNNER_TEMP/t3-cli-notary.XXXXXXXX")"
454 trap 'rm -rf "$key_dir"' EXIT
455 key_path="$key_dir/AuthKey.p8"
456 (umask 077 && printf '%s' "$APPLE_API_KEY" > "$key_path")
457 export APPLE_API_KEY="$key_path"
458 fi
459 # Older release refs lack deferred notarization and still wait here.
460 cli_help="$(node scripts/build-cli-archive.ts --help)"
461 if [[ "$cli_help" == *--notary-submission-file* ]]; then
462 extra_args=(--notary-submission-file "$RUNNER_TEMP/t3-cli-notary-submission")
463 echo "deferred_notarization=true" >> "$GITHUB_OUTPUT"
464 fi
465 fi
466 node scripts/build-cli-archive.ts \
467 --platform "${{ inputs.platform }}" \
468 --arch "${{ inputs.arch }}" \
469 --version "${{ inputs.version }}" \
470 --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \
471 --output-dir release-cli \
472 ${extra_args[@]+"${extra_args[@]}"}
473​
474 - name: Smoke-test CLI archive
475 if: inputs.cli_archive && inputs.verify_runner == ''
476 shell: bash
477 run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ inputs.version }}"
478​
479 # macOS uploads its archive after "Await CLI notarization" instead.
480 - name: Upload CLI archive
481 if: inputs.cli_archive && inputs.platform != 'mac'
482 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
483 with:
484 name: cli-${{ inputs.platform }}-${{ inputs.arch }}
485 path: release-cli/*
486 if-no-files-found: error
487 # The archive is already gzip or zip; deflating it again saves under 2%.
488 compression-level: 0
489​
490 - name: Install Linux desktop build libraries
491 if: inputs.platform == 'linux'
492 shell: bash
493 run: |
494 sudo apt-get update
495 sudo apt-get install -y libsecret-1-dev pkg-config libgtk-3-0t64 libnss3 libasound2t64 libgbm1
496 if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then
497 sudo apt-get install -y imagemagick
498 fi
499​
500 if command -v magick >/dev/null 2>&1; then
501 magick -version
502 else
503 convert -version
504 fi
505​
506 # The WSL backend runs the Linux CLI archive inside the distro, so the
507 # Windows desktop embeds the same-arch archive the release attaches.
508 # This job starts alongside that Linux job instead of after it, and the
509 # Linux job uploads the archive partway through, so the wait is placed
510 # after all other setup. It stops early if the Linux job ended without
511 # uploading the archive.
512 - name: Wait for Linux CLI archive
513 if: inputs.platform == 'win'
514 shell: pwsh
515 timeout-minutes: 40
516 env:
517 GITHUB_TOKEN: ${{ github.token }}
518 ARTIFACT: cli-linux-${{ inputs.arch }}
519 LINUX_JOB: Build Linux ${{ inputs.arch }}
520 run: |
521 $headers = @{ Authorization = "Bearer $env:GITHUB_TOKEN"; Accept = "application/vnd.github+json" }
522 $run = "$env:GITHUB_API_URL/repos/$env:GITHUB_REPOSITORY/actions/runs/$env:GITHUB_RUN_ID"
523 function Test-Archive {
524 (Invoke-RestMethod -Headers $headers -Uri "$run/artifacts?name=$env:ARTIFACT").total_count -gt 0
525 }
526 $missingSince = $null
527 while ($true) {
528 try {
529 if (Test-Archive) {
530 Write-Host "$env:ARTIFACT is ready."
531 exit 0
532 }
533 $jobs = Invoke-RestMethod -Headers $headers -Uri "$run/attempts/$env:GITHUB_RUN_ATTEMPT/jobs?per_page=100"
534 $linux = $jobs.jobs | Where-Object { $_.name.EndsWith($env:LINUX_JOB) } | Select-Object -First 1
535 if (-not $linux) {
536 # Both jobs start together, so a Linux job still missing after a
537 # few minutes means a renamed or skipped job, not a slow one.
538 if (-not $missingSince) {
539 $missingSince = Get-Date
540 } elseif (((Get-Date) - $missingSince).TotalMinutes -ge 5) {
541 Write-Host "::error::No job named '$env:LINUX_JOB' in this run, so $env:ARTIFACT will not arrive."
542 exit 1
543 }
544 } elseif ($linux.conclusion) {
545 # The upload may have landed between the two requests above.
546 if (Test-Archive) {
547 Write-Host "$env:ARTIFACT is ready."
548 exit 0
549 }
550 Write-Host "::error::$env:LINUX_JOB ended ($($linux.conclusion)) without uploading $env:ARTIFACT."
551 exit 1
552 }
553 } catch {
554 Write-Warning "Could not read this run's artifacts or jobs: $($_.Exception.Message)"
555 }
556 Write-Host "Waiting for $env:ARTIFACT..."
557 Start-Sleep -Seconds 10
558 }
559​
560 - name: Download Linux CLI archive for WSL
561 if: inputs.platform == 'win'
562 uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
563 with:
564 name: cli-linux-${{ inputs.arch }}
565 path: wsl-runtime
566​
567 - name: Build desktop artifact
568 shell: bash
569 env:
570 # pnpm only goes to the registry for packages or versions the cache lacks.
571 pnpm_config_cache_dir: ${{ steps.stage_pnpm.outputs.dir }}/cache
572 pnpm_config_store_dir: ${{ steps.stage_pnpm.outputs.dir }}/store
573 pnpm_config_prefer_offline: "true"
574 T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: true
575 T3CODE_DESKTOP_REUSE_LINUX_CAPTURE_HELPERS: ${{ steps.capture_helper_cache.outputs.cache-hit == 'true' }}
576 CSC_LINK: ${{ secrets.CSC_LINK }}
577 CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
578 APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
579 APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
580 APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
581 APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
582 MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }}
583 T3CODE_CLERK_PASSKEY_RP_DOMAINS: ${{ vars.CLERK_PASSKEY_RP_DOMAINS }}
584 AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
585 AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
586 AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
587 AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
588 AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
589 AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }}
590 AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }}
591 run: |
592 args=(
593 --platform "${{ inputs.platform }}"
594 --target "${{ inputs.target }}"
595 --arch "${{ inputs.arch }}"
596 --build-version "${{ inputs.version }}"
597 --skip-build
598 --verbose
599 )
600 if [[ -n "${{ inputs.verify_runner }}" ]]; then
601 args+=(--keep-stage)
602 fi
603​
604 has_all() {
605 for value in "$@"; do
606 if [[ -z "$value" ]]; then
607 return 1
608 fi
609 done
610 return 0
611 }
612​
613 if [[ "${{ inputs.platform }}" == "mac" ]]; then
614 if has_all "$CSC_LINK" "$CSC_KEY_PASSWORD" "$APPLE_API_KEY" "$APPLE_API_KEY_ID" "$APPLE_API_ISSUER"; then
615 if ! has_all "$APPLE_TEAM_ID" "$MACOS_PROVISIONING_PROFILE"; then
616 echo "macOS signing is configured, but APPLE_TEAM_ID or MACOS_PROVISIONING_PROFILE is missing." >&2
617 exit 1
618 fi
619​
620 key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8"
621 printf '%s' "$APPLE_API_KEY" > "$key_path"
622 export APPLE_API_KEY="$key_path"
623​
624 profile_path="$RUNNER_TEMP/t3code.provisionprofile"
625 printf '%s' "$MACOS_PROVISIONING_PROFILE" | base64 -D > "$profile_path"
626 security cms -D -i "$profile_path" >/dev/null
627 export T3CODE_APPLE_TEAM_ID="$APPLE_TEAM_ID"
628 export T3CODE_MACOS_PROVISIONING_PROFILE="$profile_path"
629​
630 echo "macOS signing enabled."
631 args+=(--signed)
632 else
633 echo "macOS signing disabled (missing one or more Apple signing secrets)."
634 fi
635 elif [[ "${{ inputs.platform }}" == "win" ]]; then
636 # Embed the Linux CLI archive built by the same-arch Linux job as
637 # the WSL runtime. Required for a working WSL backend on Windows.
638 args+=(--wsl-runtime "$GITHUB_WORKSPACE"/wsl-runtime/t3-*-linux-${{ inputs.arch }}.tar.gz)
639 if has_all \
640 "$AZURE_TENANT_ID" \
641 "$AZURE_CLIENT_ID" \
642 "$AZURE_CLIENT_SECRET" \
643 "$AZURE_TRUSTED_SIGNING_ENDPOINT" \
644 "$AZURE_TRUSTED_SIGNING_ACCOUNT_NAME" \
645 "$AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME" \
646 "$AZURE_TRUSTED_SIGNING_PUBLISHER_NAME"; then
647 echo "Windows signing enabled (Azure Trusted Signing)."
648 args+=(--signed)
649 else
650 echo "Windows signing disabled (missing one or more Azure Trusted Signing secrets)."
651 fi
652 else
653 echo "Signing disabled for ${{ inputs.platform }}."
654 fi
655​
656 vp run dist:desktop:artifact "${args[@]}"
657​
658 # The CLI was submitted without waiting so Apple reviews it while the
659 # desktop builds. Notarization does not change the archive bytes, but
660 # nothing uploads them, so nothing publishes them, until Apple accepts.
661 # With signing configured, a missing submission id fails this step.
662 - name: Await CLI notarization
663 if: inputs.cli_archive && inputs.platform == 'mac' && steps.build_cli.outputs.deferred_notarization == 'true'
664 shell: bash
665 env:
666 APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
667 APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
668 APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
669 run: |
670 set -euo pipefail
671 if [[ -n "${APPLE_API_KEY:-}" ]]; then
672 key_dir="$(mktemp -d "$RUNNER_TEMP/t3-cli-notary.XXXXXXXX")"
673 trap 'rm -rf "$key_dir"' EXIT
674 key_path="$key_dir/AuthKey.p8"
675 (umask 077 && printf '%s' "$APPLE_API_KEY" > "$key_path")
676 export APPLE_API_KEY="$key_path"
677 fi
678 node scripts/build-cli-archive.ts await-notarization \
679 --submission-file "$RUNNER_TEMP/t3-cli-notary-submission"
680​
681 - name: Upload notarized CLI archive
682 if: inputs.cli_archive && inputs.platform == 'mac'
683 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
684 with:
685 name: cli-${{ inputs.platform }}-${{ inputs.arch }}
686 path: release-cli/*
687 if-no-files-found: error
688 # The archive is already gzip or zip; deflating it again saves under 2%.
689 compression-level: 0
690​
691 # Warm installs only touch the store index, so new content means a fetch.
692 - name: Check staged install cache for new content
693 id: stage_pnpm_changed
694 shell: bash
695 run: |
696 dir="${{ steps.stage_pnpm.outputs.dir }}"
697 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 ' ')
698 echo "Staged installs added $new cached files."
699 echo "changed=$([[ "$new" -gt 0 ]] && echo true || echo false)" >> "$GITHUB_OUTPUT"
700​
701 - name: Save staged install cache
702 if: steps.stage_pnpm_changed.outputs.changed == 'true'
703 uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
704 with:
705 path: ${{ steps.stage_pnpm.outputs.dir }}
706 key: ${{ steps.stage_pnpm_cache.outputs.cache-primary-key }}
707​
708 # A build can package fine and still not open (a missing module in the
709 # backend or the bundle). This launches the app from the shipped
710 # AppImage or DMG.
711 - name: Launch check
712 if: inputs.launch_check
713 shell: bash
714 run: |
715 set -euo pipefail
716 check=(node scripts/smoke-desktop-app.ts --artifacts-dir "$RUNNER_TEMP/desktop-launch-check")
717 if [[ "${{ inputs.platform }}" == "mac" ]]; then
718 mount="$RUNNER_TEMP/desktop-launch-dmg"
719 hdiutil attach -nobrowse -readonly -mountpoint "$mount" "$(ls "$GITHUB_WORKSPACE"/release/*.dmg)"
720 trap 'hdiutil detach "$mount" -force >/dev/null || true' EXIT
721 app=$(ls -d "$mount"/*.app)
722 executable=$(/usr/libexec/PlistBuddy -c "Print :CFBundleExecutable" "$app/Contents/Info.plist")
723 "${check[@]}" --executable "$app/Contents/MacOS/$executable"
724 else
725 sudo apt-get install -y xvfb
726 appimage=$(ls "$GITHUB_WORKSPACE"/release/*.AppImage)
727 chmod +x "$appimage"
728 (cd "$RUNNER_TEMP" && "$appimage" --appimage-extract >/dev/null)
729 xvfb-run -a "${check[@]}" --executable "$RUNNER_TEMP/squashfs-root/t3code"
730 fi
731​
732 - name: Upload launch check output
733 if: failure() && inputs.launch_check
734 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
735 with:
736 name: desktop-launch-check-${{ inputs.platform }}-${{ inputs.arch }}
737 path: ${{ runner.temp }}/desktop-launch-check
738 if-no-files-found: ignore
739​
740 # The verify job runs what this host could not: the packaged natives and
741 # the CLI. Its scripts are bundled so that job needs no install.
742 - name: Stage verification inputs
743 if: inputs.verify_runner != ''
744 shell: bash
745 run: |
746 set -euo pipefail
747 mkdir -p verify
748 unpacked=$(ls -d "${TMPDIR:-/tmp}"/t3code-desktop-${{ inputs.platform }}-stage-*/app/dist/"$UNPACKED_DIR")
749 tar -C "$(dirname "$unpacked")" -cf verify/unpacked.tar "$(basename "$unpacked")"
750 esbuild=scripts/node_modules/.bin/esbuild
751 banner="import{createRequire as __cr}from'node:module';const require=__cr(import.meta.url);"
752 # build-desktop-artifact.ts's own import.meta.main guard must not run
753 # its CLI inside the verify bundle.
754 "$esbuild" scripts/verify-windows-desktop-artifact.ts --bundle --platform=node --format=esm \
755 --target=node24 --define:import.meta.main=false --banner:js="$banner" \
756 --outfile=verify/verify-windows-desktop-artifact.mjs
757 "$esbuild" scripts/smoke-cli-archive.ts --bundle --platform=node --format=esm \
758 --target=node24 --banner:js="$banner" --outfile=verify/smoke-cli-archive.mjs
759​
760 - name: Upload verification inputs
761 if: inputs.verify_runner != ''
762 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
763 with:
764 name: verify-${{ inputs.platform }}-${{ inputs.arch }}
765 path: verify/*
766 if-no-files-found: error
767 retention-days: 1
768 compression-level: 1
769​
770 - name: Collect release assets
771 shell: bash
772 run: |
773 set -euo pipefail
774 mkdir -p release-publish
775​
776 shopt -s nullglob
777 patterns=(
778 "release/*.dmg"
779 "release/*.zip"
780 "release/*.AppImage"
781 "release/*.deb"
782 "release/*.exe"
783 )
784 # Preview builds have no publish config, so electron-builder writes
785 # no feed manifest for them, but it still emits blockmaps beside the
786 # installers. Neither belongs on a release no updater may follow.
787 if [[ "${{ inputs.release_channel }}" != "preview" ]]; then
788 patterns+=("release/*.blockmap" "release/*.yml")
789 fi
790 for pattern in "${patterns[@]}"; do
791 for file in $pattern; do
792 cp "$file" release-publish/
793 done
794 done
795​
796 if [[ "${{ inputs.platform }}" == "mac" && "${{ inputs.arch }}" != "arm64" ]]; then
797 shopt -s nullglob
798 for manifest in release-publish/*-mac.yml; do
799 mv "$manifest" "${manifest%.yml}-${{ inputs.arch }}.yml"
800 done
801 fi
802​
803 # Windows updater metadata is channel-specific (for example
804 # "latest.yml" or "nightly.yml") and carries no arch, so the x64 and
805 # arm64 jobs would upload the same name. Suffix each per-arch copy;
806 # the release job merges them back into one manifest per channel.
807 # builder-debug.yml is electron-builder's config dump, not a feed.
808 if [[ "${{ inputs.platform }}" == "win" ]]; then
809 for manifest in release-publish/*.yml; do
810 [[ "$manifest" == */builder-debug.yml ]] && continue
811 mv "$manifest" "${manifest%.yml}-win-${{ inputs.arch }}.yml"
812 done
813 fi
814​
815 - name: Collect resource monitor
816 shell: bash
817 run: |
818 set -euo pipefail
819 binary_name="t3-resource-monitor"
820 if [[ "${{ inputs.platform }}" == "win" ]]; then
821 binary_name="${binary_name}.exe"
822 fi
823 source_path="native/resource-monitor/target/${{ inputs.rust_target }}/release/${binary_name}"
824 target_dir="resource-monitor-publish/${{ inputs.resource_key }}"
825 mkdir -p "$target_dir"
826 cp "$source_path" "$target_dir/$binary_name"
827​
828 - name: Upload build artifacts
829 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
830 with:
831 name: desktop-${{ inputs.platform }}-${{ inputs.arch }}
832 path: release-publish/*
833 if-no-files-found: error
834 # Installers, zips and blockmaps are already compressed; deflating them again saves under 0.3%.
835 compression-level: 0
836​
837 - name: Upload resource monitor
838 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
839 with:
840 name: resource-monitor-${{ inputs.resource_key }}
841 path: resource-monitor-publish/${{ inputs.resource_key }}/*
842 if-no-files-found: error
843​
844 verify:
845 name: Verify
846 needs: [build]
847 if: inputs.verify_runner != ''
848 runs-on: ${{ inputs.verify_runner }}
849 timeout-minutes: 15
850 steps:
851 - name: Download verification inputs
852 uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
853 with:
854 name: verify-${{ inputs.platform }}-${{ inputs.arch }}
855 path: verify
856​
857 - name: Download CLI archive
858 if: inputs.cli_archive
859 uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
860 with:
861 name: cli-${{ inputs.platform }}-${{ inputs.arch }}
862 path: release-cli
863​
864 - name: Verify packaged natives
865 shell: bash
866 run: |
867 set -euo pipefail
868 tar -C verify -xf verify/unpacked.tar
869 node verify/verify-windows-desktop-artifact.mjs \
870 --app-dir "$(cygpath -w "$PWD/verify/$UNPACKED_DIR")" \
871 --arch "${{ inputs.arch }}" --build-version "${{ inputs.version }}" --verbose
872​
873 - name: Smoke-test CLI archive
874 if: inputs.cli_archive
875 shell: bash
876 run: node verify/smoke-cli-archive.mjs --archive release-cli/* --expect-version "${{ inputs.version }}"
877​
878 # cargo-xwin links this binary on Linux, and nothing else runs it before users do.
879 # Node rather than pwsh: PowerShell on Windows ARM crashes intermittently
880 # (https://github.com/dotnet/runtime/issues/131636), which is why this
881 # target stopped building on Windows.
882 - name: Smoke-test resource monitor
883 shell: bash
884 env:
885 MONITOR: verify/${{ env.UNPACKED_DIR }}/resources/resource-monitor/t3-resource-monitor.exe
886 EXPECTED_ARCH: ${{ inputs.arch == 'arm64' && 'aarch64' || 'x86_64' }}
887 run: |
888 node --input-type=module <<'EOF'
889 import { spawn } from "node:child_process";
890 const monitor = spawn(process.env.MONITOR, { stdio: ["pipe", "pipe", "inherit"] });
891 monitor.stdout.setEncoding("utf8");
892 let output = "";
893 const exited = new Promise((resolve) => monitor.on("close", resolve));
894 const hello = await new Promise((resolve, reject) => {
895 monitor.on("error", reject);
896 monitor.on("close", () => reject(new Error(`Resource monitor exited before hello: ${output}`)));
897 monitor.stdout.on("data", (chunk) => {
898 output += chunk;
899 const end = output.indexOf("\n");
900 if (end !== -1) resolve(JSON.parse(output.slice(0, end)));
901 });
902 });
903 if (hello.type !== "hello" || hello.arch !== process.env.EXPECTED_ARCH) {
904 throw new Error(`Unexpected hello: ${JSON.stringify(hello)}`);
905 }
906 const send = (message) => monitor.stdin.write(`${JSON.stringify({ version: hello.version, ...message })}\n`);
907 send({ type: "configure", rootPid: process.pid, sampleIntervalMs: 1000 });
908 send({ type: "sampleNow", requestId: "verify" });
909 monitor.stdin.end();
910 const exitCode = await exited;
911 const sample = output
912 .split("\n")
913 .filter((line) => line.trim())
914 .map((line) => JSON.parse(line))
915 .find((message) => message.requestId === "verify");
916 if (exitCode !== 0 || sample?.type !== "snapshot" || !sample.processes.some((p) => p.pid === process.pid)) {
917 throw new Error(`Resource monitor exited ${exitCode} with: ${output}`);
918 }
919 console.log(`Resource monitor sampled ${sample.scannedProcessCount} processes.`);
920 EOF