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:
510510 "release/*.dmg"
511511 "release/*.zip"
512512 "release/*.AppImage"
513 "release/*.deb"
513514 "release/*.exe"
514515 )
515516 # Preview builds have no publish config, so electron-builder writes
.github/workflows/release.yml+2-1
......@@ -625,7 +625,7 @@ jobs:
625625 clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }}
626626 relay_url: ${{ needs.relay_public_config.outputs.relay_url }}
627627 label: Linux arm64
628 runner: ubuntu-24.04-arm
628 runner: blacksmith-16vcpu-ubuntu-2404-arm
629629 platform: linux
630630 target: AppImage
631631 arch: arm64
......@@ -875,6 +875,7 @@ jobs:
875875 echo 'release-assets/*.dmg'
876876 echo 'release-assets/*.zip'
877877 echo 'release-assets/*.AppImage'
878 echo 'release-assets/*.deb'
878879 echo 'release-assets/*.exe'
879880 if [[ "${{ needs.preflight.outputs.release_channel }}" != "preview" ]]; then
880881 echo 'release-assets/*.blockmap'
README.md+8
......@@ -54,6 +54,14 @@ winget install T3Tools.T3Code
5454brew install --cask t3-code
5555```
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
5765#### Arch Linux (AUR)
5866
5967Stable:
apps/desktop/src/updates/DesktopUpdates.test.ts+19
......@@ -87,6 +87,25 @@ describe("DesktopUpdates", () => {
8787 }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
8888 });
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
90109 it.effect("subscribe delivers the latest state plus subsequent changes", () => {
91110 const harness = makeHarness();
92111
apps/desktop/src/updates/DesktopUpdates.ts+16-2
......@@ -250,6 +250,7 @@ function getAutoUpdateDisabledReason(args: {
250250 isPackaged: boolean;
251251 platform: NodeJS.Platform;
252252 appImage?: string | undefined;
253 isDebPackage: boolean;
253254 disabledByEnv: boolean;
254255 hasUpdateFeedConfig: boolean;
255256}): string | null {
......@@ -262,8 +263,8 @@ function getAutoUpdateDisabledReason(args: {
262263 if (args.disabledByEnv) {
263264 return "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting.";
264265 }
265 if (args.platform === "linux" && !args.appImage) {
266 return "Automatic updates on Linux require running the AppImage build.";
266 if (args.platform === "linux" && !args.appImage && !args.isDebPackage) {
267 return "Automatic updates on Linux require the AppImage or the .deb package.";
267268 }
268269 return null;
269270}
......@@ -332,6 +333,18 @@ export const make = Effect.gen(function* () {
332333 ),
333334 );
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
335348 const hasUpdateFeedConfig = Ref.get(appUpdateYmlConfigRef).pipe(
336349 Effect.map((appUpdateYmlConfig) => Option.isSome(appUpdateYmlConfig) || config.mockUpdates),
337350 );
......@@ -344,6 +357,7 @@ export const make = Effect.gen(function* () {
344357 isPackaged: environment.isPackaged,
345358 platform: environment.platform,
346359 appImage: Option.getOrUndefined(config.appImagePath),
360 isDebPackage,
347361 disabledByEnv: config.disableAutoUpdate,
348362 hasUpdateFeedConfig: hasFeedConfig,
349363 }),
apps/desktop/src/updates/updatesTestHarness.ts+16-1
......@@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
22import type { DesktopUpdateState } from "@t3tools/contracts";
33import * as Effect from "effect/Effect";
44import * as FileSystem from "effect/FileSystem";
5import * as PlatformError from "effect/PlatformError";
56import * as Layer from "effect/Layer";
67import * as Option from "effect/Option";
78
......@@ -33,6 +34,9 @@ export interface UpdatesHarnessOptions {
3334 readonly stopBackend?: Effect.Effect<void>;
3435 readonly startBackend?: Effect.Effect<void>;
3536 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;
3640}
3741
3842export function makeHarness(options: UpdatesHarnessOptions = {}) {
......@@ -146,7 +150,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
146150 const environmentLayer = DesktopEnvironment.layer({
147151 dirname: "/repo/apps/desktop/src",
148152 homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`,
149 platform: "darwin",
153 platform: options.platform ?? "darwin",
150154 processArch: "x64",
151155 appVersion: "1.2.3",
152156 appPath: "/repo",
......@@ -208,6 +212,17 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
208212 // disk I/O that would outrun the tests' settle loops.
209213 const updateRestartMarkers = new Set<string>();
210214 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 ),
211226 makeDirectory: () => Effect.void,
212227 writeFileString: (path) =>
213228 Effect.sync(() => {
apps/marketing/src/pages/download.astro+14
......@@ -91,6 +91,16 @@ const imageProps = {
9191 <h2 class="platform-name">Linux</h2>
9292 </div>
9393 <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>
94104 <a class="download-card" data-asset="x86_64.AppImage" href={RELEASES_URL}>
95105 <span class="card-icon">
96106 <Image class="card-icon--stable" src={iconStable} width={28} height={28} loading="lazy" {...imageProps} />
......@@ -102,6 +112,10 @@ const imageProps = {
102112 </span>
103113 </a>
104114 </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>
105119 </section>
106120
107121 <!-- 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
2929- 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):
3030 - macOS `arm64` DMG
3131 - macOS `x64` DMG
32 - Linux `x64` and `arm64` AppImage
32 - 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`.
3333 - Windows `x64` and `arm64` NSIS installer
3434- Publishes one GitHub Release with all produced files.
3535 - 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.
329329 - `T3CODE_DESKTOP_UPDATE_REPOSITORY` (format `owner/repo`), if set.
330330 - otherwise `GITHUB_REPOSITORY` from GitHub Actions.
331331- 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)
333333 - channel metadata: `latest*.yml` for stable releases, `nightly*.yml` for nightly releases
334334 - `*.blockmap` files (used for differential downloads)
335335- macOS metadata note:
docs/user/install.md+11-6
......@@ -58,12 +58,17 @@ update it with `git pull` and a rebuild.
5858Download a release from [GitHub Releases](https://github.com/pingdotgg/t3code/releases),
5959or use a package manager:
6060
61| Platform | Install |
62| ------------------ | ------------------------------- |
63| Windows | `winget install T3Tools.T3Code` |
64| macOS | `brew install --cask t3-code` |
65| Arch Linux | `yay -S t3code-bin` |
66| Arch Linux nightly | `yay -S t3code-nightly-bin` |
61| Platform | Install |
62| ------------------ | ---------------------------------- |
63| Windows | `winget install T3Tools.T3Code` |
64| macOS | `brew install --cask t3-code` |
65| Debian, Ubuntu | `sudo apt install ./T3-Code-*.deb` |
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
6873### Windows Subsystem for Linux
6974
scripts/build-desktop-artifact.test.ts+2
......@@ -668,6 +668,8 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
668668 iconSize: 120,
669669 iconTextSize: 12,
670670 });
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"]);
671673 // Linux must register the renderer schemes so the generated .desktop
672674 // entry advertises MimeType=x-scheme-handler/t3code; for OAuth deep links.
673675 assert.deepStrictEqual((linux.linux as Record<string, unknown>).protocols, [
scripts/build-desktop-artifact.ts+33-1
......@@ -929,6 +929,7 @@ interface StagePackageJson {
929929 readonly private: true;
930930 readonly packageManager: string;
931931 readonly description: string;
932 readonly homepage: string;
932933 readonly author: string;
933934 readonly main: string;
934935 readonly build: Record<string, unknown>;
......@@ -2733,10 +2734,17 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
27332734
27342735 if (platform === "linux") {
27352736 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],
27372742 executableName: "t3code",
27382743 icon: "icons",
27392744 category: "Development",
2745 synopsis: "Desktop GUI for coding agents",
2746 // Required by the .deb control file.
2747 maintainer: "T3 Tools <hello@t3.codes>",
27402748 // electron-builder turns these into MimeType=x-scheme-handler/<scheme>;
27412749 // in the .desktop entry (Exec already gets %U), so browsers can hand
27422750 // t3code:// OAuth callbacks to the app.
......@@ -2752,6 +2760,23 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
27522760 },
27532761 },
27542762 };
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 };
27552780 }
27562781
27572782 if (platform === "win") {
......@@ -3642,6 +3667,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
36423667 private: true,
36433668 packageManager: rootPackageJson.packageManager,
36443669 description: "T3 Code desktop build",
3670 // Required by the .deb control file.
3671 homepage: "https://t3.codes",
36453672 author: "T3 Tools",
36463673 main: "apps/desktop/dist-electron/main.cjs",
36473674 build: yield* createBuildConfig(
......@@ -3738,6 +3765,11 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
37383765 delete buildEnv[key];
37393766 }
37403767 }
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 }
37413773 if (!options.signed) {
37423774 buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false";
37433775 delete buildEnv.CSC_LINK;