diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a9754f9421b..a17e3bfc8bd 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -355,7 +355,7 @@ jobs:
- label: Linux x64
runner: blacksmith-32vcpu-ubuntu-2404
platform: linux
- target: AppImage
+ target: AppImage,deb,rpm
arch: x64
rust_target: x86_64-unknown-linux-gnu
resource_key: linux-x64
@@ -515,6 +515,20 @@ jobs:
Select-Object -Unique
"PSModulePath=$($modulePathEntries -join ';')" >> $env:GITHUB_ENV
+ # electron-builder's rpm target shells out to rpmbuild. The Ubuntu image
+ # ships it via the `rpm` package, but installing it here keeps the target
+ # working if the runner image ever drops it, and costs nothing when the
+ # binary is already present.
+ - name: Ensure rpmbuild is available
+ if: matrix.platform == 'linux' && contains(matrix.target, 'rpm')
+ shell: bash
+ run: |
+ if ! command -v rpmbuild >/dev/null 2>&1; then
+ sudo apt-get update
+ sudo apt-get install -y rpm
+ fi
+ rpmbuild --version
+
- name: Build desktop artifact
shell: bash
env:
@@ -608,6 +622,8 @@ jobs:
"release/*.dmg" \
"release/*.zip" \
"release/*.AppImage" \
+ "release/*.deb" \
+ "release/*.rpm" \
"release/*.exe" \
"release/*.blockmap" \
"release/*.yml"; do
@@ -830,6 +846,8 @@ jobs:
release-assets/*.dmg
release-assets/*.zip
release-assets/*.AppImage
+ release-assets/*.deb
+ release-assets/*.rpm
release-assets/*.exe
release-assets/*.blockmap
release-assets/*.yml
@@ -850,6 +868,8 @@ jobs:
release-assets/*.dmg
release-assets/*.zip
release-assets/*.AppImage
+ release-assets/*.deb
+ release-assets/*.rpm
release-assets/*.exe
release-assets/*.blockmap
release-assets/*.yml
diff --git a/apps/marketing/src/pages/download.astro b/apps/marketing/src/pages/download.astro
index 111482208cf..9912b6c8b2d 100644
--- a/apps/marketing/src/pages/download.astro
+++ b/apps/marketing/src/pages/download.astro
@@ -56,6 +56,10 @@ import { ANDROID_PLAY_STORE_URL, IOS_APP_STORE_URL } from "../lib/site";
x86_64
AppImage
+
+ Debian, Ubuntu (x86_64)
+ .deb
+
diff --git a/docs/internals/scripts.md b/docs/internals/scripts.md
index 2a020701064..3671dec484e 100644
--- a/docs/internals/scripts.md
+++ b/docs/internals/scripts.md
@@ -68,9 +68,16 @@ authenticated.
to the host, so this produces an arm64 DMG on Apple Silicon. Use `dist:desktop:dmg:arm64` or
`dist:desktop:dmg:x64`, or pass `--arch `, to force one.
- `vp run dist:desktop:linux`: Builds a Linux AppImage into `./release`.
+- `vp run dist:desktop:deb`: Builds a Debian/Ubuntu `.deb` into `./release`. Architecture defaults to
+ the host; `:arm64` and `:x64` variants exist.
+- `vp run dist:desktop:rpm`: Builds a Fedora/RHEL `.rpm` into `./release`. `:arm64` and `:x64`
+ variants exist. Needs `rpmbuild` on the host (`apt install rpm` on Debian/Ubuntu).
- `vp run dist:desktop:win`: Builds a Windows NSIS installer into `./release`. `:arm64` and `:x64`
variants exist.
+`--target` accepts a comma-separated list for Linux, so `--target AppImage,deb` emits both packages
+from one electron-builder run. That is what the release workflow uses.
+
### Desktop `.dmg` packaging notes
- Default build is unsigned/not notarized for local sharing.
diff --git a/docs/operations/release.md b/docs/operations/release.md
index 1d8768f59d0..33dbf794985 100644
--- a/docs/operations/release.md
+++ b/docs/operations/release.md
@@ -16,7 +16,7 @@ This document covers the unified release workflow for stable and nightly desktop
- Builds four artifacts in parallel for both channels:
- macOS `arm64` DMG
- macOS `x64` DMG
- - Linux `x64` AppImage
+ - Linux `x64` AppImage plus `.deb` and `.rpm` emitted by the same electron-builder run
- Windows `x64` NSIS installer
- Publishes one GitHub Release with all produced files.
- Stable tags with a suffix after `X.Y.Z` (for example `1.2.3-alpha.1`) are published as GitHub prereleases.
@@ -208,6 +208,8 @@ desktop-managed guidance when those environments are available.
- otherwise `GITHUB_REPOSITORY` from GitHub Actions.
- Required release assets for updater:
- platform installers (`.exe`, `.dmg`, `.AppImage`, plus macOS `.zip` for Squirrel.Mac update payloads)
+ - the Linux `.deb` and `.rpm` ship in the same release but are not updater payloads: in-app updates
+ on Linux stay AppImage-only, so those installs upgrade through `dpkg`/`apt` and `rpm`/`dnf`
- channel metadata: `latest*.yml` for stable releases, `nightly*.yml` for nightly releases
- `*.blockmap` files (used for differential downloads)
- macOS metadata note:
diff --git a/package.json b/package.json
index 839b8e79584..dfa7600257f 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,12 @@
"dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch arm64",
"dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch x64",
"dist:desktop:linux": "node scripts/build-desktop-artifact.ts --platform linux --target AppImage --arch x64",
+ "dist:desktop:deb": "node scripts/build-desktop-artifact.ts --platform linux --target deb",
+ "dist:desktop:deb:arm64": "node scripts/build-desktop-artifact.ts --platform linux --target deb --arch arm64",
+ "dist:desktop:deb:x64": "node scripts/build-desktop-artifact.ts --platform linux --target deb --arch x64",
+ "dist:desktop:rpm": "node scripts/build-desktop-artifact.ts --platform linux --target rpm",
+ "dist:desktop:rpm:arm64": "node scripts/build-desktop-artifact.ts --platform linux --target rpm --arch arm64",
+ "dist:desktop:rpm:x64": "node scripts/build-desktop-artifact.ts --platform linux --target rpm --arch x64",
"dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis",
"dist:desktop:win:arm64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch arm64",
"dist:desktop:win:x64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64",
diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts
index 7d2b7410a9e..babd96da624 100644
--- a/scripts/build-desktop-artifact.test.ts
+++ b/scripts/build-desktop-artifact.test.ts
@@ -13,9 +13,11 @@ import {
createStageWorkspaceConfig,
createStagePatchedDependencies,
createBuildConfig,
+ DEB_DEPENDENCIES,
DESKTOP_ELECTRON_LANGUAGES,
DESKTOP_FILE_EXCLUSIONS,
DESKTOP_EXTRA_RESOURCES,
+ DESKTOP_LINUX_MAINTAINER,
InvalidMacPasskeyRpDomainError,
InvalidMacPasskeyPublishableKeyError,
InvalidMockUpdateServerPortError,
@@ -34,7 +36,9 @@ import {
resolveDesktopProductName,
resolveDesktopUpdateChannel,
resolveDesktopWebAssetBrand,
+ resolveLinuxTargets,
resolveResourceMonitorRustTargets,
+ RPM_DEPENDENCIES,
resourceMonitorExecutableName,
resolveGitHubPublishConfig,
resolveMockUpdateServerPort,
@@ -361,6 +365,64 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
}).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))),
);
+ it("splits a comma-separated Linux target into one target per package", () => {
+ assert.deepStrictEqual(resolveLinuxTargets("AppImage"), ["AppImage"]);
+ assert.deepStrictEqual(resolveLinuxTargets("AppImage,deb"), ["AppImage", "deb"]);
+ assert.deepStrictEqual(resolveLinuxTargets(" AppImage , deb , rpm "), [
+ "AppImage",
+ "deb",
+ "rpm",
+ ]);
+ });
+
+ it.effect("packages a .deb alongside the AppImage from a single Linux build", () =>
+ Effect.gen(function* () {
+ const appImageOnly = yield* createBuildConfig(
+ "linux",
+ "AppImage",
+ "1.2.3",
+ false,
+ false,
+ undefined,
+ undefined,
+ );
+ const both = yield* createBuildConfig(
+ "linux",
+ "AppImage,deb",
+ "1.2.3",
+ false,
+ false,
+ undefined,
+ undefined,
+ );
+ const rpmOnly = yield* createBuildConfig(
+ "linux",
+ "rpm",
+ "1.2.3",
+ false,
+ false,
+ undefined,
+ undefined,
+ );
+
+ assert.deepStrictEqual((both.linux as Record).target, ["AppImage", "deb"]);
+ // fpm derives the maintainer from `author` otherwise, which carries no
+ // email address and fails the deb and rpm builds.
+ assert.equal((both.linux as Record).maintainer, DESKTOP_LINUX_MAINTAINER);
+ assert.match(DESKTOP_LINUX_MAINTAINER, /^.+ <[^@\s]+@[^@\s]+>$/u);
+
+ // Package-format sections only appear for the formats actually built.
+ assert.deepStrictEqual((both.deb as Record).depends, [...DEB_DEPENDENCIES]);
+ assert.notProperty(both, "rpm");
+ assert.deepStrictEqual((rpmOnly.rpm as Record).depends, [
+ ...RPM_DEPENDENCIES,
+ ]);
+ assert.notProperty(rpmOnly, "deb");
+ assert.notProperty(appImageOnly, "deb");
+ assert.notProperty(appImageOnly, "rpm");
+ }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))),
+ );
+
it.effect("preserves both Linux icon resize failures with structural context", () => {
const commands: Array<{ readonly command: string; readonly args: ReadonlyArray }> = [];
diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts
index a30b6d4a90a..387eb631331 100644
--- a/scripts/build-desktop-artifact.ts
+++ b/scripts/build-desktop-artifact.ts
@@ -38,6 +38,43 @@ const LINUX_ICON_SIZES = [16, 22, 24, 32, 48, 64, 128, 256, 512] as const;
const DESKTOP_APP_ID = "com.t3tools.t3code";
const APPLE_TEAM_ID_PATTERN = /^[A-Z0-9]{10}$/u;
+// The deb control file needs "Homepage:", and fpm derives "Maintainer:" from
+// `author` unless it is set explicitly — `author` carries no email address, so
+// the deb target fails without both. AppImage needs neither, which is why
+// nothing surfaced this before.
+export const DESKTOP_HOMEPAGE_URL = "https://github.com/pingdotgg/t3code";
+export const DESKTOP_LINUX_MAINTAINER = "T3 Tools ";
+
+// Electron's shared-library dependencies, declared per packaging format so the
+// package manager pulls them in instead of the app failing to start on a
+// minimal install.
+export const DEB_DEPENDENCIES = [
+ "libgtk-3-0",
+ "libnotify4",
+ "libnss3",
+ "libxss1",
+ "libxtst6",
+ "xdg-utils",
+ "libatspi2.0-0",
+ "libuuid1",
+ "libsecret-1-0",
+ "libasound2t64 | libasound2",
+] as const;
+
+export const RPM_DEPENDENCIES = [
+ "gtk3",
+ "libnotify",
+ "nss",
+ "libXScrnSaver",
+ "(libXtst or libXtst6)",
+ "xdg-utils",
+ "at-spi2-core",
+ "(libuuid or libuuid1)",
+ "alsa-lib",
+ "libsecret",
+ "mesa-libgbm",
+] as const;
+
const BuildPlatform = Schema.Literals(["mac", "linux", "win"]);
const BuildArch = Schema.Literals(["arm64", "x64", "universal"]);
@@ -616,6 +653,7 @@ interface StagePackageJson {
readonly private: true;
readonly packageManager: string;
readonly description: string;
+ readonly homepage: string;
readonly author: string;
readonly main: string;
readonly build: Record;
@@ -1521,6 +1559,19 @@ export function resolveDesktopProductName(version: string): string {
: (desktopPackageJson.productName ?? "T3 Code");
}
+/**
+ * Splits a comma-separated Linux target string, for example "AppImage,deb", so
+ * one electron-builder run emits several packages from the same staged app.
+ * artifactName's ${ext} keeps the outputs distinct, so there is no collision
+ * and no second matrix entry to upload under a duplicate artifact name.
+ */
+export function resolveLinuxTargets(target: string): ReadonlyArray {
+ return target
+ .split(",")
+ .map((entry) => entry.trim())
+ .filter((entry) => entry.length > 0);
+}
+
export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
platform: typeof BuildPlatform.Type,
target: string,
@@ -1584,11 +1635,17 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
}
if (platform === "linux") {
+ const linuxTargets = resolveLinuxTargets(target);
buildConfig.linux = {
- target: [target],
+ target: linuxTargets,
executableName: "t3code",
icon: "icons",
category: "Development",
+ // Required by the deb and rpm targets (FpmTarget), which otherwise derive
+ // the maintainer from author and fail because it carries no email.
+ maintainer: DESKTOP_LINUX_MAINTAINER,
+ vendor: "T3 Tools",
+ synopsis: "A desktop GUI for AI coding agents",
// electron-builder turns these into MimeType=x-scheme-handler/;
// in the .desktop entry (Exec already gets %U), so browsers can hand
// t3code:// OAuth callbacks to the app.
@@ -1604,6 +1661,14 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
},
},
};
+
+ if (linuxTargets.includes("deb")) {
+ buildConfig.deb = { depends: [...DEB_DEPENDENCIES] };
+ }
+
+ if (linuxTargets.includes("rpm")) {
+ buildConfig.rpm = { depends: [...RPM_DEPENDENCIES] };
+ }
}
if (platform === "win") {
@@ -1919,6 +1984,9 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
private: true,
packageManager: rootPackageJson.packageManager,
description: "T3 Code desktop build",
+ // Required by electron-builder's deb and rpm targets (FpmTarget), which
+ // read it from the staged app's metadata rather than the build config.
+ homepage: DESKTOP_HOMEPAGE_URL,
author: "T3 Tools",
main: "apps/desktop/dist-electron/main.cjs",
build: yield* createBuildConfig(