Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions .github/workflows/fix-scripts.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Installs the previous stable desktop release, then runs the fix script and
# checks it moved the install to the newest one. The scripts only run during
# incidents, so this keeps them from rotting.
name: Fix scripts

on:
pull_request:
paths:
- scripts/fix.sh
- scripts/fix.ps1
- .github/workflows/fix-scripts.yml
workflow_dispatch:

permissions:
contents: read

jobs:
unix:
name: ${{ matrix.name }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- { name: macOS, runner: macos-15, kind: mac }
- { name: Linux AppImage, runner: ubuntu-24.04, kind: appimage }
- { name: Linux deb, runner: ubuntu-24.04, kind: deb }
env:
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v6

- name: Resolve versions
run: |
echo "NEWEST=$(gh release list -R pingdotgg/t3code --exclude-pre-releases --exclude-drafts -L 2 --json tagName -q '.[0].tagName' | sed 's/^v//')" >> "$GITHUB_ENV"
echo "PREVIOUS=$(gh release list -R pingdotgg/t3code --exclude-pre-releases --exclude-drafts -L 2 --json tagName -q '.[1].tagName' | sed 's/^v//')" >> "$GITHUB_ENV"

- name: Install the previous release
run: |
if [ "${{ matrix.kind }}" = deb ]; then
gh release download "v${PREVIOUS}" -R pingdotgg/t3code -p "T3-Code-${PREVIOUS}-amd64.deb" -D /tmp
sudo apt-get install -y "/tmp/T3-Code-${PREVIOUS}-amd64.deb"
else
T3CODE_VERSION="$PREVIOUS" sh scripts/fix.sh
fi

- name: Fix
run: sh scripts/fix.sh

- name: Check the newest release is installed
run: |
case "${{ matrix.kind }}" in
mac) installed="$(defaults read "/Applications/T3 Code (Beta).app/Contents/Info" CFBundleShortVersionString)" ;;
deb) installed="$(dpkg-query -W -f='${Version}' t3code)" ;;
appimage)
test ! -e "$HOME/Applications/T3-Code-${PREVIOUS}-x86_64.AppImage"
test -x "$HOME/Applications/T3-Code-${NEWEST}-x86_64.AppImage"
installed="$NEWEST"
;;
esac
echo "installed=$installed newest=$NEWEST previous=$PREVIOUS"
test "$installed" = "$NEWEST"

windows:
name: Windows
runs-on: windows-2025
timeout-minutes: 20
env:
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v6

- name: Resolve versions
shell: bash
run: |
echo "NEWEST=$(gh release list -R pingdotgg/t3code --exclude-pre-releases --exclude-drafts -L 2 --json tagName -q '.[0].tagName' | sed 's/^v//')" >> "$GITHUB_ENV"
echo "PREVIOUS=$(gh release list -R pingdotgg/t3code --exclude-pre-releases --exclude-drafts -L 2 --json tagName -q '.[1].tagName' | sed 's/^v//')" >> "$GITHUB_ENV"

# Windows PowerShell 5.1, the oldest host the script supports.
- name: Install the previous release
shell: powershell
run: |
$env:T3CODE_VERSION = $env:PREVIOUS
./scripts/fix.ps1

- name: Fix
shell: powershell
run: ./scripts/fix.ps1

