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(