Release #61
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | |
| on: | |
| push: | |
| tags: | |
| - "v*.*.*" | |
| - "!v*-nightly.*" | |
| schedule: | |
| # Once a day, which is what "nightly" means. The inherited every-three-hours | |
| # cadence suits a project with constant contributor traffic; here it just | |
| # rebuilds four platforms up to eight times on any day with a push, and | |
| # each rebuild is billable. Dispatch manually when a build is needed sooner. | |
| - cron: "0 9 * * *" | |
| workflow_dispatch: | |
| inputs: | |
| channel: | |
| description: "Release channel" | |
| required: false | |
| default: stable | |
| type: choice | |
| options: | |
| - stable | |
| - nightly | |
| version: | |
| description: "Release version (for example 1.2.3 or v1.2.3)" | |
| required: false | |
| type: string | |
| permissions: | |
| contents: read | |
| id-token: none | |
| jobs: | |
| # Runner split: the four platform builds and the preflight suite stay on | |
| # Blacksmith, where the compute is worth paying for. Everything else here is | |
| # short and I/O-bound — API calls, artifact uploads, a git push, a curl — and | |
| # those jobs repeatedly sat unassigned in Blacksmith's queue while heavier | |
| # jobs in the same run picked up immediately. GitHub-hosted runners took them | |
| # in seconds every time, so light orchestration runs there. | |
| check_changes: | |
| name: Check for changes since last nightly | |
| if: github.event_name == 'schedule' | |
| runs-on: ubuntu-latest | |
| outputs: | |
| has_changes: ${{ steps.check.outputs.has_changes }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| sparse-checkout: | | |
| /* | |
| !/.repos/ | |
| sparse-checkout-cone-mode: false | |
| - id: check | |
| name: Compare HEAD to last nightly tag | |
| run: | | |
| last_nightly_tag=$(git tag --list 'v*-nightly.*' 'nightly-v*' --sort=-creatordate | head -n 1) | |
| if [[ -z "$last_nightly_tag" ]]; then | |
| echo "No previous nightly tag found. Proceeding with release." | |
| echo "has_changes=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| last_nightly_sha=$(git rev-parse "$last_nightly_tag^{commit}") | |
| head_sha=$(git rev-parse HEAD) | |
| if [[ "$last_nightly_sha" == "$head_sha" ]]; then | |
| echo "No changes since last nightly release ($last_nightly_tag). Skipping." | |
| echo "has_changes=false" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "Changes detected since $last_nightly_tag ($last_nightly_sha → $head_sha). Proceeding." | |
| echo "has_changes=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| preflight: | |
| name: Preflight | |
| needs: [check_changes] | |
| if: | | |
| !failure() && !cancelled() && | |
| (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') | |
| runs-on: blacksmith-8vcpu-ubuntu-2404 | |
| # Runs the whole repo's check, typecheck, and test suites. | |
| timeout-minutes: 30 | |
| outputs: | |
| release_channel: ${{ steps.release_meta.outputs.release_channel }} | |
| version: ${{ steps.release_meta.outputs.version }} | |
| tag: ${{ steps.release_meta.outputs.tag }} | |
| release_name: ${{ steps.release_meta.outputs.name }} | |
| short_sha: ${{ steps.release_meta.outputs.short_sha }} | |
| previous_tag: ${{ steps.previous_tag.outputs.previous_tag }} | |
| cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }} | |
| is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }} | |
| make_latest: ${{ steps.release_meta.outputs.make_latest }} | |
| ref: ${{ github.sha }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| 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: true | |
| - name: Ensure Electron runtime is installed | |
| run: vp run --filter @t3tools/desktop ensure:electron | |
| - id: release_meta | |
| name: Resolve release version | |
| shell: bash | |
| env: | |
| DISPATCH_CHANNEL: ${{ github.event.inputs.channel }} | |
| DISPATCH_VERSION: ${{ github.event.inputs.version }} | |
| NIGHTLY_DATE: ${{ github.run_started_at }} | |
| NIGHTLY_SHA: ${{ github.sha }} | |
| NIGHTLY_RUN_NUMBER: ${{ github.run_number }} | |
| run: | | |
| if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then | |
| nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" | |
| node scripts/resolve-nightly-release.ts \ | |
| --date "$nightly_date" \ | |
| --run-number "$NIGHTLY_RUN_NUMBER" \ | |
| --sha "$NIGHTLY_SHA" \ | |
| --github-output | |
| echo "release_channel=nightly" >> "$GITHUB_OUTPUT" | |
| echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT" | |
| echo "is_prerelease=true" >> "$GITHUB_OUTPUT" | |
| echo "make_latest=false" >> "$GITHUB_OUTPUT" | |
| else | |
| if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then | |
| raw="${DISPATCH_VERSION}" | |
| if [[ -z "$raw" ]]; then | |
| echo "workflow_dispatch stable releases require the version input." >&2 | |
| exit 1 | |
| fi | |
| else | |
| raw="${GITHUB_REF_NAME}" | |
| fi | |
| version="${raw#v}" | |
| if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then | |
| echo "Invalid release version: $raw" >&2 | |
| exit 1 | |
| fi | |
| echo "release_channel=stable" >> "$GITHUB_OUTPUT" | |
| echo "version=$version" >> "$GITHUB_OUTPUT" | |
| echo "tag=v$version" >> "$GITHUB_OUTPUT" | |
| echo "name=Pylon v$version" >> "$GITHUB_OUTPUT" | |
| echo "cli_dist_tag=latest" >> "$GITHUB_OUTPUT" | |
| if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| echo "is_prerelease=false" >> "$GITHUB_OUTPUT" | |
| echo "make_latest=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "is_prerelease=true" >> "$GITHUB_OUTPUT" | |
| echo "make_latest=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| fi | |
| - name: Check | |
| run: vp check | |
| - name: Typecheck | |
| run: vp run typecheck | |
| - name: Test | |
| run: vp run test | |
| - id: previous_tag | |
| name: Resolve previous release tag | |
| run: | | |
| node scripts/resolve-previous-release-tag.ts \ | |
| --channel "${{ steps.release_meta.outputs.release_channel }}" \ | |
| --current-tag "${{ steps.release_meta.outputs.tag }}" \ | |
| --github-output | |
| relay_public_config: | |
| name: Resolve T3 Connect public config | |
| needs: preflight | |
| if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| environment: | |
| name: production | |
| outputs: | |
| clerk_publishable_key: ${{ steps.public_config.outputs.clerk_publishable_key }} | |
| clerk_jwt_template: ${{ steps.public_config.outputs.clerk_jwt_template }} | |
| clerk_cli_oauth_client_id: ${{ steps.public_config.outputs.clerk_cli_oauth_client_id }} | |
| relay_url: ${{ steps.public_config.outputs.relay_url }} | |
| env: | |
| CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} | |
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }} | |
| RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }} | |
| CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} | |
| CLERK_JWT_TEMPLATE: ${{ vars.CLERK_JWT_TEMPLATE }} | |
| CLERK_CLI_OAUTH_CLIENT_ID: ${{ vars.CLERK_CLI_OAUTH_CLIENT_ID }} | |
| 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=t3code-relay... | |
| - id: relay_state | |
| name: Read production relay tracing config | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| # Connect is optional. Without Cloudflare credentials there is no relay | |
| # to read state from, so emit an empty config rather than failing the | |
| # whole release: the desktop apps build and auto-update without it. | |
| if [[ -z "${CLOUDFLARE_API_TOKEN:-}" || -z "${CLOUDFLARE_ACCOUNT_ID:-}" ]]; then | |
| echo "Cloudflare credentials absent; building without Connect relay tracing." >&2 | |
| printf '# Connect is not configured; no relay client tracing.\n' \ | |
| > "$RUNNER_TEMP/relay-client-tracing.env" | |
| exit 0 | |
| fi | |
| vp run --filter t3code-relay deploy \ | |
| --stage prod \ | |
| --read-state \ | |
| --github-output \ | |
| --github-env-file "$RUNNER_TEMP/relay-client-tracing.env" | |
| - name: Upload relay client tracing config | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: relay-client-tracing-config | |
| path: ${{ runner.temp }}/relay-client-tracing.env | |
| if-no-files-found: error | |
| retention-days: 1 | |
| - id: public_config | |
| name: Resolve production relay public config | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| relay_domain="${RELAY_DOMAIN:-}" | |
| if [[ -z "$relay_domain" && -n "${RELAY_API_ZONE_NAME:-}" ]]; then | |
| relay_domain="relay.$RELAY_API_ZONE_NAME" | |
| fi | |
| # Connect is opt-in. A repository that has not configured Clerk and a | |
| # relay still produces working desktop builds; it just ships without | |
| # cloud sign-in. Partial configuration is treated as none, because | |
| # half-configured Connect fails at runtime in the user's app rather | |
| # than here, where the cause is still visible. | |
| required=( | |
| relay_domain | |
| CLERK_PUBLISHABLE_KEY | |
| CLERK_JWT_TEMPLATE | |
| CLERK_CLI_OAUTH_CLIENT_ID | |
| ) | |
| missing=() | |
| for name in "${required[@]}"; do | |
| if [[ -z "${!name:-}" ]]; then | |
| missing+=("$name") | |
| fi | |
| done | |
| if (( ${#missing[@]} > 0 )); then | |
| printf 'Connect is not configured (missing: %s); building without it.\n' "${missing[*]}" >&2 | |
| { | |
| echo "clerk_publishable_key=" | |
| echo "clerk_jwt_template=" | |
| echo "clerk_cli_oauth_client_id=" | |
| echo "relay_url=" | |
| } >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| echo "clerk_publishable_key=$CLERK_PUBLISHABLE_KEY" >> "$GITHUB_OUTPUT" | |
| echo "clerk_jwt_template=$CLERK_JWT_TEMPLATE" >> "$GITHUB_OUTPUT" | |
| echo "clerk_cli_oauth_client_id=$CLERK_CLI_OAUTH_CLIENT_ID" >> "$GITHUB_OUTPUT" | |
| echo "relay_url=https://$relay_domain" >> "$GITHUB_OUTPUT" | |
| # 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. | |
| build_wsl_node_pty: | |
| name: Build WSL node-pty (linux-x64) | |
| needs: [preflight] | |
| if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} | |
| runs-on: blacksmith-8vcpu-ubuntu-2404 | |
| timeout-minutes: 30 | |
| 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... | |
| - name: Build node-pty linux-x64 prebuild | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| # Resolve node-pty from apps/server (where it's a dependency) and build | |
| # its native binary from source for Linux. node-addon-api resolves from | |
| # node-pty's own dependency tree, so node-gyp has everything it needs. | |
| pty_pkg="$(node -e "console.log(require.resolve('node-pty/package.json', { paths: ['$GITHUB_WORKSPACE/apps/server'] }))")" | |
| pty_dir="$(dirname "$pty_pkg")" | |
| ( cd "$pty_dir" && npx --yes node-gyp rebuild ) | |
| mkdir -p wsl-prebuild | |
| cp "$pty_dir/build/Release/pty.node" wsl-prebuild/pty.node | |
| file wsl-prebuild/pty.node | |
| # Consumed by the Windows build in this same run, so it only needs to | |
| # outlive the run. Left unset it inherited the 90-day repository default. | |
| - name: Upload node-pty linux-x64 prebuild | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: wsl-node-pty-x64 | |
| path: wsl-prebuild/pty.node | |
| if-no-files-found: error | |
| retention-days: 1 | |
| build: | |
| name: Build ${{ matrix.label }} | |
| # build_wsl_node_pty stays in `needs` so it runs first and its artifact is | |
| # available to download, but only the Windows matrix entry consumes it. We | |
| # therefore gate the job on preflight + relay (must succeed) WITHOUT requiring | |
| # build_wsl_node_pty, so a failed Linux prebuild doesn't skip the macOS/Linux | |
| # builds. `!cancelled()` (not `!failure()`) lets the job run even when | |
| # build_wsl_node_pty failed; the Windows-only download step below then fails | |
| # that single platform if the prebuild is missing. | |
| needs: [preflight, relay_public_config, build_wsl_node_pty] | |
| if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} | |
| runs-on: ${{ matrix.runner }} | |
| # Electron packaging plus a Rust target; the macOS legs also cross-build | |
| # the second architecture. Generous on purpose: a ceiling costs nothing on | |
| # a fast runner, and a run that dies at the limit wastes the whole matrix. | |
| timeout-minutes: 90 | |
| 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: | |
| - label: macOS arm64 | |
| runner: blacksmith-6vcpu-macos-26 | |
| platform: mac | |
| target: dmg | |
| arch: arm64 | |
| rust_target: aarch64-apple-darwin | |
| resource_key: darwin-arm64 | |
| - label: macOS x64 | |
| runner: blacksmith-6vcpu-macos-26 | |
| platform: mac | |
| target: dmg | |
| arch: x64 | |
| rust_target: x86_64-apple-darwin | |
| resource_key: darwin-x64 | |
| - label: Linux x64 | |
| # GitHub-hosted deliberately. Every stuck job so far has been on a | |
| # blacksmith ubuntu runner, including this build sitting unassigned | |
| # for 43 minutes while the macOS and Windows jobs queued after it | |
| # ran and finished. Slower here, but it actually starts. | |
| runner: ubuntu-latest | |
| platform: linux | |
| target: AppImage | |
| arch: x64 | |
| rust_target: x86_64-unknown-linux-gnu | |
| resource_key: linux-x64 | |
| - label: Windows x64 | |
| runner: blacksmith-8vcpu-windows-2025 | |
| platform: win | |
| target: nsis | |
| arch: x64 | |
| rust_target: x86_64-pc-windows-msvc | |
| resource_key: win32-x64 | |
| # - 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: true | |
| run-install: | | |
| args: | |
| - --filter=@t3tools/desktop... | |
| - --filter=t3... | |
| - --filter=@t3tools/scripts... | |
| - name: Setup Rust | |
| 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" | |
| # The artifact is always uploaded so the download stays uniform, but | |
| # it carries no variables when Connect is not configured. | |
| if ! grep -q '^T3CODE_RELAY_CLIENT_OTLP_TRACES_' "$config_path" 2>/dev/null; then | |
| echo "No relay client tracing config; continuing without it." >&2 | |
| exit 0 | |
| fi | |
| tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" | |
| if [[ -n "$tracing_token" ]]; then | |
| echo "::add-mask::$tracing_token" | |
| fi | |
| cat "$config_path" >> "$GITHUB_ENV" | |
| - name: Align package versions to release version | |
| run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" | |
| - name: Download WSL node-pty prebuild | |
| if: matrix.platform == 'win' | |
| uses: actions/download-artifact@v7 | |
| with: | |
| name: wsl-node-pty-x64 | |
| path: wsl-prebuild | |
| - 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.Tools.x86.x64.Spectre", "--quiet", "--norestart" ` | |
| -Wait -PassThru -NoNewWindow | |
| if ($null -eq $proc -or $proc.ExitCode -ne 0) { | |
| $code = if ($null -ne $proc) { $proc.ExitCode } else { 1 } | |
| Write-Error "Visual Studio Installer failed with exit code $code" | |
| exit $code | |
| } | |
| - name: Install ImageMagick | |
| if: matrix.platform == 'linux' | |
| shell: bash | |
| run: | | |
| if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then | |
| sudo apt-get update | |
| 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: | |
| # Where installed apps look for updates. Must match wherever the | |
| # release job actually publishes, or builds go looking in the wrong | |
| # place forever. Falls back to this repository when unset. | |
| PYLON_DESKTOP_UPDATE_REPOSITORY: ${{ vars.PYLON_DESKTOP_UPDATE_REPOSITORY }} | |
| 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 | |
| key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" | |
| printf '%s' "$APPLE_API_KEY" > "$key_path" | |
| export APPLE_API_KEY="$key_path" | |
| # Passkeys are a separate, optional capability. Signing must not | |
| # depend on them: an unsigned macOS build cannot auto-update at | |
| # all, because Squirrel refuses to replace a bundle without a | |
| # valid signature — and it fails silently when it does. | |
| if has_all "$APPLE_TEAM_ID" "$MACOS_PROVISIONING_PROFILE"; then | |
| profile_path="$RUNNER_TEMP/pylon.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 passkey entitlements enabled." | |
| elif has_all "$APPLE_TEAM_ID" || has_all "$MACOS_PROVISIONING_PROFILE"; then | |
| echo "macOS passkey signing needs both APPLE_TEAM_ID and MACOS_PROVISIONING_PROFILE; only one is set." >&2 | |
| exit 1 | |
| else | |
| echo "macOS passkey entitlements disabled (no provisioning profile configured)." | |
| fi | |
| echo "macOS signing enabled." | |
| args+=(--signed) | |
| else | |
| echo "macOS signing disabled (missing one or more Apple signing secrets)." | |
| fi | |
| elif [[ "${{ matrix.platform }}" == "win" ]]; then | |
| # Bundle the Linux node-pty binary built by the build_wsl_node_pty job | |
| # so the packaged WSL backend ships a ready binary (no first-launch | |
| # compile). Required for a working WSL backend on Windows. | |
| args+=(--wsl-prebuild "$GITHUB_WORKSPACE/wsl-prebuild/pty.node") | |
| 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[@]}" | |
| - name: Collect release assets | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| mkdir -p release-publish | |
| shopt -s nullglob | |
| for pattern in \ | |
| "release/*.dmg" \ | |
| "release/*.zip" \ | |
| "release/*.AppImage" \ | |
| "release/*.exe" \ | |
| "release/*.blockmap" \ | |
| "release/*.yml"; 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" | |
| # These are intra-run handoff to `publish_release`, which downloads | |
| # `desktop-*` and attaches them to the GitHub Release — that Release is the | |
| # durable copy, not the artifact. Retention here has to be sized against | |
| # the org's included Actions storage, because overrunning it fails *every* | |
| # upload in the org, reddening unrelated PRs: the Team plan includes | |
| # 2 GB-month, and GitHub meters in GB-hours, so the budget is 2 * 730 = | |
| # 1460 GB-hours. At ~915 MB per run across the four platforms, a daily | |
| # nightly costs 0.915 * retention_days * 730 GB-hours. The repository | |
| # default of 90 days reached ~14 GB/day and exhausted the quota; 7 days | |
| # still bills ~4665 GB-hours, over triple the budget. One day costs ~666 | |
| # and leaves room for PR CI and tagged stable releases. Re-running a | |
| # failed publish job against an older build is not worth the outage — | |
| # re-dispatch the workflow instead. | |
| - name: Upload build artifacts | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: desktop-${{ matrix.platform }}-${{ matrix.arch }} | |
| path: release-publish/* | |
| if-no-files-found: error | |
| retention-days: 1 | |
| - 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 | |
| retention-days: 7 | |
| publish_cli: | |
| name: Publish CLI to npm | |
| needs: [preflight, relay_public_config, build] | |
| # Opt-in: publishing the CLI needs an npm package this repository owns and | |
| # trusted publishing configured for it. Set the PUBLISH_CLI_TO_NPM | |
| # repository variable to "true" once both exist. | |
| if: ${{ !failure() && !cancelled() && vars.PUBLISH_CLI_TO_NPM == 'true' && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build.result == 'success' }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| id-token: write | |
| 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/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" | |
| # The artifact is always uploaded so the download stays uniform, but | |
| # it carries no variables when Connect is not configured. | |
| if ! grep -q '^T3CODE_RELAY_CLIENT_OTLP_TRACES_' "$config_path" 2>/dev/null; then | |
| echo "No relay client tracing config; continuing without it." >&2 | |
| exit 0 | |
| fi | |
| tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" | |
| if [[ -n "$tracing_token" ]]; then | |
| echo "::add-mask::$tracing_token" | |
| fi | |
| cat "$config_path" >> "$GITHUB_ENV" | |
| - name: Align package versions to release version | |
| run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" | |
| - name: Build web package | |
| run: vp run --filter @t3tools/web build | |
| - name: Build CLI package | |
| run: vp run --filter t3 build | |
| - name: Download resource monitors | |
| uses: actions/download-artifact@v8 | |
| with: | |
| pattern: resource-monitor-* | |
| path: ${{ runner.temp }}/resource-monitors | |
| - name: Bundle resource monitors into CLI package | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| for artifact_dir in "$RUNNER_TEMP"/resource-monitors/resource-monitor-*; do | |
| resource_key="${artifact_dir##*/resource-monitor-}" | |
| target_dir="apps/server/dist/resource-monitor/${resource_key}" | |
| mkdir -p "$target_dir" | |
| cp "$artifact_dir"/t3-resource-monitor* "$target_dir/" | |
| chmod +x "$target_dir"/t3-resource-monitor 2>/dev/null || true | |
| done | |
| - name: Publish CLI package | |
| run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose | |
| release: | |
| name: Publish GitHub Release | |
| needs: [preflight, build, publish_cli] | |
| # The desktop release is the point of this workflow, so a skipped CLI | |
| # publish must not withhold it. A failed one still does. | |
| if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' && (needs.publish_cli.result == 'success' || needs.publish_cli.result == 'skipped') }} | |
| runs-on: ubuntu-latest | |
| # Uploading the desktop artifacts regularly outruns 10 minutes, and it does | |
| # so after a 90-minute build (pingdotgg/t3code#6034). | |
| timeout-minutes: 30 | |
| permissions: | |
| contents: write | |
| 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=@t3tools/scripts... | |
| - name: Download all desktop artifacts | |
| uses: actions/download-artifact@v8 | |
| with: | |
| pattern: desktop-* | |
| merge-multiple: true | |
| path: release-assets | |
| - name: Merge macOS updater manifests | |
| run: | | |
| shopt -s nullglob | |
| for x64_manifest in release-assets/*-mac-x64.yml; do | |
| arm64_manifest="${x64_manifest%-x64.yml}.yml" | |
| if [[ -f "$arm64_manifest" ]]; then | |
| node scripts/merge-update-manifests.ts --platform mac "$arm64_manifest" "$x64_manifest" | |
| rm -f "$x64_manifest" | |
| fi | |
| done | |
| # - name: Merge Windows updater manifests | |
| # run: | | |
| # shopt -s nullglob | |
| # found_windows_manifest=false | |
| # for x64_manifest in release-assets/*-win-x64.yml; do | |
| # if [[ "$(basename "$x64_manifest")" == builder-debug-* ]]; then | |
| # continue | |
| # fi | |
| # arm64_manifest="${x64_manifest/-x64.yml/-arm64.yml}" | |
| # output_manifest="${x64_manifest/-win-x64.yml/.yml}" | |
| # if [[ ! -f "$arm64_manifest" ]]; then | |
| # echo "Missing matching arm64 Windows manifest for $x64_manifest" >&2 | |
| # exit 1 | |
| # fi | |
| # found_windows_manifest=true | |
| # node scripts/merge-update-manifests.ts --platform win \ | |
| # "$arm64_manifest" \ | |
| # "$x64_manifest" \ | |
| # "$output_manifest" | |
| # rm -f "$arm64_manifest" "$x64_manifest" | |
| # done | |
| # if [[ "$found_windows_manifest" != true ]]; then | |
| # echo "No Windows updater manifests found to merge." >&2 | |
| # exit 1 | |
| # fi | |
| # Same-repository publication. Used when no separate releases repository | |
| # is configured, and the only path that can generate release notes: | |
| # note generation compares tags, which only exist alongside the source. | |
| - name: Publish release | |
| if: vars.PYLON_DESKTOP_UPDATE_REPOSITORY == '' && needs.preflight.outputs.previous_tag != '' | |
| uses: softprops/action-gh-release@v3 | |
| with: | |
| tag_name: ${{ needs.preflight.outputs.tag }} | |
| target_commitish: ${{ needs.preflight.outputs.ref }} | |
| name: ${{ needs.preflight.outputs.release_name }} | |
| generate_release_notes: true | |
| previous_tag: ${{ needs.preflight.outputs.previous_tag }} | |
| prerelease: ${{ needs.preflight.outputs.is_prerelease }} | |
| make_latest: ${{ needs.preflight.outputs.make_latest }} | |
| files: | | |
| release-assets/*.dmg | |
| release-assets/*.zip | |
| release-assets/*.AppImage | |
| release-assets/*.exe | |
| release-assets/*.blockmap | |
| release-assets/*.yml | |
| fail_on_unmatched_files: true | |
| token: ${{ github.token }} | |
| - name: Publish first release | |
| if: vars.PYLON_DESKTOP_UPDATE_REPOSITORY == '' && needs.preflight.outputs.previous_tag == '' | |
| uses: softprops/action-gh-release@v3 | |
| with: | |
| tag_name: ${{ needs.preflight.outputs.tag }} | |
| target_commitish: ${{ needs.preflight.outputs.ref }} | |
| name: ${{ needs.preflight.outputs.release_name }} | |
| generate_release_notes: true | |
| prerelease: ${{ needs.preflight.outputs.is_prerelease }} | |
| make_latest: ${{ needs.preflight.outputs.make_latest }} | |
| files: | | |
| release-assets/*.dmg | |
| release-assets/*.zip | |
| release-assets/*.AppImage | |
| release-assets/*.exe | |
| release-assets/*.blockmap | |
| release-assets/*.yml | |
| fail_on_unmatched_files: true | |
| token: ${{ github.token }} | |
| # Cross-repository publication. Auto-update reads release assets over the | |
| # public GitHub API, which cannot see a private repository's releases, so | |
| # the artifacts are published to a public repository instead. | |
| # | |
| # Release notes are not generated here: the tag and the commit history | |
| # they would be derived from live in the source repository, not this one. | |
| # Generating them would also republish private commit subjects in public. | |
| - name: Check releases repository credentials | |
| if: vars.PYLON_DESKTOP_UPDATE_REPOSITORY != '' | |
| shell: bash | |
| env: | |
| RELEASES_REPO_TOKEN: ${{ secrets.RELEASES_REPO_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| if [[ -z "${RELEASES_REPO_TOKEN:-}" ]]; then | |
| echo "PYLON_DESKTOP_UPDATE_REPOSITORY is set to '${{ vars.PYLON_DESKTOP_UPDATE_REPOSITORY }}' but the RELEASES_REPO_TOKEN secret is missing." >&2 | |
| echo "Builds already embed that repository as their update feed, so publishing anywhere else would strand them." >&2 | |
| exit 1 | |
| fi | |
| - name: Publish release to the releases repository | |
| if: vars.PYLON_DESKTOP_UPDATE_REPOSITORY != '' | |
| uses: softprops/action-gh-release@v3 | |
| with: | |
| repository: ${{ vars.PYLON_DESKTOP_UPDATE_REPOSITORY }} | |
| tag_name: ${{ needs.preflight.outputs.tag }} | |
| name: ${{ needs.preflight.outputs.release_name }} | |
| body: | | |
| Pylon ${{ needs.preflight.outputs.version }} (${{ needs.preflight.outputs.release_channel }}), built from `${{ needs.preflight.outputs.short_sha || needs.preflight.outputs.ref }}`. | |
| Download the file for your platform below. Installed apps on this | |
| channel update themselves. | |
| prerelease: ${{ needs.preflight.outputs.is_prerelease }} | |
| make_latest: ${{ needs.preflight.outputs.make_latest }} | |
| files: | | |
| release-assets/*.dmg | |
| release-assets/*.zip | |
| release-assets/*.AppImage | |
| release-assets/*.exe | |
| release-assets/*.blockmap | |
| release-assets/*.yml | |
| fail_on_unmatched_files: true | |
| token: ${{ secrets.RELEASES_REPO_TOKEN }} | |
| # Cross-repository publication creates the tag in the releases repository, | |
| # so this one never gains it. The nightly change check looks for those | |
| # tags here to decide whether anything has changed, finds none, and | |
| # therefore rebuilds all four platforms on every scheduled run forever — | |
| # even when the commit is identical to the last release. Recording the tag | |
| # restores that check. | |
| - name: Record the release tag in this repository | |
| if: vars.PYLON_DESKTOP_UPDATE_REPOSITORY != '' | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RELEASE_TAG: ${{ needs.preflight.outputs.tag }} | |
| RELEASE_SHA: ${{ needs.preflight.outputs.ref }} | |
| run: | | |
| set -euo pipefail | |
| if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" >/dev/null 2>&1; then | |
| echo "Tag $RELEASE_TAG already recorded." | |
| exit 0 | |
| fi | |
| gh api -X POST "repos/$GITHUB_REPOSITORY/git/refs" \ | |
| -f "ref=refs/tags/$RELEASE_TAG" \ | |
| -f "sha=$RELEASE_SHA" >/dev/null | |
| echo "Recorded $RELEASE_TAG at $RELEASE_SHA." | |
| deploy_web: | |
| name: Deploy hosted web app | |
| needs: [preflight, relay_public_config, release] | |
| # Opt-in: the hosted web app needs a Vercel project and its domains. Set | |
| # the DEPLOY_HOSTED_WEB repository variable to "true" once they exist. | |
| if: ${{ !failure() && !cancelled() && vars.DEPLOY_HOSTED_WEB == 'true' && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.release.result == 'success' }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| 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 }} | |
| VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} | |
| VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} | |
| VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} | |
| T3CODE_WEB_ROUTER_URL: ${{ vars.T3CODE_WEB_ROUTER_URL }} | |
| T3CODE_WEB_LATEST_DOMAIN: ${{ vars.T3CODE_WEB_LATEST_DOMAIN }} | |
| T3CODE_WEB_NIGHTLY_DOMAIN: ${{ vars.T3CODE_WEB_NIGHTLY_DOMAIN }} | |
| VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} | |
| 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=@t3tools/scripts... | |
| - --filter=@t3tools/web... | |
| - 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" | |
| # The artifact is always uploaded so the download stays uniform, but | |
| # it carries no variables when Connect is not configured. | |
| if ! grep -q '^T3CODE_RELAY_CLIENT_OTLP_TRACES_' "$config_path" 2>/dev/null; then | |
| echo "No relay client tracing config; continuing without it." >&2 | |
| exit 0 | |
| fi | |
| tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" | |
| if [[ -n "$tracing_token" ]]; then | |
| echo "::add-mask::$tracing_token" | |
| fi | |
| cat "$config_path" >> "$GITHUB_ENV" | |
| - name: Align package versions to release version | |
| run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" | |
| - name: Refresh release lockfile | |
| run: vp install --lockfile-only --ignore-scripts | |
| - name: Deploy and alias channel | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if [[ -z "${VERCEL_TOKEN:-}" || -z "${VERCEL_ORG_ID:-}" || -z "${VERCEL_PROJECT_ID:-}" ]]; then | |
| echo "Missing one or more required Vercel secrets: VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID." >&2 | |
| exit 1 | |
| fi | |
| # Defaults must match the routing hosts in apps/web/vercel.ts. | |
| router_url="${T3CODE_WEB_ROUTER_URL:-https://app.pylon-code.com}" | |
| latest_domain="${T3CODE_WEB_LATEST_DOMAIN:-latest.pylon-code.com}" | |
| nightly_domain="${T3CODE_WEB_NIGHTLY_DOMAIN:-nightly.pylon-code.com}" | |
| router_domain="${router_url#http://}" | |
| router_domain="${router_domain#https://}" | |
| router_domain="${router_domain%%/*}" | |
| if [[ "${{ needs.preflight.outputs.release_channel }}" == "stable" ]]; then | |
| channel_domain="$latest_domain" | |
| channel_name="latest" | |
| else | |
| channel_domain="$nightly_domain" | |
| channel_name="nightly" | |
| fi | |
| vercel_scope="${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}" | |
| vercel_scope_args=(--scope "$vercel_scope") | |
| echo "Deploying hosted web app for $channel_name channel." | |
| deployment_url="$( | |
| vp dlx vercel@53.1.1 deploy \ | |
| --archive=tgz \ | |
| --prod \ | |
| --skip-domain \ | |
| --yes \ | |
| --token "$VERCEL_TOKEN" \ | |
| "${vercel_scope_args[@]}" \ | |
| --build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \ | |
| --build-env "T3CODE_CLERK_PUBLISHABLE_KEY=${T3CODE_CLERK_PUBLISHABLE_KEY:-}" \ | |
| --build-env "T3CODE_CLERK_JWT_TEMPLATE=${T3CODE_CLERK_JWT_TEMPLATE:-}" \ | |
| --build-env "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=${T3CODE_CLERK_CLI_OAUTH_CLIENT_ID:-}" \ | |
| --build-env "T3CODE_RELAY_URL=${T3CODE_RELAY_URL:-}" \ | |
| --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=${T3CODE_RELAY_CLIENT_OTLP_TRACES_URL:-}" \ | |
| --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=${T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET:-}" \ | |
| --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=${T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN:-}" \ | |
| --build-env "VITE_HOSTED_APP_URL=$router_url" \ | |
| --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name" | |
| )" | |
| # `vercel alias set` resolves the target against account-level domains | |
| # and reports "You don't have access to the domain" for a domain that | |
| # is attached to the project instead — which is how these are held. | |
| # The REST alias endpoint accepts project domains directly. | |
| alias_deployment() { | |
| local url="${1#https://}" | |
| local domain="$2" | |
| local dep_id | |
| dep_id="$( | |
| curl -sS --fail-with-body \ | |
| -H "Authorization: Bearer $VERCEL_TOKEN" \ | |
| "https://api.vercel.com/v13/deployments/${url}?teamId=${VERCEL_ORG_ID}" | | |
| node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{const j=JSON.parse(s);if(!j.id){console.error(j.error?.message??"deployment id missing");process.exit(1)}process.stdout.write(j.id)})' | |
| )" | |
| echo "Aliasing $url ($dep_id) to $domain." | |
| curl -sS --fail-with-body -X POST \ | |
| -H "Authorization: Bearer $VERCEL_TOKEN" \ | |
| -H "Content-Type: application/json" \ | |
| -d "{\"alias\":\"${domain}\"}" \ | |
| "https://api.vercel.com/v2/deployments/${dep_id}/aliases?teamId=${VERCEL_ORG_ID}" | | |
| node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{const j=JSON.parse(s);if(j.error){console.error("alias failed:",j.error.message??j.error.code);process.exit(1)}console.log("aliased ->",j.alias)})' | |
| } | |
| alias_deployment "$deployment_url" "$channel_domain" | |
| if [[ "$channel_name" == "latest" && -n "$router_domain" && "$router_domain" != "$channel_domain" ]]; then | |
| alias_deployment "$deployment_url" "$router_domain" | |
| fi | |
| finalize: | |
| name: Finalize release | |
| # Opt-in: pushes a version-bump commit back to the product branch through a | |
| # GitHub App. Set the FINALIZE_RELEASE_COMMIT repository variable to "true" | |
| # once the app is installed and its credentials are stored. | |
| if: ${{ !failure() && !cancelled() && vars.FINALIZE_RELEASE_COMMIT == 'true' && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'stable' }} | |
| needs: [preflight, release] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - id: app_token | |
| name: Mint release app token | |
| uses: actions/create-github-app-token@v2 | |
| with: | |
| app-id: ${{ secrets.RELEASE_APP_ID }} | |
| private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} | |
| owner: ${{ github.repository_owner }} | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| # Pylon's product branch. The inherited `main` is not it. | |
| ref: pylon | |
| fetch-depth: 0 | |
| token: ${{ steps.app_token.outputs.token }} | |
| persist-credentials: true | |
| sparse-checkout: | | |
| /* | |
| !/.repos/ | |
| sparse-checkout-cone-mode: false | |
| - id: app_bot | |
| name: Resolve GitHub App bot identity | |
| env: | |
| GH_TOKEN: ${{ steps.app_token.outputs.token }} | |
| APP_SLUG: ${{ steps.app_token.outputs.app-slug }} | |
| run: | | |
| user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)" | |
| echo "name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT" | |
| echo "email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT" | |
| - name: Setup Vite+ | |
| uses: voidzero-dev/setup-vp@v1 | |
| with: | |
| node-version-file: package.json | |
| cache: true | |
| run-install: | | |
| args: | |
| - --filter=@t3tools/scripts... | |
| - --filter=@t3tools/oxlint-plugin-t3code... | |
| - id: update_versions | |
| name: Update version strings | |
| env: | |
| RELEASE_VERSION: ${{ needs.preflight.outputs.version }} | |
| run: node scripts/update-release-package-versions.ts "$RELEASE_VERSION" --github-output | |
| - name: Format package.json files | |
| if: steps.update_versions.outputs.changed == 'true' | |
| run: vp fmt apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json | |
| - name: Refresh lockfile | |
| if: steps.update_versions.outputs.changed == 'true' | |
| run: vp install --lockfile-only --ignore-scripts | |
| - name: Commit and push version bump | |
| if: steps.update_versions.outputs.changed == 'true' | |
| shell: bash | |
| env: | |
| RELEASE_TAG: ${{ needs.preflight.outputs.tag }} | |
| run: | | |
| if git diff --quiet -- apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml; then | |
| echo "No version changes to commit." | |
| exit 0 | |
| fi | |
| git config user.name "${{ steps.app_bot.outputs.name }}" | |
| git config user.email "${{ steps.app_bot.outputs.email }}" | |
| git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml | |
| git commit -m "chore(release): prepare $RELEASE_TAG" | |
| git push origin HEAD:pylon | |
| announce_discord: | |
| name: Announce release on Discord | |
| if: | | |
| always() && !cancelled() && | |
| needs.preflight.result == 'success' && | |
| needs.relay_public_config.result == 'success' && | |
| needs.release.result == 'success' && | |
| vars.ANNOUNCE_RELEASE_ON_DISCORD == 'true' && | |
| (needs.deploy_web.result == 'success' || needs.deploy_web.result == 'skipped') && | |
| (needs.finalize.result == 'success' || needs.finalize.result == 'skipped') | |
| needs: [preflight, relay_public_config, release, deploy_web, finalize] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| 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=@t3tools/scripts... | |
| - name: Announce prerelease on Discord | |
| if: needs.preflight.outputs.is_prerelease == 'true' | |
| continue-on-error: true | |
| env: | |
| DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_RELEASE_NIGHTLY_ROLE_ID }} | |
| DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} | |
| run: | | |
| node scripts/notify-discord-release.ts prerelease \ | |
| --role-id "$DISCORD_MENTION_ROLE_ID" \ | |
| --release-name "${{ needs.preflight.outputs.release_name }}" \ | |
| --release-version "${{ needs.preflight.outputs.version }}" \ | |
| --tag "${{ needs.preflight.outputs.tag }}" \ | |
| --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}" | |
| - name: Announce latest release on Discord | |
| if: needs.preflight.outputs.make_latest == 'true' | |
| continue-on-error: true | |
| env: | |
| DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_RELEASE_LATEST_ROLE_ID }} | |
| DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} | |
| run: | | |
| node scripts/notify-discord-release.ts latest \ | |
| --role-id "$DISCORD_MENTION_ROLE_ID" \ | |
| --release-name "${{ needs.preflight.outputs.release_name }}" \ | |
| --release-version "${{ needs.preflight.outputs.version }}" \ | |
| --tag "${{ needs.preflight.outputs.tag }}" \ | |
| --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}" |