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;