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
9 changes: 8 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -202,6 +207,7 @@ jobs:
"release/*.dmg" \
"release/*.zip" \
"release/*.AppImage" \
"release/*.deb" \
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate artifact name causes Linux build upload conflict

High Severity

The two new Linux matrix entries (Linux x64 AppImage and Linux x64 deb) both have platform: linux and arch: x64, so the artifact upload name desktop-${{ matrix.platform }}-${{ matrix.arch }} resolves to desktop-linux-x64 for both jobs. With actions/upload-artifact@v7, uploading a second artifact with the same name in the same workflow run results in a 409 Conflict error. The artifact name template needs to incorporate matrix.target to differentiate the two Linux builds.

Additional Locations (1)
Fix in Cursor Fix in Web

"release/*.exe" \
"release/*.blockmap" \
"release/latest*.yml"; do
Expand Down Expand Up @@ -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
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 @@ -53,6 +53,10 @@ import Layout from "../layouts/Layout.astro";
<span class="card-arch">x86_64</span>
<span class="card-format">AppImage</span>
</a>
<a class="download-card" data-asset="amd64.deb" href="#">
<span class="card-arch">Debian / Ubuntu (amd64)</span>
<span class="card-format">.deb</span>
</a>
</div>
</section>
</div>
Expand Down
2 changes: 2 additions & 0 deletions apps/marketing/src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
Expand Down
8 changes: 6 additions & 2 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 <token>` 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.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
162 changes: 153 additions & 9 deletions scripts/build-desktop-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand Down Expand Up @@ -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;
Expand All @@ -176,6 +253,7 @@ interface StagePackageJson {
readonly private: true;
readonly description: string;
readonly author: string;
readonly homepage: string;
readonly main: string;
readonly build: Record<string, unknown>;
readonly dependencies: Record<string, unknown>;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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-`,
Expand Down Expand Up @@ -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,
Expand All @@ -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" ? ";" : ":");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Windows PATH key casing causes broken environment variable

Medium Severity

On Windows, spreading process.env into a plain object loses case-insensitivity — the path key is stored as Path, not PATH. Reading buildEnv.PATH returns undefined, so the assignment creates a new PATH key containing only nodeGypShim.shimDir, while Path (with the real system path) remains untouched. The resulting env object has both Path and PATH, leading to unpredictable behavior: the child process may lose the system path entirely, preventing bun and other tools from being found.

Fix in Cursor Fix in Web

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;
Expand Down
Loading