Skip to content

Release Desktop

Release Desktop #262

Workflow file for this run

name: Release Desktop
on:
push:
tags:
- "v*.*.*"
- "!v*-nightly.*"
schedule:
- cron: "0 */3 * * *"
workflow_dispatch:
inputs:
version:
description: "Release version (for example 1.2.3 or v1.2.3)"
required: true
type: string
permissions:
contents: write
id-token: none
env:
T3CODE_RELEASE_REPOSITORY: aaditagrawal/t3code
T3CODE_DESKTOP_UPDATE_REPOSITORY: aaditagrawal/t3code
jobs:
check_changes:
name: Check for changes since last nightly
runs-on: ubuntu-24.04
outputs:
has_changes: ${{ steps.check.outputs.has_changes }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- id: check
name: Compare HEAD to last nightly tag
run: |
if [[ "${GITHUB_EVENT_NAME}" != "schedule" ]]; then
echo "Manual or tag release. Proceeding without nightly change detection."
echo "has_changes=true" >> "$GITHUB_OUTPUT"
exit 0
fi
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 on main since last nightly release ($last_nightly_tag). Skipping."
echo "has_changes=false" >> "$GITHUB_OUTPUT"
else
echo "Changes detected on main 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
timeout-minutes: 10
outputs:
version: ${{ steps.release_meta.outputs.version }}
tag: ${{ steps.release_meta.outputs.tag }}
release_name: ${{ steps.release_meta.outputs.release_name }}
release_channel: ${{ steps.release_meta.outputs.release_channel }}
release_repository: ${{ steps.release_meta.outputs.release_repository }}
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
- id: release_meta
name: Resolve release version
shell: bash
run: |
release_repository="${T3CODE_RELEASE_REPOSITORY:?T3CODE_RELEASE_REPOSITORY is required}"
desktop_update_repository="${T3CODE_DESKTOP_UPDATE_REPOSITORY:?T3CODE_DESKTOP_UPDATE_REPOSITORY is required}"
for repository in "$release_repository" "$desktop_update_repository"; do
if [[ ! "$repository" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
echo "Invalid release repository: $repository" >&2
exit 1
fi
done
if [[ "$GITHUB_REPOSITORY" != "$release_repository" || "$GITHUB_REPOSITORY" != "$desktop_update_repository" ]]; then
echo "This fork release workflow is configured for release repository $release_repository and desktop updater repository $desktop_update_repository, but it is running in $GITHUB_REPOSITORY." >&2
exit 1
fi
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
raw="${{ github.event.inputs.version }}"
elif [[ "${GITHUB_EVENT_NAME}" == "schedule" ]]; then
# Nightly: derive a unique prerelease version from the current
# package.json base and today's date + run counter so every
# scheduled run produces a valid semver like
# `0.0.21-nightly.20260420.3`.
base_version=$(node -e "console.log(require('./apps/desktop/package.json').version)")
base_version="${base_version%%-*}"
date_stamp=$(date -u +%Y%m%d)
raw="${base_version}-nightly.${date_stamp}.${GITHUB_RUN_NUMBER}"
else
raw="${GITHUB_REF_NAME}"
fi
version="${raw#v}"
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ && ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-nightly\.[0-9]{8}\.[0-9]+$ ]]; then
echo "Invalid release version: $raw" >&2
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "tag=v$version" >> "$GITHUB_OUTPUT"
echo "release_repository=$release_repository" >> "$GITHUB_OUTPUT"
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "release_name=T3 Code v$version" >> "$GITHUB_OUTPUT"
echo "release_channel=stable" >> "$GITHUB_OUTPUT"
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
echo "make_latest=true" >> "$GITHUB_OUTPUT"
elif [[ "$version" =~ -nightly\.[0-9]{8}\.[0-9]+$ ]]; then
echo "release_name=T3 Code Nightly $version (${GITHUB_SHA::12})" >> "$GITHUB_OUTPUT"
echo "release_channel=nightly" >> "$GITHUB_OUTPUT"
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
echo "make_latest=false" >> "$GITHUB_OUTPUT"
else
echo "Unsupported prerelease version: $version" >&2
exit 1
fi
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: package.json
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Lint
run: bun run lint
- name: Typecheck
run: bun run typecheck
- name: Test
run: bun run test
build:
name: Build ${{ matrix.label }}
needs: preflight
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- label: macOS arm64
runner: macos-14
platform: mac
target: dmg
arch: arm64
- label: macOS x64
runner: macos-15-intel
platform: mac
target: dmg
arch: x64
- label: Linux x64
runner: ubuntu-24.04
platform: linux
target: AppImage
arch: x64
- label: Windows x64
runner: windows-2022
platform: win
target: nsis
arch: x64
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preflight.outputs.ref }}
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: package.json
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Align package versions to release version
run: bun run scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"
- 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:
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 }}
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"
echo "macOS signing enabled."
args+=(--signed)
else
echo "macOS signing disabled (missing one or more Apple signing secrets)."
fi
elif [[ "${{ matrix.platform }}" == "win" ]]; then
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
bun 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/latest*.yml" \
"release/nightly*.yml"; do
for file in $pattern; do
cp "$file" release-publish/
done
done
if [[ "${{ matrix.platform }}" == "mac" && "${{ matrix.arch }}" != "arm64" ]]; then
for channel in latest nightly; do
if [[ -f "release-publish/${channel}-mac.yml" ]]; then
mv "release-publish/${channel}-mac.yml" "release-publish/${channel}-mac-${{ matrix.arch }}.yml"
fi
done
fi
- name: Upload build artifacts
uses: actions/upload-artifact@v7
with:
name: desktop-${{ matrix.platform }}-${{ matrix.arch }}
path: release-publish/*
if-no-files-found: error
release:
name: Publish GitHub Release
needs: [preflight, build]
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: write
id-token: none
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preflight.outputs.ref }}
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: package.json
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
registry-url: https://registry.npmjs.org
- name: Install dependencies
run: bun install --frozen-lockfile
- 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: |
set -euo pipefail
shopt -s nullglob
found_mac_manifest=false
for x64_manifest in release-assets/*-mac-x64.yml; do
arm64_manifest="${x64_manifest/-x64.yml/.yml}"
if [[ ! -f "$arm64_manifest" ]]; then
echo "Missing matching arm64 macOS manifest for $x64_manifest" >&2
exit 1
fi
found_mac_manifest=true
bun run scripts/merge-update-manifests.ts --platform mac \
"$arm64_manifest" \
"$x64_manifest"
rm -f "$x64_manifest"
done
if [[ "$found_mac_manifest" != true ]]; then
echo "No macOS updater manifests found to merge." >&2
exit 1
fi
- name: Publish release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.preflight.outputs.tag }}
target_commitish: ${{ needs.preflight.outputs.ref }}
name: ${{ needs.preflight.outputs.release_name }}
repository: ${{ needs.preflight.outputs.release_repository }}
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
deploy_web:
name: Deploy hosted web app
needs: [preflight, release]
# Fork: enable once this fork has its own hosted app deployment configured.
if: false
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 10
env:
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 }}
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: package.json
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Install release tooling dependencies
run: bun install --frozen-lockfile --filter=@t3tools/scripts --filter=@t3tools/web
- name: Align package versions to release version
run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"
- name: Refresh release lockfile
run: bun 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
router_url="${T3CODE_WEB_ROUTER_URL:-https://app.t3.codes}"
latest_domain="${T3CODE_WEB_LATEST_DOMAIN:-latest.app.t3.codes}"
nightly_domain="${T3CODE_WEB_NIGHTLY_DOMAIN:-nightly.app.t3.codes}"
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="$(
bunx vercel@53.1.1 deploy \
--prod \
--skip-domain \
--yes \
--token "$VERCEL_TOKEN" \
"${vercel_scope_args[@]}" \
--build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \
--build-env "VITE_HOSTED_APP_URL=$router_url" \
--build-env "VITE_HOSTED_APP_CHANNEL=$channel_name"
)"
echo "Aliasing $deployment_url to $channel_domain."
bunx vercel@53.1.1 alias set "$deployment_url" "$channel_domain" \
--token "$VERCEL_TOKEN" \
"${vercel_scope_args[@]}"
if [[ "$channel_name" == "latest" && -n "$router_domain" && "$router_domain" != "$channel_domain" ]]; then
echo "Aliasing $deployment_url to router domain $router_domain."
bunx vercel@53.1.1 alias set "$deployment_url" "$router_domain" \
--token "$VERCEL_TOKEN" \
"${vercel_scope_args[@]}"
fi
finalize:
name: Finalize release
needs: [preflight, release]
# secrets context is not available in job-level `if`. Use an env/output
# from a prior job, or simply always run and let the token step fail-fast.
if: false # Fork: enable once RELEASE_APP_ID secret is configured
runs-on: ubuntu-24.04
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:
ref: main
fetch-depth: 0
token: ${{ steps.app_token.outputs.token }}
persist-credentials: true
- 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 Bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: package.json
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
- id: update_versions
name: Update version strings
env:
RELEASE_VERSION: ${{ needs.preflight.outputs.version }}
run: bun run scripts/update-release-package-versions.ts "$RELEASE_VERSION" --github-output
- name: Format package.json files
if: steps.update_versions.outputs.changed == 'true'
run: bunx oxfmt 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: bun 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 bun.lock; 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 bun.lock
git commit -m "chore(release): prepare $RELEASE_TAG"
git push origin HEAD:main