Skip to content

Release

Release #96

Workflow file for this run

name: Release
on:
push:
tags:
- "v*.*.*"
- "!v*-nightly.*"
schedule:
# Every three hours. The check_changes job skips the run when pylon has not
# moved since the last nightly tag, so idle slots cost one short job, and
# active days rebuild at most eight times. Runner minutes are free on this
# public repository, which is why the once-a-day throttle from 61ba5899e
# is gone; dispatch manually when a build is needed sooner than three hours.
# Minute 7 rather than 0: GitHub delays scheduled runs most at the top of
# the hour, where upstream measured a 13-minute median start delay.
- cron: "7 */3 * * *"
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
# Nightlies (scheduled and manually dispatched) share one group so overlapping
# runs cannot build the same commit twice or publish out of order. Every stable
# release keys off run_id instead, so it is always unique: a nightly never
# blocks a real release, and a hotfix tag never queues behind the stable release
# already in flight. Upstream collapses all stable runs into one shared lane,
# which would have delayed a P0 tag by the better part of a build matrix.
#
# Nothing is cancelled mid-flight. `publish_cli` pushes to npm before `release`
# creates the GitHub release, and a cancel in that window strands a published
# CLI version with no matching desktop build — and npm will not take that
# version number again. `queue: max` gives the nightly group 100 FIFO pending
# slots instead of the default newest-wins single slot, so a nightly waiting on
# its predecessor is never silently dropped; the ones with no new commits then
# skip via check_changes when their turn comes.
#
# This supersedes the cancel-on-schedule policy from 8dd0d6e06, which existed
# because a Blacksmith capacity outage let 23 nightlies stack up ~480 VM-hours
# deep waiting for runners that never arrived. 124630c3f moved every workflow
# onto GitHub-hosted runners, so that queue no longer forms.
concurrency:
group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly') && 'nightly' || github.run_id }}
cancel-in-progress: false
queue: max
permissions:
contents: read
id-token: none
jobs:
# Every job runs on GitHub-hosted standard runners, which are free on this
# public repository. Heavy jobs pin an OS version (`ubuntu-24.04`,
# `windows-2025`, `macos-26`) so a build cannot drift when `-latest` moves;
# the short I/O-bound orchestration jobs use `ubuntu-latest` because nothing
# in them is OS-sensitive.
check_changes:
name: Check for changes since last nightly
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
# Nothing cancels a wedged predecessor now that nightlies queue, and this
# was the only job in the file relying on GitHub's 360-minute default. A
# stuck checkout here would hold release-nightly across two cron slots.
timeout-minutes: 5
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: ubuntu-24.04
# 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
- 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
- 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
quality:
name: Release quality checks
needs: [preflight]
if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }}
runs-on: ubuntu-24.04
# `vp run test` runs the whole repository's suites serially here — the
# same work ci.yml spreads over four jobs. On a 4-vCPU GitHub-hosted
# runner the job lands around fifteen minutes (the Test step alone was
# still running at 7m44s when a 10-minute ceiling cut it on 2026-08-26,
# cancelling the first nightly after the runner migration), so give it
# 2x headroom. Blacksmith's 8 vCPU did the Test step in 3m16s.
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: true
- name: Ensure Electron runtime is installed
run: vp run --filter @t3tools/desktop ensure:electron
- name: Check
run: vp check
- name: Typecheck
run: vp run typecheck
- name: Test
run: vp run test
relay_public_config:
name: Resolve T3 Connect public config
# Consumes only the commit SHA, never preflight's resolved version, so it
# runs alongside preflight instead of waiting on it. The condition mirrors
# preflight's own: check_changes is skipped on non-schedule events, and a
# skipped job is neither failure nor success, so success() would be wrong.
needs: [check_changes]
if: |
!failure() && !cancelled() &&
(github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true')
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: ${{ github.sha }}
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"
# Non-fatal on purpose, matching the same reasoning as the upload in
# ci.yml. Actions storage is an org-wide quota that anything in the
# account can exhaust, and when it is full every upload in every repo
# fails. Connect is already optional here and the loader below degrades
# to a build without relay tracing, so a storage condition must not take
# down the release matrix — which is exactly what killed every nightly
# from 2026-08-11 onward.
- name: Upload relay client tracing config
uses: actions/upload-artifact@v7
continue-on-error: true
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)
# Same gating as relay_public_config: only the commit SHA is needed here,
# so this runs alongside preflight. See the condition comment there.
needs: [check_changes]
if: |
!failure() && !cancelled() &&
(github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true')
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
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: macos-26
platform: mac
target: dmg
arch: arm64
rust_target: aarch64-apple-darwin
resource_key: darwin-arm64
- label: macOS x64
runner: macos-26
platform: mac
target: dmg
arch: x64
rust_target: x86_64-apple-darwin
resource_key: darwin-x64
- label: Linux x64
runner: ubuntu-24.04
platform: linux
target: AppImage
arch: x64
rust_target: x86_64-unknown-linux-gnu
resource_key: linux-x64
- label: Windows x64
runner: 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: ${{ matrix.platform != 'win' }}
run-install: false
- name: Resolve Windows package cache path
if: matrix.platform == 'win'
id: package_cache_path
shell: pwsh
run: '"path=$(vp pm cache dir)" >> $env:GITHUB_OUTPUT'
- name: Cache Windows packages
if: matrix.platform == 'win'
uses: actions/cache@v6
with:
path: ${{ steps.package_cache_path.outputs.path }}
key: windows-release-packages-v1-${{ matrix.arch }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install desktop dependencies
run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts...
- name: Cache resource monitor
id: resource_monitor_cache
uses: actions/cache@v6
with:
path: native/resource-monitor/target/${{ matrix.rust_target }}/release/t3-resource-monitor${{ matrix.platform == 'win' && '.exe' || '' }}
key: resource-monitor-${{ matrix.rust_target }}-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }}
- name: Setup Rust
if: steps.resource_monitor_cache.outputs.cache-hit != 'true'
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust_target }}
- name: Download relay client tracing config
uses: actions/download-artifact@v8
continue-on-error: true
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 upload is best-effort against a shared storage quota, so the
# file can be missing entirely. Building without relay tracing is the
# correct way to degrade, but it is worth surfacing: a silently
# untraced production build should not look identical to a healthy one.
if [[ ! -f "$config_path" ]]; then
echo "::warning::Relay client tracing config artifact unavailable; building without Connect tracing."
exit 0
fi
# Present but carrying no variables when Connect is not configured at
# all, which is a supported setup rather than a problem.
if ! grep -q '^T3CODE_RELAY_CLIENT_OTLP_TRACES_' "$config_path"; 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 }}
T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }}
T3CODE_CLERK_PASSKEY_RP_DOMAINS: ${{ vars.CLERK_PASSKEY_RP_DOMAINS }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }}
AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }}
run: |
args=(
--platform "${{ matrix.platform }}"
--target "${{ matrix.target }}"
--arch "${{ matrix.arch }}"
--build-version "${{ needs.preflight.outputs.version }}"
--verbose
)
has_all() {
for value in "$@"; do
if [[ -z "$value" ]]; then
return 1
fi
done
return 0
}
if [[ "${{ matrix.platform }}" == "mac" ]]; then
if has_all "$CSC_LINK" "$CSC_KEY_PASSWORD" "$APPLE_API_KEY" "$APPLE_API_KEY_ID" "$APPLE_API_ISSUER"; then
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
# Consumed by the CLI publish job in this same run, so it only has to
# outlive the matrix. The old 7-day window was the one release
# artifact held above the minimum, and Actions storage is billed by
# GB-hour against a quota this repo already sits on top of.
retention-days: 1
publish_cli:
name: Publish CLI to npm
needs: [preflight, relay_public_config, quality, 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.quality.result == 'success' && needs.build.result == 'success' }}
runs-on: ubuntu-24.04
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
continue-on-error: true
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 upload is best-effort against a shared storage quota, so the
# file can be missing entirely. Building without relay tracing is the
# correct way to degrade, but it is worth surfacing: a silently
# untraced production build should not look identical to a healthy one.
if [[ ! -f "$config_path" ]]; then
echo "::warning::Relay client tracing config artifact unavailable; building without Connect tracing."
exit 0
fi
# Present but carrying no variables when Connect is not configured at
# all, which is a supported setup rather than a problem.
if ! grep -q '^T3CODE_RELAY_CLIENT_OTLP_TRACES_' "$config_path"; 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 }}"
# The t3 build task depends on @t3tools/web#build, so the web client is
# built (once) as part of this step.
- name: Build CLI package
run: vp run --filter t3 build
- 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, quality, 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.
# `quality` must stay in `needs`: it is the only job running check and test,
# and the CLI publish that also depends on it is opt-in, so without this the
# desktop release ships from a commit whose suites are red.
if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.quality.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
continue-on-error: true
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 upload is best-effort against a shared storage quota, so the
# file can be missing entirely. Building without relay tracing is the
# correct way to degrade, but it is worth surfacing: a silently
# untraced production build should not look identical to a healthy one.
if [[ ! -f "$config_path" ]]; then
echo "::warning::Relay client tracing config artifact unavailable; building without Connect tracing."
exit 0
fi
# Present but carrying no variables when Connect is not configured at
# all, which is a supported setup rather than a problem.
if ! grep -q '^T3CODE_RELAY_CLIENT_OTLP_TRACES_' "$config_path"; 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 }}"