- name: Check the newest release is installed
shell: powershell
run: |
$install = Get-ItemProperty "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*", "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*" -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName -like "T3 Code*" } | Select-Object -First 1
Write-Host "installed=$($install.DisplayVersion) newest=$env:NEWEST previous=$env:PREVIOUS"
if ($install.DisplayVersion -ne $env:NEWEST) { exit 1 }
2 changes: 2 additions & 0 deletions apps/marketing/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
/public/install.sh
/public/install.ps1
/public/fix.sh
/public/fix.ps1
2 changes: 1 addition & 1 deletion apps/marketing/scripts/stage-install-scripts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ const marketingDir = NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(imp
const repoRoot = NodePath.dirname(NodePath.dirname(marketingDir));
const publicDir = NodePath.join(marketingDir, "public");
NodeFS.mkdirSync(publicDir, { recursive: true });
for (const name of ["install.sh", "install.ps1"]) {
for (const name of ["install.sh", "install.ps1", "fix.sh", "fix.ps1"]) {
NodeFS.copyFileSync(NodePath.join(repoRoot, "scripts", name), NodePath.join(publicDir, name));
}
4 changes: 2 additions & 2 deletions apps/marketing/vercel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ export const config: VercelConfig = {
// curl | sh requires plain, uncompressed scripts with no cache across deploys.
headers: [
{
source: "/install.sh",
source: "/(install|fix).sh",
headers: [
{ key: "Content-Type", value: "text/x-shellscript; charset=utf-8" },
{ key: "Cache-Control", value: "public, max-age=300" },
],
},
{
source: "/install.ps1",
source: "/(install|fix).ps1",
headers: [
{ key: "Content-Type", value: "text/plain; charset=utf-8" },
{ key: "Cache-Control", value: "public, max-age=300" },
Expand Down
10 changes: 10 additions & 0 deletions docs/operations/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,16 @@ available.
- `electron-updater` reads `latest-mac.yml` on stable and `nightly-mac.yml` on nightly, for both Intel and Apple Silicon.
- The workflow merges the per-arch mac manifests into one channel-specific mac manifest before publishing the GitHub Release.

### Recovering from a broken release

A desktop build that crashes before its UI loads cannot install its own update.
Publish the fixed release first, then point users at `curl -fsSL https://t3.codes/fix.sh | sh`
(`irm https://t3.codes/fix.ps1 | iex` on Windows). The scripts (`scripts/fix.sh`, `scripts/fix.ps1`)
run no T3 Code code: they reinstall the newest release on each installed app's channel and
verify the download against the channel's `latest*.yml` or `nightly*.yml`. The marketing site
serves them, so changes to them go live with the next nightly. `.github/workflows/fix-scripts.yml`
exercises them against the two newest stable releases.

### Windows payload topology and update validation

Windows packages the bundled server and only its runtime-external/native
Expand Down
20 changes: 20 additions & 0 deletions docs/user/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,26 @@ The `.deb` updates itself like the other desktop builds. It asks for your
password to install each update. If your desktop has no password prompt, the
update fails. Download the new `.deb` and install it the same way.

### If the desktop app won't start

If T3 Code crashes or shows a blank window after an update, reinstall the newest
release from a terminal. Your threads and settings stay where they are.

```bash
curl -fsSL https://t3.codes/fix.sh | sh
```

On Windows, in PowerShell:

```powershell
irm https://t3.codes/fix.ps1 | iex
```

It quits T3 Code, reinstalls the newest release on the channel you already use
(stable or nightly), and opens it again. It asks only when it can't tell, such
as when both are installed. Set `T3CODE_CHANNEL=nightly` to switch channels.
Arch Linux packages update through your AUR helper instead.

### The `t3` command

The desktop app includes the `t3` command-line tool. To run it from any
Expand Down
134 changes: 134 additions & 0 deletions scripts/fix.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Reinstalls the T3 Code desktop app from the newest GitHub Release on Windows.
# It runs no T3 Code code, so it works however broken the installed app is.
# Needs only PowerShell 5.1+.
#
# irm https://t3.codes/fix.ps1 | iex
#
# Environment:
# T3CODE_CHANNEL stable or nightly (default: the installed app's
# channel, or stable when none is installed)
# T3CODE_VERSION exact version to install (overrides the newest release)
# T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub)
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

$repo = "pingdotgg/t3code"
$baseUrl = if ($env:T3CODE_RELEASE_BASE_URL) { $env:T3CODE_RELEASE_BASE_URL.TrimEnd("/") } else { "https://github.com/$repo/releases/download" }
$headers = @{ "User-Agent" = "t3-fix" }

function Fail([string] $message) {
Write-Error "t3 fix: $message"
exit 1
}
function Step([string] $message) { [Console]::Error.WriteLine(" $message") }

function Find-Install {
$keys = @(
"HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*",
"HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*",
"HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*"
)
Get-ItemProperty $keys -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName -like "T3 Code*" -and $_.InstallLocation } |
Select-Object -First 1
}

# PROCESSOR_ARCHITEW6432 bypasses WOW64 emulation.
$rawArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
$arch = switch ($rawArch) {
"AMD64" { "x64" }
"ARM64" { "arm64" }
default { Fail "unsupported architecture $rawArch" }
}

[Console]::Error.WriteLine("`n T3 Code fix: reinstalling the newest desktop app`n")

$install = Find-Install
$channel = $env:T3CODE_CHANNEL
if (-not $channel -and $env:T3CODE_VERSION) {
$channel = if ($env:T3CODE_VERSION -match "-nightly\.") { "nightly" } else { "stable" }
}
if (-not $channel -and $install) {
$channel = if ("$($install.DisplayName) $($install.DisplayVersion)" -match "nightly") { "nightly" } else { "stable" }
}
if (-not $channel) {
$channel = "stable"
if (-not [Console]::IsInputRedirected -and [Environment]::UserInteractive) {
do {
$answer = Read-Host " No T3 Code desktop app found. Install stable or nightly? [stable]"
if (-not $answer) { $answer = "stable" }
} until ($answer -eq "stable" -or $answer -eq "nightly")
$channel = $answer
}
}
if ($channel -ne "stable" -and $channel -ne "nightly") { Fail "T3CODE_CHANNEL must be stable or nightly" }

$version = $env:T3CODE_VERSION
if (-not $version) {
if ($channel -eq "stable") {
# release.yml marks only plain stable releases latest.
$release = Invoke-RestMethod -Uri "https://github.com/$repo/releases/latest" -Headers ($headers + @{ "Accept" = "application/json" })
$tag = ($release | Where-Object { $_.tag_name -match '^v\d+\.\d+\.\d+$' }).tag_name
} else {
$tag = $null
for ($page = 1; -not $tag -and $page -le 10; $page++) {
$releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases?per_page=100&page=$page" -Headers $headers
$tag = ($releases | Where-Object { -not $_.draft -and $_.tag_name -match '^v\d+\.\d+\.\d+-nightly\.\d+\.\d+$' } | Select-Object -First 1).tag_name
if (-not $releases) { break }
}
}
if (-not $tag) { Fail "could not find a $channel release" }
$version = $tag.Substring(1)
}

$work = Join-Path ([IO.Path]::GetTempPath()) ("t3-fix-" + [IO.Path]::GetRandomFileName())
New-Item -ItemType Directory -Path $work | Out-Null
try {
$asset = "T3-Code-$version-$arch.exe"
$manifest = if ($channel -eq "stable") { "latest.yml" } else { "nightly.yml" }
$installer = Join-Path $work $asset
Step "Downloading $asset..."
$manifestText = (Invoke-WebRequest -Uri "$baseUrl/v$version/$manifest" -Headers $headers -UseBasicParsing).Content
if ($manifestText -is [byte[]]) { $manifestText = [Text.Encoding]::UTF8.GetString($manifestText) }
Invoke-WebRequest -Uri "$baseUrl/v$version/$asset" -Headers $headers -OutFile $installer -UseBasicParsing

# electron-builder's update manifest records each file's base64 SHA-512.
$expected = $null
$url = $null
foreach ($line in $manifestText -split "`n") {
if ($line -match '^\s*-\s*url:\s*(\S+)') { $url = $Matches[1] }
elseif ($line -match '^\s*sha512:\s*(\S+)' -and $url -eq $asset) { $expected = $Matches[1]; break }
}
if (-not $expected) { Fail "$asset is not listed in $manifest" }
$stream = [IO.File]::OpenRead($installer)
try { $actual = [Convert]::ToBase64String([Security.Cryptography.SHA512]::Create().ComputeHash($stream)) }
finally { $stream.Dispose() }
if ($actual -ne $expected) { Fail "checksum mismatch for $asset" }

if ($install) {
$installDir = $install.InstallLocation.TrimEnd("\")
$running = @(Get-Process | Where-Object { $_.Path -and $_.Path.StartsWith("$installDir\", [StringComparison]::OrdinalIgnoreCase) })
if ($running.Count -gt 0) {
Step "Quitting $($install.DisplayName)..."
$running | ForEach-Object { $_.CloseMainWindow() | Out-Null }
$running | Wait-Process -Timeout 10 -ErrorAction SilentlyContinue
$running | Where-Object { -not $_.HasExited } | Stop-Process -Force -ErrorAction SilentlyContinue
$running | Wait-Process -Timeout 10 -ErrorAction SilentlyContinue
}
}

Step "Installing T3 Code $version..."
$process = Start-Process -FilePath $installer -ArgumentList "/S" -Wait -PassThru
if ($process.ExitCode -ne 0) { Fail "the installer exited with code $($process.ExitCode)" }
} finally {
Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue
}

$install = Find-Install
if ($install) {
$exe = Get-ChildItem -Path $install.InstallLocation -Filter "T3 Code*.exe" -File | Where-Object { $_.Name -notlike "Uninstall*" } | Select-Object -First 1
if ($exe) { Start-Process -FilePath $exe.FullName }
}
[Console]::Error.WriteLine(" Reinstalled T3 Code $version`n")
[Console]::Error.WriteLine(" If T3 Code is still broken, run 'npx t3@nightly unfuck' for a guided recovery, or share the logs in ~\.t3\userdata\logs on GitHub or Discord.`n")
Loading
Loading