Skip to content
Merged
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
402 changes: 254 additions & 148 deletions .github/workflows/release-desktop.yml

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,8 @@ jobs:
resource_key: win32-x64
cli_archive: true

# Windows arm64 runners have 4 vCPUs, and the packaging is mostly CPU-bound,
# so the build runs on Linux and only the native checks run on arm64.
desktop_win_arm64:
name: Desktop Windows arm64
needs: [preflight, relay_public_config, build_bundle]
Expand All @@ -674,7 +676,8 @@ jobs:
clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }}
relay_url: ${{ needs.relay_public_config.outputs.relay_url }}
label: Windows arm64
runner: windows-11-arm
runner: blacksmith-16vcpu-ubuntu-2404
verify_runner: windows-11-arm
platform: win
target: nsis
arch: arm64
Expand Down
6 changes: 3 additions & 3 deletions docs/operations/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ the version.
- Runs lint, typecheck, and tests alongside artifact builds. Publishing waits for every check.
- Reads the shared production T3 Connect relay URL and Clerk client configuration before packaging clients.
- Builds the platform-independent JS (server bundle, web client, Electron main) once in the `build_bundle` job and hands it to every platform job as the `js-bundle` artifact; the platform jobs only package it, so no runner rebuilds it.
- Builds six desktop artifacts in parallel for both channels, each as its own job (`desktop_<platform>_<arch>`, one call of `release-desktop.yml`) on hardware of its own architecture, gated only on the bundle. The Windows jobs embed the same-arch Linux CLI archive as the WSL runtime and wait for that artifact partway through, not for the whole Linux job:
- Builds six desktop artifacts in parallel for both channels, each as its own job (`desktop_<platform>_<arch>`, one call of `release-desktop.yml`), gated only on the bundle. Every target builds on hardware of its own architecture except Windows arm64: its runners have 4 vCPUs, so it builds and signs (with jsign) on a Linux runner, and a `windows-11-arm` job runs the packaged natives and the CLI archive (`verify_runner`). The Windows jobs embed the same-arch Linux CLI archive as the WSL runtime and wait for that artifact partway through, not for the whole Linux job:
- macOS `arm64` DMG
- macOS `x64` DMG
- Linux `x64` and `arm64` AppImage and `.deb`, from one electron-builder run. The `.deb` updates in the app through electron-updater, which installs it with `dpkg`.
Expand All @@ -42,11 +42,11 @@ the version.
- Nightly runs are always GitHub prereleases and never marked latest.
- Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag.
- Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets.
- Builds a self-contained CLI archive per platform (`t3-<version>-<platform>-<arch>.tar.gz`, `.zip` on Windows) in the same job as that target's desktop artifact and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel, for five targets: macOS arm64, Linux x64 and arm64, Windows x64 and arm64. Every archive is built, signed, and smoke-tested on hardware of its own architecture. There is no macOS x64 archive: Node single-executables are unsupported on x64 macOS (the SEA docs list macOS as arm64 only) and the binary segfaults on start; the x64 desktop app is Electron and unaffected.
- Builds a self-contained CLI archive per platform (`t3-<version>-<platform>-<arch>.tar.gz`, `.zip` on Windows) in the same job as that target's desktop artifact and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel, for five targets: macOS arm64, Linux x64 and arm64, Windows x64 and arm64. Every archive is smoke-tested on hardware of its own architecture, and every one but Windows arm64 is also built and signed there. There is no macOS x64 archive: Node single-executables are unsupported on x64 macOS (the SEA docs list macOS as arm64 only) and the binary segfaults on start; the x64 desktop app is Electron and unaffected.
- The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the only form in which T3 Code manages a runtime: the desktop's SSH environments, the boot service, `t3 update`, and the install scripts all download and verify this archive against `SHA256SUMS`. The `t3` npm package exists for people who run `npx t3` or `npm install -g t3` themselves, and it downloads this same archive too; nothing in the product installs from npm. The `curl | sh` installers are `scripts/install.sh` and `scripts/install.ps1`; the marketing site copies them into its `public/` at build time (`apps/marketing/scripts/stage-install-scripts.mjs`) and serves them at `t3.codes/install.sh` and `/install.ps1`.
- The executable is built with a Node that supports `--build-sea` (`VP_NODE_VERSION=26.8.2`, kept in step with `SEA_NODE_VERSION` in `apps/server/vite.config.ts`), while the repo stays on `engines.node`.
- macOS archives are signed with the Developer ID certificate and notarized when the Apple secrets are present (ad hoc otherwise, which still runs from `curl`/`tar` installs). Windows executables use the same Azure Trusted Signing setup as the installer. Every native addon in the macOS archive is signed too, since the hardened runtime refuses unsigned libraries.
- Each archive is extracted and executed on its build runner (`scripts/smoke-cli-archive.ts`) before it is uploaded.
- Each archive is extracted and executed (`scripts/smoke-cli-archive.ts`) on its build runner, or on the `verify_runner` for Windows arm64.
- After the GitHub Release is published, publishes the `t3` npm package with OIDC trusted publishing from the same workflow file. It is only a launcher (`scripts/npm-launcher/t3.mjs`): on first run it downloads its exact version's CLI archive from the GitHub Release, verifies it against `SHA256SUMS`, and unpacks it into `<T3 home>/runtime/versions/<version>`, the layout the install scripts and `t3 update` share. The executables are not on npm. They used to be, as `@t3code/t3-<platform>-<arch>` optional dependencies, but npm's publish-time scanning held those 80-250 MB packages back for up to an hour after the launcher was live, and every install in that window got a launcher with no executable. Those packages stay on npm at their old versions and get no new ones. Because the launcher reads from the release, `publish_cli` runs after `release`, never before.
- stable releases publish npm dist-tag `latest`
- nightly releases publish npm dist-tag `nightly`
Expand Down
11 changes: 9 additions & 2 deletions knip.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,15 @@
},
"scripts": {
// Knip loads its preprocessor through a CLI option; native verification and
// worktree setup (from t3.json) run directly.
"entry": ["knip-schemas.ts", "mobile-native-client.ts", "setup-worktree.ts"],
// worktree setup (from t3.json) run directly. release-desktop.yml bundles the
// Windows verify script with esbuild through a path knip does not trace.
"entry": [
"knip-schemas.ts",
"mobile-native-client.ts",
"setup-worktree.ts",
"verify-windows-desktop-artifact.ts",
],
"ignoreDependencies": ["esbuild"],
// The `t3` npm package's files: copied into the package, never imported.
"ignore": ["npm-launcher/**"],
},
Expand Down
15 changes: 15 additions & 0 deletions patches/app-builder-lib@26.16.1.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
diff --git a/out/targets/nsis/NsisTarget.js b/out/targets/nsis/NsisTarget.js
index 8a3462b185b078a260483635e6d774e2ece6e269..105778c145ab63a26444179215c643a7f22148a0 100644
--- a/out/targets/nsis/NsisTarget.js
+++ b/out/targets/nsis/NsisTarget.js
@@ -366,7 +366,9 @@ class NsisTarget extends core_1.Target {
await this.executeMakensis(defines, commands, sharedHeader + (await this.computeFinalScript(script, false, archs)));
// http://forums.winamp.com/showthread.php?p=3078545
// TODO: remove workaround when wine is fully upgraded to 11
- if ((0, macosVersion_1.isMacOsCatalina)()) {
+ // T3 Code builds Windows installers on Linux without wine; read the
+ // uninstaller out of the stub the way macOS hosts already do.
+ if ((0, macosVersion_1.isMacOsCatalina)() || process.platform === "linux") {
try {
await nsisUtil_1.UninstallerReader.exec(installerPath, uninstallerPath);
}
12 changes: 8 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@ patchedDependencies:
# Android: Maven-resolved gradle plugins (expo-widgets) have no sourceDir; without this
# skip, the fingerprint silently drops every Expo native module and OTAs reach stale binaries.
"@expo/fingerprint@0.21.5": patches/@expo__fingerprint@0.21.5.patch
app-builder-lib@26.16.1: patches/app-builder-lib@26.16.1.patch

peerDependencyRules:
allowAny:
Expand Down
21 changes: 15 additions & 6 deletions scripts/build-cli-archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import {
STAGE_INSTALL_ARGS,
} from "./build-desktop-artifact.ts";
import { selectCliRuntimeExternalDependencies } from "./lib/cli-external-packages.ts";
import { signWithJsign } from "./lib/windows-jsign.ts";
import { windowsSystemTar } from "./lib/windows-tar.ts";
import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts";

const BuildPlatform = Schema.Literals(["mac", "linux", "win"]);
Expand Down Expand Up @@ -95,11 +97,6 @@ export function cliArchiveFileName(version: string, platform: BuildPlatform, arc
return `${cliArchiveStem(version, platform, arch)}.${platform === "win" ? "zip" : "tar.gz"}`;
}

export function windowsSystemTar(): string {
const systemRoot = process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows";
return `${systemRoot}\\System32\\tar.exe`;
}

const runCommand = Effect.fn("runCommand")(function* (
command: ChildProcess.Command,
label: string,
Expand Down Expand Up @@ -411,6 +408,11 @@ const signWindowsExecutable = Effect.fn("signWindowsExecutable")(function* (
return;
}
yield* stripStaleAuthenticodeEntry(executablePath);
if ((yield* HostProcess.Platform) !== "win32") {
yield* signWithJsign(executablePath);
yield* Effect.log("[cli-archive] Signed t3.exe (Azure Trusted Signing via jsign).");
return;
}
const quote = (value: string) => `'${value.replaceAll("'", "''")}'`;
const script = [
"$ErrorActionPreference = 'Stop';",
Expand Down Expand Up @@ -507,7 +509,14 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: {
cliArchiveFileName(input.version, input.platform, input.arch),
);
yield* fs.remove(archivePath, { force: true });
if (input.platform === "win") {
if (input.platform === "win" && hostPlatform !== "win32") {
yield* runCommand(
ChildProcess.make("zip", ["-q", "-r", "-X", path.resolve(archivePath), stem], {
cwd: stageRoot,
}),
"zip",
);
} else if (input.platform === "win") {
// Windows ships bsdtar, which writes zip natively. Name it by path: under
// the Git Bash shell CI uses, a bare `tar` is GNU tar, which neither
// writes zip nor accepts a drive-letter path.
Expand Down
70 changes: 64 additions & 6 deletions scripts/build-desktop-artifact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import {
LinuxBrowserSecretHostError,
stageBrowserSecret,
validateWindowsPackagedPayload,
verifyPackagedBundleIsSelfContained,
WindowsPrimaryNativeProbeError,
WindowsDesktopBuildPrerequisitesMissingError,
WindowsPackagedPayloadValidationError,
Expand Down Expand Up @@ -2063,21 +2064,44 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
it.effect("rejects a sidecar whose extracted server bundle cannot resolve", () =>
Effect.scoped(
Effect.gen(function* () {
const path = yield* Path.Path;
const fixture = yield* makeWindowsPayloadFixture({
copyUnpackedNatives: true,
serverEntrySource: 'import "t3code-deliberately-missing-package";\n',
});
const error = yield* validateWindowsPackagedPayload({
stageDistDir: fixture.stageDistDir,
appExecutableName: fixture.appExecutableName,
targetArch: "x64",
appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
const error = yield* verifyPackagedBundleIsSelfContained({
asarPath: path.join(fixture.packagedAppDir, "resources", WINDOWS_SERVER_ASAR_RESOURCE),
verbose: false,
}).pipe(Effect.flip);

assert.instanceOf(error, BundleNotSelfContainedError);
assert.include(error.output, "t3code-deliberately-missing-package");
}),
).pipe(Effect.provideService(HostProcess.Platform, "linux")),
),
);

it.effect("leaves the bundle self-check of a cross-built payload to the verify job", () =>
Effect.scoped(
Effect.gen(function* () {
const fixture = yield* makeWindowsPayloadFixture({
copyUnpackedNatives: true,
serverEntrySource: 'import "t3code-deliberately-missing-package";\n',
});
yield* validateWindowsPackagedPayload({
stageDistDir: fixture.stageDistDir,
appExecutableName: fixture.appExecutableName,
targetArch: "arm64",
appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
});
}),
).pipe(
Effect.provide(
Layer.mergeAll(
Layer.succeed(HostProcess.Platform, "linux"),
Layer.succeed(HostProcess.Architecture, "x64"),
),
),
),
);

it.effect("preserves both Linux icon resize failures with structural context", () => {
Expand Down Expand Up @@ -2438,6 +2462,40 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
}).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))),
);

it.effect("signs Windows builds with signtool on Windows and the jsign hook elsewhere", () =>
Effect.gen(function* () {
const signedWinConfig = (hostPlatform: string) =>
createBuildConfig("win", "nsis", "1.2.3", true, false, undefined, undefined).pipe(
Effect.map((config) => config.win as Record<string, unknown>),
Effect.provideService(HostProcess.Platform, hostPlatform),
);

const onWindows = yield* signedWinConfig("win32");
assert.equal((onWindows.azureSignOptions as Record<string, unknown>).publisherName, "T3");
assert.notProperty(onWindows, "signtoolOptions");

const onLinux = yield* signedWinConfig("linux");
assert.notProperty(onLinux, "azureSignOptions");
const signtoolOptions = onLinux.signtoolOptions as Record<string, unknown>;
assert.match(signtoolOptions.sign as string, /scripts[\\/]lib[\\/]windows-jsign\.ts$/);
assert.deepStrictEqual(signtoolOptions.signingHashAlgorithms, ["sha256"]);
assert.deepStrictEqual(signtoolOptions.publisherName, ["T3"]);
}).pipe(
Effect.provide(
ConfigProvider.layer(
ConfigProvider.fromEnv({
env: {
AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: "T3",
AZURE_TRUSTED_SIGNING_ENDPOINT: "https://eus.codesigning.azure.net/",
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: "profile",
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: "account",
},
}),
),
),
),
);

it("stages the resource monitor as an external executable resource", () => {
assert.deepStrictEqual(DESKTOP_EXTRA_RESOURCES, [
{
Expand Down
Loading
Loading