diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a9754f9421b..5f5ccde3b55 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,19 @@ jobs:
Select-Object -Unique
"PSModulePath=$($modulePathEntries -join ';')" >> $env:GITHUB_ENV
+ # electron-builder shells out to rpmbuild for the rpm target. The Ubuntu
+ # image ships it in the `rpm` package, so this is a no-op there; installing
+ # it only when missing keeps the target working if the image ever drops it.
+ - 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 +621,8 @@ jobs:
"release/*.dmg" \
"release/*.zip" \
"release/*.AppImage" \
+ "release/*.deb" \
+ "release/*.rpm" \
"release/*.exe" \
"release/*.blockmap" \
"release/*.yml"; do
@@ -830,6 +845,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 +867,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/.gitignore b/.gitignore
index 07793efe9b5..badada5f270 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,6 +14,7 @@ build/
.logs/
release/
release-mock/
+packaging-output/
.t3
.idea/
apps/web/.playwright
diff --git a/apps/marketing/src/pages/download.astro b/apps/marketing/src/pages/download.astro
index 5557f5fb6b1..2c2d9143d97 100644
--- a/apps/marketing/src/pages/download.astro
+++ b/apps/marketing/src/pages/download.astro
@@ -56,6 +56,14 @@ import { ANDROID_PLAY_STORE_URL, IOS_APP_STORE_URL } from "../lib/site";
x86_64
AppImage
+
+ Debian, Ubuntu (x86_64)
+ .deb
+
+
+ Fedora, RHEL (x86_64)
+ .rpm
+
diff --git a/docs/internals/scripts.md b/docs/internals/scripts.md
index 9440115e1a9..2f6ad36d816 100644
--- a/docs/internals/scripts.md
+++ b/docs/internals/scripts.md
@@ -71,9 +71,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 `./packaging-output`. Architecture
+ defaults to the host; `:arm64` and `:x64` variants exist.
+- `vp run dist:desktop:rpm`: Builds a Fedora/RHEL `.rpm` into `./packaging-output`. `: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,rpm` emits all three
+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 3fc66d0dd02..11219333141 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,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 --output-dir packaging-output",
+ "dist:desktop:deb:arm64": "node scripts/build-desktop-artifact.ts --platform linux --target deb --arch arm64 --output-dir packaging-output",
+ "dist:desktop:deb:x64": "node scripts/build-desktop-artifact.ts --platform linux --target deb --arch x64 --output-dir packaging-output",
+ "dist:desktop:rpm": "node scripts/build-desktop-artifact.ts --platform linux --target rpm --output-dir packaging-output",
+ "dist:desktop:rpm:arm64": "node scripts/build-desktop-artifact.ts --platform linux --target rpm --arch arm64 --output-dir packaging-output",
+ "dist:desktop:rpm:x64": "node scripts/build-desktop-artifact.ts --platform linux --target rpm --arch x64 --output-dir packaging-output",
"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..80a917e8f31 100644
--- a/scripts/build-desktop-artifact.test.ts
+++ b/scripts/build-desktop-artifact.test.ts
@@ -34,6 +34,7 @@ import {
resolveDesktopProductName,
resolveDesktopUpdateChannel,
resolveDesktopWebAssetBrand,
+ resolveLinuxTargets,
resolveResourceMonitorRustTargets,
resourceMonitorExecutableName,
resolveGitHubPublishConfig,
@@ -552,6 +553,115 @@ 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,rpm"), ["AppImage", "deb", "rpm"]);
+ assert.deepStrictEqual(resolveLinuxTargets(" AppImage , deb "), ["AppImage", "deb"]);
+ });
+
+ it.effect("emits every requested Linux package from a single build", () =>
+ Effect.gen(function* () {
+ const all = yield* createBuildConfig(
+ "linux",
+ "AppImage,deb,rpm",
+ "1.2.3",
+ false,
+ false,
+ undefined,
+ undefined,
+ );
+ const appImageOnly = yield* createBuildConfig(
+ "linux",
+ "AppImage",
+ "1.2.3",
+ false,
+ false,
+ undefined,
+ undefined,
+ );
+
+ assert.deepStrictEqual((all.linux as Record).target, [
+ "AppImage",
+ "deb",
+ "rpm",
+ ]);
+ // Both fpm-backed formats are configured from the one run.
+ assert.property(all, "deb");
+ assert.property(all, "rpm");
+
+ // An AppImage-only build stays free of package-format sections.
+ assert.notProperty(appImageOnly, "deb");
+ assert.notProperty(appImageOnly, "rpm");
+ }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))),
+ );
+
+ it.effect("includes distribution metadata in Linux package builds", () =>
+ Effect.gen(function* () {
+ const config = yield* createBuildConfig(
+ "linux",
+ "deb",
+ "1.2.3",
+ false,
+ false,
+ undefined,
+ undefined,
+ );
+
+ const linux = config.linux as Record;
+ assert.deepStrictEqual(linux.target, ["deb"]);
+ assert.equal(linux.executableName, "t3code");
+ assert.equal(linux.category, "Development");
+ assert.equal(linux.maintainer, "T3 Tools ");
+ assert.equal(linux.vendor, "T3 Tools");
+ assert.equal(linux.synopsis, "A desktop GUI for AI coding agents");
+ assert.equal(linux.syncDesktopName, true);
+
+ const deb = config.deb as Record;
+ assert.deepStrictEqual(deb.depends, [
+ "libgtk-3-0",
+ "libnotify4",
+ "libnss3",
+ "libxss1",
+ "libxtst6",
+ "xdg-utils",
+ "libatspi2.0-0",
+ "libuuid1",
+ "libsecret-1-0",
+ "libasound2t64 | libasound2",
+ "libgbm1",
+ ]);
+ }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))),
+ );
+
+ it.effect("includes runtime dependencies in RPM packages", () =>
+ Effect.gen(function* () {
+ const config = yield* createBuildConfig(
+ "linux",
+ "rpm",
+ "1.2.3",
+ false,
+ false,
+ undefined,
+ undefined,
+ );
+
+ const rpm = config.rpm as Record;
+ assert.deepStrictEqual(rpm.depends, [
+ "gtk3",
+ "libnotify",
+ "nss",
+ "libXScrnSaver",
+ "(libXtst or libXtst6)",
+ "xdg-utils",
+ "at-spi2-core",
+ "(libuuid or libuuid1)",
+ "alsa-lib",
+ "libsecret",
+ "mesa-libgbm",
+ ]);
+ }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))),
+ );
+
it("stages the resource monitor as an external executable resource", () => {
assert.deepStrictEqual(DESKTOP_EXTRA_RESOURCES, [
{
diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts
index a30b6d4a90a..955727abb7a 100644
--- a/scripts/build-desktop-artifact.ts
+++ b/scripts/build-desktop-artifact.ts
@@ -132,6 +132,34 @@ const PLATFORM_CONFIG: Record = {
},
};
+const DEB_DEPENDENCIES = [
+ "libgtk-3-0",
+ "libnotify4",
+ "libnss3",
+ "libxss1",
+ "libxtst6",
+ "xdg-utils",
+ "libatspi2.0-0",
+ "libuuid1",
+ "libsecret-1-0",
+ "libasound2t64 | libasound2",
+ "libgbm1",
+] as const;
+
+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;
+
interface BuildCliInput {
readonly platform: Option.Option;
readonly target: Option.Option;
@@ -617,6 +645,9 @@ interface StagePackageJson {
readonly packageManager: string;
readonly description: string;
readonly author: string;
+ readonly homepage: string;
+ readonly license: string;
+ readonly desktopName: string;
readonly main: string;
readonly build: Record;
readonly dependencies: Record;
@@ -1521,6 +1552,23 @@ export function resolveDesktopProductName(version: string): string {
: (desktopPackageJson.productName ?? "T3 Code");
}
+/**
+ * Splits a comma-separated Linux target string, for example "AppImage,deb,rpm",
+ * so one electron-builder run emits every package from the same staged app.
+ *
+ * The alternative — one release-matrix entry per format — collides on the
+ * upload artifact name (`desktop-${platform}-${arch}` is identical for two
+ * entries that differ only by target), which is what sank #1655. artifactName's
+ * ${ext} already keeps the output files apart, so a single run needs no extra
+ * runner and no extra release wall-clock.
+ */
+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 +1632,16 @@ 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",
+ maintainer: "T3 Tools ",
+ vendor: "T3 Tools",
+ synopsis: "A desktop GUI for AI coding agents",
+ syncDesktopName: true,
// 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 +1657,18 @@ 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") {
@@ -1918,8 +1983,11 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
t3codeCommitHash: commitHash,
private: true,
packageManager: rootPackageJson.packageManager,
- description: "T3 Code desktop build",
- author: "T3 Tools",
+ description: "A desktop GUI for AI coding agents such as Codex, Claude, Cursor, and OpenCode.",
+ author: "T3 Tools ",
+ homepage: "https://github.com/pingdotgg/t3code",
+ license: "MIT",
+ desktopName: "t3code.desktop",
main: "apps/desktop/dist-electron/main.cjs",
build: yield* createBuildConfig(
options.platform,