authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-24 23:08:35-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-24 23:08:35-07:00
logc13f7d93fff0f578886985a3f673b4b21b7f6ac8
tree4f53dec02735dd9bfae9c464477c3eb72d89511c
parent20f0ff1781fb02898a6fa5f97f76f36079cff0b8
signature Signed by PGP key B5690EEEBB952194

feat(release): ship a Linux .deb that updates itself (#13575)

The Linux release build now also makes a .deb in the same electron-builder run as the AppImage, and the release publishes it. electron-builder lists it in latest-linux.yml and writes resources/package-type into it, so the app updates a .deb install through electron-updater, which installs the new .deb with dpkg. The desktop updater now allows that path. The .deb uses xz in threaded mode (XZ_DEFAULTS=-T0): 113 MB in place of 141 MB with gzip. The Linux arm64 release job moves to a 16-vCPU Blacksmith arm64 runner. The download page, README, install guide, and release docs list the .deb. Based on community work in #4071, #5139, #4900, and #4887. Co-authored-by: chukfinley <chuk@chuk.dev> Co-authored-by: Primož Ajdišek <bigpod@bigpod.si> Co-authored-by: benthecarman <benthecarman@live.com> Co-authored-by: NaveDanan <nave0712@gmail.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

11 files changed, 124 insertions(+), 13 deletions(-)

.github/workflows/release-desktop.yml+1
...@@ -510,6 +510,7 @@ jobs:...@@ -510,6 +510,7 @@ jobs:
510 "release/*.dmg"510 "release/*.dmg"
511 "release/*.zip"511 "release/*.zip"
512 "release/*.AppImage"512 "release/*.AppImage"
513 "release/*.deb"
513 "release/*.exe"514 "release/*.exe"
514 )515 )
515 # Preview builds have no publish config, so electron-builder writes516 # Preview builds have no publish config, so electron-builder writes
.github/workflows/release.yml+2-1
...@@ -625,7 +625,7 @@ jobs:...@@ -625,7 +625,7 @@ jobs:
625 clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }}625 clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }}
626 relay_url: ${{ needs.relay_public_config.outputs.relay_url }}626 relay_url: ${{ needs.relay_public_config.outputs.relay_url }}
627 label: Linux arm64627 label: Linux arm64
628 runner: ubuntu-24.04-arm628 runner: blacksmith-16vcpu-ubuntu-2404-arm
629 platform: linux629 platform: linux
630 target: AppImage630 target: AppImage
631 arch: arm64631 arch: arm64
...@@ -875,6 +875,7 @@ jobs:...@@ -875,6 +875,7 @@ jobs:
875 echo 'release-assets/*.dmg'875 echo 'release-assets/*.dmg'
876 echo 'release-assets/*.zip'876 echo 'release-assets/*.zip'
877 echo 'release-assets/*.AppImage'877 echo 'release-assets/*.AppImage'
878 echo 'release-assets/*.deb'
878 echo 'release-assets/*.exe'879 echo 'release-assets/*.exe'
879 if [[ "${{ needs.preflight.outputs.release_channel }}" != "preview" ]]; then880 if [[ "${{ needs.preflight.outputs.release_channel }}" != "preview" ]]; then
880 echo 'release-assets/*.blockmap'881 echo 'release-assets/*.blockmap'
README.md+8
...@@ -54,6 +54,14 @@ winget install T3Tools.T3Code...@@ -54,6 +54,14 @@ winget install T3Tools.T3Code
54brew install --cask t3-code54brew install --cask t3-code
55```55```
5656
57#### Debian, Ubuntu (`.deb`)
58
59Download the `.deb` from [GitHub Releases](https://github.com/pingdotgg/t3code/releases), then:
60
61```bash
62sudo apt install ./T3-Code-*.deb
63```
64
57#### Arch Linux (AUR)65#### Arch Linux (AUR)
5866
59Stable:67Stable:
apps/desktop/src/updates/DesktopUpdates.test.ts+19
...@@ -87,6 +87,25 @@ describe("DesktopUpdates", () => {...@@ -87,6 +87,25 @@ describe("DesktopUpdates", () => {
87 }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));87 }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
88 });88 });
8989
90 it.effect("updates Linux .deb installs and leaves other non-AppImage installs off", () =>
91 Effect.gen(function* () {
92 const linuxState = (packageType: string | undefined) =>
93 Effect.scoped(
94 Effect.gen(function* () {
95 const updates = yield* DesktopUpdates.DesktopUpdates;
96 yield* updates.configure;
97 return yield* updates.getState;
98 }),
99 ).pipe(Effect.provide(makeHarness({ platform: "linux", packageType }).layer));
100
101 const deb = yield* linuxState("deb\n");
102 assert.equal(deb.status, "idle");
103
104 const unmarked = yield* linuxState(undefined);
105 assert.equal(unmarked.status, "disabled");
106 }),
107 );
108
90 it.effect("subscribe delivers the latest state plus subsequent changes", () => {109 it.effect("subscribe delivers the latest state plus subsequent changes", () => {
91 const harness = makeHarness();110 const harness = makeHarness();
92111
apps/desktop/src/updates/DesktopUpdates.ts+16-2
...@@ -250,6 +250,7 @@ function getAutoUpdateDisabledReason(args: {...@@ -250,6 +250,7 @@ function getAutoUpdateDisabledReason(args: {
250 isPackaged: boolean;250 isPackaged: boolean;
251 platform: NodeJS.Platform;251 platform: NodeJS.Platform;
252 appImage?: string | undefined;252 appImage?: string | undefined;
253 isDebPackage: boolean;
253 disabledByEnv: boolean;254 disabledByEnv: boolean;
254 hasUpdateFeedConfig: boolean;255 hasUpdateFeedConfig: boolean;
255}): string | null {256}): string | null {
...@@ -262,8 +263,8 @@ function getAutoUpdateDisabledReason(args: {...@@ -262,8 +263,8 @@ function getAutoUpdateDisabledReason(args: {
262 if (args.disabledByEnv) {263 if (args.disabledByEnv) {
263 return "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting.";264 return "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting.";
264 }265 }
265 if (args.platform === "linux" && !args.appImage) {266 if (args.platform === "linux" && !args.appImage && !args.isDebPackage) {
266 return "Automatic updates on Linux require running the AppImage build.";267 return "Automatic updates on Linux require the AppImage or the .deb package.";
267 }268 }
268 return null;269 return null;
269}270}
...@@ -332,6 +333,18 @@ export const make = Effect.gen(function* () {...@@ -332,6 +333,18 @@ export const make = Effect.gen(function* () {
332 ),333 ),
333 );334 );
334335
336 // The .deb carries electron-builder's resources/package-type marker.
337 // electron-updater reads the same file and installs updates with dpkg.
338 const isDebPackage =
339 environment.platform === "linux" && environment.isPackaged
340 ? yield* fileSystem
341 .readFileString(environment.path.join(environment.resourcesPath, "package-type"))
342 .pipe(
343 Effect.map((packageType) => packageType.trim() === "deb"),
344 Effect.orElseSucceed(() => false),
345 )
346 : false;
347
335 const hasUpdateFeedConfig = Ref.get(appUpdateYmlConfigRef).pipe(348 const hasUpdateFeedConfig = Ref.get(appUpdateYmlConfigRef).pipe(
336 Effect.map((appUpdateYmlConfig) => Option.isSome(appUpdateYmlConfig) || config.mockUpdates),349 Effect.map((appUpdateYmlConfig) => Option.isSome(appUpdateYmlConfig) || config.mockUpdates),
337 );350 );
...@@ -344,6 +357,7 @@ export const make = Effect.gen(function* () {...@@ -344,6 +357,7 @@ export const make = Effect.gen(function* () {
344 isPackaged: environment.isPackaged,357 isPackaged: environment.isPackaged,
345 platform: environment.platform,358 platform: environment.platform,
346 appImage: Option.getOrUndefined(config.appImagePath),359 appImage: Option.getOrUndefined(config.appImagePath),
360 isDebPackage,
347 disabledByEnv: config.disableAutoUpdate,361 disabledByEnv: config.disableAutoUpdate,
348 hasUpdateFeedConfig: hasFeedConfig,362 hasUpdateFeedConfig: hasFeedConfig,
349 }),363 }),
apps/desktop/src/updates/updatesTestHarness.ts+16-1
...@@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";...@@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
2import type { DesktopUpdateState } from "@t3tools/contracts";2import type { DesktopUpdateState } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";4import * as FileSystem from "effect/FileSystem";
5import * as PlatformError from "effect/PlatformError";
5import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";7import * as Option from "effect/Option";
78
...@@ -33,6 +34,9 @@ export interface UpdatesHarnessOptions {...@@ -33,6 +34,9 @@ export interface UpdatesHarnessOptions {
33 readonly stopBackend?: Effect.Effect<void>;34 readonly stopBackend?: Effect.Effect<void>;
34 readonly startBackend?: Effect.Effect<void>;35 readonly startBackend?: Effect.Effect<void>;
35 readonly env?: Record<string, string | undefined>;36 readonly env?: Record<string, string | undefined>;
37 readonly platform?: NodeJS.Platform;
38 /** Contents of the resources/package-type marker a Linux package ships. */
39 readonly packageType?: string | undefined;
36}40}
3741
38export function makeHarness(options: UpdatesHarnessOptions = {}) {42export function makeHarness(options: UpdatesHarnessOptions = {}) {
...@@ -146,7 +150,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {...@@ -146,7 +150,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
146 const environmentLayer = DesktopEnvironment.layer({150 const environmentLayer = DesktopEnvironment.layer({
147 dirname: "/repo/apps/desktop/src",151 dirname: "/repo/apps/desktop/src",
148 homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`,152 homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`,
149 platform: "darwin",153 platform: options.platform ?? "darwin",
150 processArch: "x64",154 processArch: "x64",
151 appVersion: "1.2.3",155 appVersion: "1.2.3",
152 appPath: "/repo",156 appPath: "/repo",
...@@ -208,6 +212,17 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {...@@ -208,6 +212,17 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
208 // disk I/O that would outrun the tests' settle loops.212 // disk I/O that would outrun the tests' settle loops.
209 const updateRestartMarkers = new Set<string>();213 const updateRestartMarkers = new Set<string>();
210 const fileSystemLayer = FileSystem.layerNoop({214 const fileSystemLayer = FileSystem.layerNoop({
215 readFileString: (path) =>
216 path === "/missing/resources/package-type" && options.packageType !== undefined
217 ? Effect.succeed(options.packageType)
218 : Effect.fail(
219 PlatformError.systemError({
220 module: "FileSystem",
221 method: "readFileString",
222 _tag: "NotFound",
223 pathOrDescriptor: path,
224 }),
225 ),
211 makeDirectory: () => Effect.void,226 makeDirectory: () => Effect.void,
212 writeFileString: (path) =>227 writeFileString: (path) =>
213 Effect.sync(() => {228 Effect.sync(() => {
apps/marketing/src/pages/download.astro+14
...@@ -91,6 +91,16 @@ const imageProps = {...@@ -91,6 +91,16 @@ const imageProps = {
91 <h2 class="platform-name">Linux</h2>91 <h2 class="platform-name">Linux</h2>
92 </div>92 </div>
93 <div class="download-cards">93 <div class="download-cards">
94 <a class="download-card" data-asset="amd64.deb" href={RELEASES_URL}>
95 <span class="card-icon">
96 <Image class="card-icon--stable" src={iconStable} width={28} height={28} loading="lazy" {...imageProps} />
97 <Image class="card-icon--nightly" src={iconNightly} width={28} height={28} loading="lazy" {...imageProps} />
98 </span>
99 <span class="card-text">
100 <span class="card-arch">Debian, Ubuntu</span>
101 <span class="card-format">.deb</span>
102 </span>
103 </a>
94 <a class="download-card" data-asset="x86_64.AppImage" href={RELEASES_URL}>104 <a class="download-card" data-asset="x86_64.AppImage" href={RELEASES_URL}>
95 <span class="card-icon">105 <span class="card-icon">
96 <Image class="card-icon--stable" src={iconStable} width={28} height={28} loading="lazy" {...imageProps} />106 <Image class="card-icon--stable" src={iconStable} width={28} height={28} loading="lazy" {...imageProps} />
...@@ -102,6 +112,10 @@ const imageProps = {...@@ -102,6 +112,10 @@ const imageProps = {
102 </span>112 </span>
103 </a>113 </a>
104 </div>114 </div>
115 <p class="intel-note">
116 On ARM? Download the arm64 <a data-asset="arm64.deb" href={RELEASES_URL}>.deb</a> or
117 <a data-asset="arm64.AppImage" href={RELEASES_URL}>AppImage</a>.
118 </p>
105 </section>119 </section>
106120
107 <!-- Mobile. Stable only, there is no nightly mobile build. -->121 <!-- Mobile. Stable only, there is no nightly mobile build. -->
docs/operations/release.md+2-2
...@@ -29,7 +29,7 @@ This document covers the unified release workflow for stable and nightly desktop...@@ -29,7 +29,7 @@ This document covers the unified release workflow for stable and nightly desktop
29- Builds six desktop artifacts in parallel for both channels, each as its own job (`desktop_<platform>_<arch>`, one call of `release-desktop.yml`) on hardware of its own architecture, gated only on the bundle (the Windows jobs also wait for the same-arch Linux job, whose CLI archive they embed as the WSL runtime):29- Builds six desktop artifacts in parallel for both channels, each as its own job (`desktop_<platform>_<arch>`, one call of `release-desktop.yml`) on hardware of its own architecture, gated only on the bundle (the Windows jobs also wait for the same-arch Linux job, whose CLI archive they embed as the WSL runtime):
30 - macOS `arm64` DMG30 - macOS `arm64` DMG
31 - macOS `x64` DMG31 - macOS `x64` DMG
32 - Linux `x64` and `arm64` AppImage32 - Linux `x64` and `arm64` AppImage and `.deb`, from one electron-builder run. The `.deb` updates in the app through electron-updater, which installs it with `dpkg`.
33 - Windows `x64` and `arm64` NSIS installer33 - Windows `x64` and `arm64` NSIS installer
34- Publishes one GitHub Release with all produced files.34- Publishes one GitHub Release with all produced files.
35 - Stable tags with a suffix after `X.Y.Z` (for example `1.2.3-alpha.1`) are published as GitHub prereleases.35 - Stable tags with a suffix after `X.Y.Z` (for example `1.2.3-alpha.1`) are published as GitHub prereleases.
...@@ -329,7 +329,7 @@ available....@@ -329,7 +329,7 @@ available.
329 - `T3CODE_DESKTOP_UPDATE_REPOSITORY` (format `owner/repo`), if set.329 - `T3CODE_DESKTOP_UPDATE_REPOSITORY` (format `owner/repo`), if set.
330 - otherwise `GITHUB_REPOSITORY` from GitHub Actions.330 - otherwise `GITHUB_REPOSITORY` from GitHub Actions.
331- Required release assets for updater:331- Required release assets for updater:
332 - platform installers (`.exe`, `.dmg`, `.AppImage`, plus macOS `.zip` for Squirrel.Mac update payloads)332 - platform installers (`.exe`, `.dmg`, `.AppImage`, `.deb`, plus macOS `.zip` for Squirrel.Mac update payloads)
333 - channel metadata: `latest*.yml` for stable releases, `nightly*.yml` for nightly releases333 - channel metadata: `latest*.yml` for stable releases, `nightly*.yml` for nightly releases
334 - `*.blockmap` files (used for differential downloads)334 - `*.blockmap` files (used for differential downloads)
335- macOS metadata note:335- macOS metadata note:
docs/user/install.md+11-6
...@@ -58,12 +58,17 @@ update it with `git pull` and a rebuild....@@ -58,12 +58,17 @@ update it with `git pull` and a rebuild.
58Download a release from [GitHub Releases](https://github.com/pingdotgg/t3code/releases),58Download a release from [GitHub Releases](https://github.com/pingdotgg/t3code/releases),
59or use a package manager:59or use a package manager:
6060
61| Platform | Install |61| Platform | Install |
62| ------------------ | ------------------------------- |62| ------------------ | ---------------------------------- |
63| Windows | `winget install T3Tools.T3Code` |63| Windows | `winget install T3Tools.T3Code` |
64| macOS | `brew install --cask t3-code` |64| macOS | `brew install --cask t3-code` |
65| Arch Linux | `yay -S t3code-bin` |65| Debian, Ubuntu | `sudo apt install ./T3-Code-*.deb` |
66| Arch Linux nightly | `yay -S t3code-nightly-bin` |66| Arch Linux | `yay -S t3code-bin` |
67| Arch Linux nightly | `yay -S t3code-nightly-bin` |
68
69The `.deb` updates itself like the other desktop builds. It asks for your
70password to install each update. If your desktop has no password prompt, the
71update fails. Download the new `.deb` and install it the same way.
6772
68### Windows Subsystem for Linux73### Windows Subsystem for Linux
6974
scripts/build-desktop-artifact.test.ts+2
...@@ -668,6 +668,8 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -668,6 +668,8 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
668 iconSize: 120,668 iconSize: 120,
669 iconTextSize: 12,669 iconTextSize: 12,
670 });670 });
671 // A Linux AppImage build also emits the .deb from the same run.
672 assert.deepStrictEqual((linux.linux as Record<string, unknown>).target, ["AppImage", "deb"]);
671 // Linux must register the renderer schemes so the generated .desktop673 // Linux must register the renderer schemes so the generated .desktop
672 // entry advertises MimeType=x-scheme-handler/t3code; for OAuth deep links.674 // entry advertises MimeType=x-scheme-handler/t3code; for OAuth deep links.
673 assert.deepStrictEqual((linux.linux as Record<string, unknown>).protocols, [675 assert.deepStrictEqual((linux.linux as Record<string, unknown>).protocols, [
scripts/build-desktop-artifact.ts+33-1
...@@ -929,6 +929,7 @@ interface StagePackageJson {...@@ -929,6 +929,7 @@ interface StagePackageJson {
929 readonly private: true;929 readonly private: true;
930 readonly packageManager: string;930 readonly packageManager: string;
931 readonly description: string;931 readonly description: string;
932 readonly homepage: string;
932 readonly author: string;933 readonly author: string;
933 readonly main: string;934 readonly main: string;
934 readonly build: Record<string, unknown>;935 readonly build: Record<string, unknown>;
...@@ -2733,10 +2734,17 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (...@@ -2733,10 +2734,17 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
27332734
2734 if (platform === "linux") {2735 if (platform === "linux") {
2735 buildConfig.linux = {2736 buildConfig.linux = {
2736 target: [target],2737 // The .deb is built from the same unpacked app after the AppImage.
2738 // electron-builder lists both in latest-linux.yml and writes
2739 // resources/package-type into the .deb only, so electron-updater updates
2740 // each install in its own format.
2741 target: target === "AppImage" ? [target, "deb"] : [target],
2737 executableName: "t3code",2742 executableName: "t3code",
2738 icon: "icons",2743 icon: "icons",
2739 category: "Development",2744 category: "Development",
2745 synopsis: "Desktop GUI for coding agents",
2746 // Required by the .deb control file.
2747 maintainer: "T3 Tools <hello@t3.codes>",
2740 // electron-builder turns these into MimeType=x-scheme-handler/<scheme>;2748 // electron-builder turns these into MimeType=x-scheme-handler/<scheme>;
2741 // in the .desktop entry (Exec already gets %U), so browsers can hand2749 // in the .desktop entry (Exec already gets %U), so browsers can hand
2742 // t3code:// OAuth callbacks to the app.2750 // t3code:// OAuth callbacks to the app.
...@@ -2752,6 +2760,23 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (...@@ -2752,6 +2760,23 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
2752 },2760 },
2753 },2761 },
2754 };2762 };
2763 buildConfig.deb = {
2764 // Electron's runtime libraries. Debian 13 and Ubuntu 24.04 renamed some
2765 // for 64-bit time; the old name is the fallback for older releases.
2766 depends: [
2767 "libasound2t64 | libasound2",
2768 "libatspi2.0-0t64 | libatspi2.0-0",
2769 "libgbm1",
2770 "libgtk-3-0t64 | libgtk-3-0",
2771 "libnotify4",
2772 "libnss3",
2773 "libsecret-1-0",
2774 "libuuid1",
2775 "libxss1",
2776 "libxtst6",
2777 "xdg-utils",
2778 ],
2779 };
2755 }2780 }
27562781
2757 if (platform === "win") {2782 if (platform === "win") {
...@@ -3642,6 +3667,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -3642,6 +3667,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
3642 private: true,3667 private: true,
3643 packageManager: rootPackageJson.packageManager,3668 packageManager: rootPackageJson.packageManager,
3644 description: "T3 Code desktop build",3669 description: "T3 Code desktop build",
3670 // Required by the .deb control file.
3671 homepage: "https://t3.codes",
3645 author: "T3 Tools",3672 author: "T3 Tools",
3646 main: "apps/desktop/dist-electron/main.cjs",3673 main: "apps/desktop/dist-electron/main.cjs",
3647 build: yield* createBuildConfig(3674 build: yield* createBuildConfig(
...@@ -3738,6 +3765,11 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -3738,6 +3765,11 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
3738 delete buildEnv[key];3765 delete buildEnv[key];
3739 }3766 }
3740 }3767 }
3768 if (options.platform === "linux") {
3769 // fpm compresses the .deb with the system xz through tar. Threaded mode
3770 // takes seconds on a many-core runner instead of about two minutes.
3771 buildEnv.XZ_DEFAULTS = "-T0";
3772 }
3741 if (!options.signed) {3773 if (!options.signed) {
3742 buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false";3774 buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false";
3743 delete buildEnv.CSC_LINK;3775 delete buildEnv.CSC_LINK;