diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 504952e3aa..1737efc5b1 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -95,11 +95,16 @@ jobs:
platform: mac
target: dmg
arch: x64
- - label: Linux x64
+ - label: Linux x64 AppImage
runner: ubuntu-24.04
platform: linux
target: AppImage
arch: x64
+ - label: Linux x64 deb
+ runner: ubuntu-24.04
+ platform: linux
+ target: deb
+ arch: x64
- label: Windows x64
runner: windows-2022
platform: win
@@ -202,6 +207,7 @@ jobs:
"release/*.dmg" \
"release/*.zip" \
"release/*.AppImage" \
+ "release/*.deb" \
"release/*.exe" \
"release/*.blockmap" \
"release/latest*.yml"; do
@@ -298,6 +304,7 @@ jobs:
release-assets/*.dmg
release-assets/*.zip
release-assets/*.AppImage
+ release-assets/*.deb
release-assets/*.exe
release-assets/*.blockmap
release-assets/latest*.yml
diff --git a/apps/marketing/src/pages/download.astro b/apps/marketing/src/pages/download.astro
index 0cf31a82e4..2e9a4842d4 100644
--- a/apps/marketing/src/pages/download.astro
+++ b/apps/marketing/src/pages/download.astro
@@ -53,6 +53,10 @@ import Layout from "../layouts/Layout.astro";
x86_64
AppImage
+
+ Debian / Ubuntu (amd64)
+ .deb
+
diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro
index 3a2111f4f8..ef1a11883e 100644
--- a/apps/marketing/src/pages/index.astro
+++ b/apps/marketing/src/pages/index.astro
@@ -59,6 +59,8 @@ import Layout from "../layouts/Layout.astro";
if (platform.os === "linux") {
return (
assets.find((a) => a.name.endsWith(".AppImage"))
+ ?.browser_download_url ??
+ assets.find((a) => a.name.endsWith(".deb"))
?.browser_download_url ?? null
);
}
diff --git a/docs/release.md b/docs/release.md
index 4aec150f33..da8c3bbe70 100644
--- a/docs/release.md
+++ b/docs/release.md
@@ -6,10 +6,11 @@ This document covers how to run desktop releases from one tag, first without sig
- Trigger: push tag matching `v*.*.*`.
- Runs quality gates first: lint, typecheck, test.
-- Builds four artifacts in parallel:
+- Builds five artifacts in parallel:
- macOS `arm64` DMG
- macOS `x64` DMG
- Linux `x64` AppImage
+ - Linux `x64` `.deb`
- Windows `x64` NSIS installer
- Publishes one GitHub Release with all produced files.
- Versions with a suffix after `X.Y.Z` (for example `1.2.3-alpha.1`) are published as GitHub prereleases.
@@ -33,9 +34,12 @@ This document covers how to run desktop releases from one tag, first without sig
- set `T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN` (or `GH_TOKEN`) in the desktop app runtime environment.
- the app forwards it as an `Authorization: Bearer ` request header for updater HTTP calls.
- Required release assets for updater:
- - platform installers (`.exe`, `.dmg`, `.AppImage`, plus macOS `.zip` for Squirrel.Mac update payloads)
+ - platform installers used by updater (`.exe`, `.dmg`, `.AppImage`, plus macOS `.zip` for Squirrel.Mac update payloads)
- `latest*.yml` metadata
- `*.blockmap` files (used for differential downloads)
+- Linux `.deb` note:
+ - `.deb` packages are published as release assets for manual installation.
+ - Linux auto-update remains AppImage-only.
- macOS metadata note:
- `electron-updater` reads `latest-mac.yml` for both Intel and Apple Silicon.
- The workflow merges the per-arch mac manifests into one `latest-mac.yml` before publishing the GitHub Release.
diff --git a/package.json b/package.json
index a26a359c03..00560a114a 100644
--- a/package.json
+++ b/package.json
@@ -48,6 +48,7 @@
"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:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64",
"release:smoke": "node scripts/release-smoke.ts",
"clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo",
diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts
index c0327ab4ca..b25127331d 100644
--- a/scripts/build-desktop-artifact.ts
+++ b/scripts/build-desktop-artifact.ts
@@ -2,7 +2,7 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
-import { join } from "node:path";
+import { dirname as nodeDirname, join, resolve as resolveNodePath } from "node:path";
import rootPackageJson from "../package.json" with { type: "json" };
import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" };
@@ -154,6 +154,83 @@ function resolvePythonForNodeGyp(): string | undefined {
return executable;
}
+function resolveNodeExecutable(): string | undefined {
+ const configured = process.env.npm_node_execpath ?? process.env.NODE;
+ if (configured && existsSync(configured)) {
+ return configured;
+ }
+
+ if (existsSync(process.execPath) && /(^|[\\/])node(?:\.exe)?$/i.test(process.execPath)) {
+ return process.execPath;
+ }
+
+ const probe = spawnSync("node", ["-p", "process.execPath"], {
+ encoding: "utf8",
+ });
+ if (probe.status !== 0) {
+ return undefined;
+ }
+
+ const executable = probe.stdout.trim();
+ if (!executable || !existsSync(executable)) {
+ return undefined;
+ }
+
+ return executable;
+}
+
+function resolveNodeGypScript(nodeExecutable: string | undefined): string | undefined {
+ const configured = process.env.npm_config_node_gyp;
+ if (configured && existsSync(configured)) {
+ return configured;
+ }
+
+ const candidates: string[] = [];
+ const npmRootProbe = spawnSync("npm", ["root", "-g"], {
+ encoding: "utf8",
+ });
+ if (npmRootProbe.status === 0) {
+ const npmRoot = npmRootProbe.stdout.trim();
+ if (npmRoot) {
+ candidates.push(join(npmRoot, "node-gyp", "bin", "node-gyp.js"));
+ candidates.push(join(npmRoot, "npm", "node_modules", "node-gyp", "bin", "node-gyp.js"));
+ }
+ }
+
+ if (nodeExecutable) {
+ const nodePrefix = resolveNodePath(nodeDirname(nodeExecutable), "..");
+ candidates.push(join(nodePrefix, "lib", "node_modules", "node-gyp", "bin", "node-gyp.js"));
+ candidates.push(
+ join(
+ nodePrefix,
+ "lib",
+ "node_modules",
+ "npm",
+ "node_modules",
+ "node-gyp",
+ "bin",
+ "node-gyp.js",
+ ),
+ );
+ candidates.push(join(nodePrefix, "node_modules", "node-gyp", "bin", "node-gyp.js"));
+ candidates.push(
+ join(nodePrefix, "node_modules", "npm", "node_modules", "node-gyp", "bin", "node-gyp.js"),
+ );
+ }
+
+ return candidates.find((candidate) => existsSync(candidate));
+}
+
+function quoteForPosixShell(value: string): string {
+ return `'${value.replaceAll("'", `'\\''`)}'`;
+}
+
+interface NodeGypShim {
+ readonly shimDir: string;
+ readonly shimPath: string;
+ readonly nodeExecutable: string;
+}
+
interface ResolvedBuildOptions {
readonly platform: typeof BuildPlatform.Type;
readonly target: string;
@@ -176,6 +253,7 @@ interface StagePackageJson {
readonly private: true;
readonly description: string;
readonly author: string;
+ readonly homepage: string;
readonly main: string;
readonly build: Record;
readonly dependencies: Record;
@@ -386,6 +464,51 @@ function stageWindowsIcons(stageResourcesDir: string) {
});
}
+function createNodeGypShim(stageRoot: string, verbose: boolean) {
+ return Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const nodeExecutable = resolveNodeExecutable();
+ const nodeGypScript = resolveNodeGypScript(nodeExecutable);
+ if (!nodeExecutable || !nodeGypScript) {
+ return undefined;
+ }
+
+ const shimDir = path.join(stageRoot, ".tooling-bin");
+ yield* fs.makeDirectory(shimDir, { recursive: true });
+
+ if (process.platform === "win32") {
+ const shimPath = path.join(shimDir, "node-gyp.cmd");
+ yield* fs.writeFileString(
+ shimPath,
+ `@"${nodeExecutable.replaceAll('"', '""')}" "${nodeGypScript.replaceAll('"', '""')}" %*\r\n`,
+ );
+ return {
+ shimDir,
+ shimPath,
+ nodeExecutable,
+ } satisfies NodeGypShim;
+ }
+
+ const shimPath = path.join(shimDir, "node-gyp");
+ yield* fs.writeFileString(
+ shimPath,
+ `#!/bin/sh\nexec ${quoteForPosixShell(nodeExecutable)} ${quoteForPosixShell(nodeGypScript)} "$@"\n`,
+ );
+ yield* runCommand(
+ ChildProcess.make({
+ ...commandOutputOptions(verbose),
+ })`chmod +x ${shimPath}`,
+ );
+
+ return {
+ shimDir,
+ shimPath,
+ nodeExecutable,
+ } satisfies NodeGypShim;
+ });
+}
+
function validateBundledClientAssets(clientDir: string) {
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
@@ -463,6 +586,14 @@ function resolveGitHubPublishConfig():
};
}
+function resolveProjectHomepage(): string | undefined {
+ const repository = serverPackageJson.repository;
+
+ if (!repository) return undefined;
+ if (typeof repository === "string") return repository;
+ return repository.url;
+}
+
const createBuildConfig = Effect.fn("createBuildConfig")(function* (
platform: typeof BuildPlatform.Type,
target: string,
@@ -505,6 +636,7 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* (
executableName: "t3code",
icon: "icon.png",
category: "Development",
+ maintainer: process.env.T3CODE_DESKTOP_LINUX_MAINTAINER ?? "T3 Tools",
desktop: {
entry: {
StartupWMClass: "t3code",
@@ -598,6 +730,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
const appVersion = options.version ?? serverPackageJson.version;
const commitHash = resolveGitCommitHash(repoRoot);
+ const homepage = resolveProjectHomepage() ?? "https://github.com/pingdotgg/t3code";
const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped;
const stageRoot = yield* mkdir({
prefix: `t3code-desktop-${options.platform}-stage-`,
@@ -661,6 +794,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
private: true,
description: "T3 Code desktop build",
author: "T3 Tools",
+ homepage,
main: "apps/desktop/dist-electron/main.js",
build: yield* createBuildConfig(
options.platform,
@@ -682,24 +816,34 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
const stagePackageJsonString = yield* encodeJsonString(stagePackageJson);
yield* fs.writeFileString(path.join(stageAppDir, "package.json"), `${stagePackageJsonString}\n`);
+ const nodeGypShim = yield* createNodeGypShim(stageRoot, options.verbose);
+
yield* Effect.log("[desktop-artifact] Installing staged production dependencies...");
+ const buildEnv: NodeJS.ProcessEnv = {
+ ...process.env,
+ };
+ for (const [key, value] of Object.entries(buildEnv)) {
+ if (value === "") {
+ delete buildEnv[key];
+ }
+ }
+ if (nodeGypShim) {
+ buildEnv.PATH = [nodeGypShim.shimDir, buildEnv.PATH]
+ .filter(Boolean)
+ .join(process.platform === "win32" ? ";" : ":");
+ buildEnv.npm_config_node_gyp = nodeGypShim.shimPath;
+ buildEnv.npm_node_execpath = nodeGypShim.nodeExecutable;
+ }
yield* runCommand(
ChildProcess.make({
cwd: stageAppDir,
+ env: buildEnv,
...commandOutputOptions(options.verbose),
// Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd).
shell: process.platform === "win32",
})`bun install --production`,
);
- const buildEnv: NodeJS.ProcessEnv = {
- ...process.env,
- };
- for (const [key, value] of Object.entries(buildEnv)) {
- if (value === "") {
- delete buildEnv[key];
- }
- }
if (!options.signed) {
buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false";
delete buildEnv.CSC_LINK;