Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -608,6 +622,8 @@ jobs:
"release/*.dmg" \
"release/*.zip" \
"release/*.AppImage" \
"release/*.deb" \
"release/*.rpm" \
"release/*.exe" \
"release/*.blockmap" \
"release/*.yml"; do
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions apps/marketing/src/pages/download.astro
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ import { ANDROID_PLAY_STORE_URL, IOS_APP_STORE_URL } from "../lib/site";
<span class="card-arch">x86_64</span>
<span class="card-format">AppImage</span>
</a>
<a class="download-card" data-asset="amd64.deb" href={RELEASES_URL}>
<span class="card-arch">Debian, Ubuntu (x86_64)</span>
<span class="card-format">.deb</span>
</a>
</div>
</section>

Expand Down
7 changes: 7 additions & 0 deletions docs/internals/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <arm64|x64|universal>`, 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.
Expand Down
4 changes: 3 additions & 1 deletion docs/operations/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
62 changes: 62 additions & 0 deletions scripts/build-desktop-artifact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -34,7 +36,9 @@ import {
resolveDesktopProductName,
resolveDesktopUpdateChannel,
resolveDesktopWebAssetBrand,
resolveLinuxTargets,
resolveResourceMonitorRustTargets,
RPM_DEPENDENCIES,
resourceMonitorExecutableName,
resolveGitHubPublishConfig,
resolveMockUpdateServerPort,
Expand Down Expand Up @@ -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<string, unknown>).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<string, unknown>).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<string, unknown>).depends, [...DEB_DEPENDENCIES]);
assert.notProperty(both, "rpm");
assert.deepStrictEqual((rpmOnly.rpm as Record<string, unknown>).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<string> }> = [];

Expand Down
70 changes: 69 additions & 1 deletion scripts/build-desktop-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <hello@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"]);

Expand Down Expand Up @@ -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<string, unknown>;
Expand Down Expand Up @@ -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<string> {
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,
Expand Down Expand Up @@ -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/<scheme>;
// in the .desktop entry (Exec already gets %U), so browsers can hand
// t3code:// OAuth callbacks to the app.
Expand All @@ -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") {
Expand Down Expand Up @@ -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(
Expand Down