From ce3ca8d6175ce7384b69f706a7ad214ca0d5109f Mon Sep 17 00:00:00 2001 From: chukfinley Date: Fri, 17 Jul 2026 05:56:55 +0200 Subject: [PATCH 1/3] feat(release): publish Linux .deb alongside AppImage The desktop build script already accepts `--target deb`; the target string is passed through to electron-builder untouched and the artifact copy step is target-agnostic. A deb build fails only because app-builder-lib's FpmTarget requires two metadata fields the staged package.json omits: `homepage` (control "Homepage:") and `maintainer` (control "Maintainer:", otherwise derived from `author`, which carries no email). AppImage needs neither, so nothing surfaced this. Rather than add a second Linux matrix entry -- whose upload artifact name `desktop-linux-x64` would collide with the AppImage entry's -- let the existing Linux entry emit both targets from one electron-builder run. `--target` now takes a comma-separated list, and artifactName's ${ext} already keeps the output files distinct, so there is one artifact name, one runner, and no collision. The .deb also has to be listed in the collect step and in both action-gh-release file lists, or it builds and never ships. Packages declare Electron's shared-library dependencies per format, so a minimal Debian/Ubuntu install pulls them in instead of the app failing to start. The same wiring covers `rpm`, exposed as `dist:desktop:rpm` for local builds; the release workflow stays AppImage+deb because the runners have no `rpmbuild`. Consolidates the overlapping .deb work from #4887, #4900 and #5139: the dependency lists and the rpm target come from @bigpod98's #5139, the docs and the download-page card from @benthecarman's #4900. The .deb is not an updater payload -- in-app updates on Linux stay AppImage-only, so .deb installs upgrade through dpkg/apt. Documented in docs/operations/release.md. --- .github/workflows/release.yml | 5 +- apps/marketing/src/pages/download.astro | 4 ++ docs/internals/scripts.md | 6 +++ docs/operations/release.md | 4 +- package.json | 2 + scripts/build-desktop-artifact.test.ts | 62 ++++++++++++++++++++++ scripts/build-desktop-artifact.ts | 70 ++++++++++++++++++++++++- 7 files changed, 150 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a9754f9421b..3172718eab1 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 arch: x64 rust_target: x86_64-unknown-linux-gnu resource_key: linux-x64 @@ -608,6 +608,7 @@ jobs: "release/*.dmg" \ "release/*.zip" \ "release/*.AppImage" \ + "release/*.deb" \ "release/*.exe" \ "release/*.blockmap" \ "release/*.yml"; do @@ -830,6 +831,7 @@ jobs: release-assets/*.dmg release-assets/*.zip release-assets/*.AppImage + release-assets/*.deb release-assets/*.exe release-assets/*.blockmap release-assets/*.yml @@ -850,6 +852,7 @@ jobs: release-assets/*.dmg release-assets/*.zip release-assets/*.AppImage + release-assets/*.deb 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..3a093e70595 100644 --- a/docs/internals/scripts.md +++ b/docs/internals/scripts.md @@ -68,9 +68,15 @@ 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`. +- `vp run dist:desktop:rpm`: Builds a Fedora/RHEL `.rpm` into `./release`. Needs `rpmbuild` on the + host; the release workflow therefore ships the `.deb` only. - `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..88ebf2a110f 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 a `.deb` 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` ships in the same release but is not an updater payload: in-app updates on Linux + stay AppImage-only, so `.deb` installs upgrade through `dpkg`/`apt` - 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..948c107ba53 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,8 @@ "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 --arch x64", + "dist:desktop:rpm": "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( From 195040d564287f69bcb7c266509b6a9c4c09992f Mon Sep 17 00:00:00 2001 From: chukfinley Date: Thu, 6 Aug 2026 20:59:08 +0200 Subject: [PATCH 2/3] chore(scripts): add per-arch deb and rpm dist scripts Matches the existing dist:desktop:dmg / dist:desktop:win families: the bare script builds for the host arch, with explicit :arm64 and :x64 variants. Keeps the Linux packaging scripts in line with what is already there rather than pinning x64. --- docs/internals/scripts.md | 7 ++++--- package.json | 8 ++++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/internals/scripts.md b/docs/internals/scripts.md index 3a093e70595..d9a1bcfe9c1 100644 --- a/docs/internals/scripts.md +++ b/docs/internals/scripts.md @@ -68,9 +68,10 @@ 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`. -- `vp run dist:desktop:rpm`: Builds a Fedora/RHEL `.rpm` into `./release`. Needs `rpmbuild` on the - host; the release workflow therefore ships the `.deb` only. +- `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; the release workflow therefore ships the `.deb` only. - `vp run dist:desktop:win`: Builds a Windows NSIS installer into `./release`. `:arm64` and `:x64` variants exist. diff --git a/package.json b/package.json index 948c107ba53..dfa7600257f 100644 --- a/package.json +++ b/package.json @@ -36,8 +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 --arch x64", - "dist:desktop:rpm": "node scripts/build-desktop-artifact.ts --platform linux --target rpm --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", From 6815958cf6ac68e3b1d8a90127fe16bf5e902653 Mon Sep 17 00:00:00 2001 From: chukfinley Date: Thu, 6 Aug 2026 21:11:19 +0200 Subject: [PATCH 3/3] feat(release): ship the Linux .rpm alongside the .deb The rpm target needs no code beyond what the deb target already added: same FpmTarget, same homepage/maintainer metadata, and the rpm dependency list is already in the build config. Only the release wiring was missing. The Linux job now builds AppImage,deb,rpm from the one electron-builder run, and *.rpm is added to the collect step and to both action-gh-release file lists, same as the deb. electron-builder shells out to rpmbuild for this. The Ubuntu runner image ships it in the `rpm` package (4.18.2 on 24.04), so the step is a no-op there; it installs the package only when the binary is missing, which keeps the target working if the image ever drops it. --- .github/workflows/release.yml | 19 ++++++++++++++++++- docs/internals/scripts.md | 2 +- docs/operations/release.md | 6 +++--- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3172718eab1..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,deb + 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: @@ -609,6 +623,7 @@ jobs: "release/*.zip" \ "release/*.AppImage" \ "release/*.deb" \ + "release/*.rpm" \ "release/*.exe" \ "release/*.blockmap" \ "release/*.yml"; do @@ -832,6 +847,7 @@ jobs: release-assets/*.zip release-assets/*.AppImage release-assets/*.deb + release-assets/*.rpm release-assets/*.exe release-assets/*.blockmap release-assets/*.yml @@ -853,6 +869,7 @@ jobs: release-assets/*.zip release-assets/*.AppImage release-assets/*.deb + release-assets/*.rpm release-assets/*.exe release-assets/*.blockmap release-assets/*.yml diff --git a/docs/internals/scripts.md b/docs/internals/scripts.md index d9a1bcfe9c1..3671dec484e 100644 --- a/docs/internals/scripts.md +++ b/docs/internals/scripts.md @@ -71,7 +71,7 @@ authenticated. - `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; the release workflow therefore ships the `.deb` only. + 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. diff --git a/docs/operations/release.md b/docs/operations/release.md index 88ebf2a110f..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 plus a `.deb` emitted by the same electron-builder run + - 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,8 +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` ships in the same release but is not an updater payload: in-app updates on Linux - stay AppImage-only, so `.deb` installs upgrade through `dpkg`/`apt` + - 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: