authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-14 05:02:04-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-14 05:02:04-07:00
log9375c779707fb95c06670db6da87441720b2d2e2
tree3864ba37529e2e094f3118fc041a580c365a88ec
parent6f00d3881a197dd33c2cb43c6a11a9e759e56089
signature Signed by PGP key B5690EEEBB952194

fix(release): preserve updates from npm-based services (#11732)


7 files changed, 227 insertions(+), 9 deletions(-)

apps/server/src/cloud/pinnedRuntime.test.ts+48
......@@ -1,3 +1,4 @@
1import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
12import * as NodeServices from "@effect/platform-node/NodeServices";
23import { assert, it } from "@effect/vitest";
34import * as Deferred from "effect/Deferred";
......@@ -94,6 +95,19 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
9495 assert.deepEqual(commands, ["tar"]);
9596 assert.equal(yield* fs.readFileString(paths.sentinelPath), `${version}\n`);
9697 assert.isFalse(yield* fs.exists(path.join(paths.versionDir, "t3-runtime-archive")));
98 if ((yield* HostProcessPlatform) !== "win32") {
99 // The old launcher must still be able to start this archive after the
100 // first npm-to-executable update, including from the final directory.
101 yield* fs.writeFileString(paths.entryPath, '#!/bin/sh\nprintf "%s\\n" "$@"\n');
102 yield* fs.chmod(paths.entryPath, 0o755);
103 const runner = yield* ProcessRunner.make();
104 const legacyStart = yield* runner.run({
105 command: process.execPath,
106 args: [path.join(paths.versionDir, "node_modules/t3/dist/bin.mjs"), "serve"],
107 });
108 assert.equal(Number(legacyStart.code), 0, legacyStart.stderr);
109 assert.equal(legacyStart.stdout.trim(), "serve");
110 }
97111 }),
98112 );
99113
......@@ -209,6 +223,40 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
209223 }),
210224 );
211225
226 it.effect("backfills a cached archive without downloading or replacing it", () =>
227 Effect.gen(function* () {
228 const fs = yield* FileSystem.FileSystem;
229 const path = yield* Path.Path;
230 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-legacy-cache-" });
231 const cached = pinnedRuntimePaths(path, baseDir, version, "linux");
232 const legacyEntry = path.join(cached.versionDir, "node_modules/t3/dist/bin.mjs");
233 yield* fs.makeDirectory(cached.versionDir, { recursive: true });
234 yield* fs.writeFileString(cached.entryPath, "cached executable\n");
235 yield* fs.writeFileString(cached.sentinelPath, `${version}\n`);
236 const requests: string[] = [];
237 const commands: string[] = [];
238 yield* ensurePinnedRuntimeInstalled({
239 baseDir,
240 version,
241 fs,
242 path,
243 platform: "linux",
244 arch: "x64",
245 httpClient: releaseHttpClient(yield* validChecksums, requests),
246 runner: extractingRunner(fs, path, commands),
247 validate: () =>
248 fs.exists(legacyEntry).pipe(
249 Effect.flatMap((exists) => (exists ? Effect.void : Effect.die("missing legacy entry"))),
250 Effect.orDie,
251 ),
252 });
253 assert.deepEqual(requests, []);
254 assert.deepEqual(commands, []);
255 assert.equal(yield* fs.readFileString(cached.entryPath), "cached executable\n");
256 assert.equal(yield* fs.readFileString(cached.sentinelPath), `${version}\n`);
257 }),
258 );
259
212260 it.effect("preserves a completed runtime when validation fails", () =>
213261 Effect.gen(function* () {
214262 const fs = yield* FileSystem.FileSystem;
apps/server/src/cloud/pinnedRuntime.ts+26-1
......@@ -8,6 +8,7 @@ import * as Option from "effect/Option";
88import * as Semaphore from "effect/Semaphore";
99import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
1010
11import { legacyCliLauncherScript } from "@t3tools/shared/legacyCliLauncher";
1112import {
1213 CLI_RELEASE_CHECKSUMS_FILE,
1314 cliArchiveFileName,
......@@ -229,6 +230,24 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")(
229230 input: PinnedRuntimeInstallInput,
230231) {
231232 const { fs } = input;
233 // Old service launchers still use the npm entry point, including when an
234 // archive was cached before this compatibility wrapper existed.
235 const ensureLegacyEntry = Effect.fn("cloud.pinned_runtime.ensure_legacy_entry")(
236 function* (versionDir: string) {
237 const legacyDir = input.path.join(versionDir, "node_modules", "t3", "dist");
238 const entryPath = input.path.join(legacyDir, "bin.mjs");
239 if (yield* fs.exists(entryPath)) return;
240 yield* fs.makeDirectory(legacyDir, { recursive: true });
241 yield* fs.writeFileString(entryPath, legacyCliLauncherScript("archive"));
242 },
243 Effect.mapError(
244 (cause) =>
245 new PinnedRuntimeInstallError({
246 step: "writing the legacy service entry point",
247 cause,
248 }),
249 ),
250 );
232251 const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version, input.platform);
233252 const [versionDirExists, entryExists, sentinel] = yield* Effect.all([
234253 fs.exists(paths.versionDir),
......@@ -242,6 +261,7 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")(
242261 const alreadyPinned =
243262 entryExists && Option.isSome(sentinel) && sentinel.value.trim() === input.version;
244263 if (alreadyPinned) {
264 yield* ensureLegacyEntry(paths.versionDir);
245265 yield* input.validate(paths);
246266 return paths;
247267 }
......@@ -290,6 +310,8 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")(
290310 return yield* Effect.gen(function* () {
291311 yield* installFromArchive(input, stagingDir);
292312
313 yield* ensureLegacyEntry(stagingDir);
314
293315 yield* input.validate(stagingPaths);
294316 yield* fs
295317 .writeFileString(stagingPaths.sentinelPath, `${input.version}\n`)
......@@ -328,7 +350,10 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")(
328350 ),
329351 ),
330352 );
331 if (!published) yield* input.validate(paths);
353 if (!published) {
354 yield* ensureLegacyEntry(paths.versionDir);
355 yield* input.validate(paths);
356 }
332357 return paths;
333358 }).pipe(
334359 Effect.ensuring(fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore)),
packages/shared/package.json+4
......@@ -3,6 +3,10 @@
33 "private": true,
44 "type": "module",
55 "exports": {
6 "./legacyCliLauncher": {
7 "types": "./src/legacyCliLauncher.ts",
8 "import": "./src/legacyCliLauncher.ts"
9 },
610 "./delimitedPreview": {
711 "types": "./src/delimitedPreview.ts",
812 "import": "./src/delimitedPreview.ts"
packages/shared/src/legacyCliLauncher.test.ts created+62
......@@ -0,0 +1,62 @@
1// @effect-diagnostics nodeBuiltinImport:off - Exercises real Node IPC and process signals.
2import * as NodeChildProcess from "node:child_process";
3import * as NodeEvents from "node:events";
4import * as NodeFSP from "node:fs/promises";
5import * as NodeOS from "node:os";
6import * as NodePath from "node:path";
7import { expect, it } from "vite-plus/test";
8
9import { legacyCliLauncherScript } from "./legacyCliLauncher.ts";
10
11// oxlint-disable-next-line t3code/no-global-process-runtime -- This test launches a real host executable.
12const hostPlatform = NodeOS.platform();
13// oxlint-disable-next-line t3code/no-global-process-runtime -- Match the real executable used by the subprocess.
14const hostArch = NodeOS.arch();
15
16// The fixture executable uses a POSIX shebang. The wrapper itself also runs on Windows.
17it.skipIf(hostPlatform === "win32").each(["npm", "archive"] as const)(
18 "keeps %s service IPC, arguments, and termination connected",
19 async (distribution) => {
20 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-legacy-launcher-"));
21 const entry = NodePath.join(root, "node_modules/t3/dist/bin.mjs");
22 const executable =
23 distribution === "archive"
24 ? NodePath.join(root, "t3")
25 : NodePath.join(root, `node_modules/@t3code/t3-${hostPlatform}-${hostArch}/t3`);
26 await NodeFSP.mkdir(NodePath.dirname(entry), { recursive: true });
27 await NodeFSP.mkdir(NodePath.dirname(executable), { recursive: true });
28 await NodeFSP.writeFile(
29 NodePath.join(NodePath.dirname(executable), "package.json"),
30 '{"type":"commonjs"}',
31 );
32 await NodeFSP.writeFile(entry, legacyCliLauncherScript(distribution));
33 await NodeFSP.writeFile(
34 executable,
35 `#!${process.execPath}
36process.on("SIGTERM", () => process.exit(23));
37process.on("message", message => process.send({ reply: message }));
38process.send({ args: process.argv.slice(2) });
39`,
40 );
41 await NodeFSP.chmod(executable, 0o755);
42 const child = NodeChildProcess.fork(entry, ["serve", "a path with spaces"], { silent: true });
43 try {
44 expect((await NodeEvents.EventEmitter.once(child, "message"))[0]).toEqual({
45 args: ["serve", "a path with spaces"],
46 });
47 const reply = NodeEvents.EventEmitter.once(child, "message");
48 child.send({ type: "trial-accepted" });
49 expect((await reply)[0]).toEqual({ reply: { type: "trial-accepted" } });
50 const exit = NodeEvents.EventEmitter.once(child, "exit");
51 child.kill("SIGTERM");
52 expect(await exit).toEqual([23, null]);
53 } finally {
54 if (child.exitCode === null && child.signalCode === null) {
55 const exit = NodeEvents.EventEmitter.once(child, "exit");
56 child.kill("SIGTERM");
57 await exit;
58 }
59 await NodeFSP.rm(root, { recursive: true, force: true });
60 }
61 },
62);
packages/shared/src/legacyCliLauncher.ts created+36
......@@ -0,0 +1,36 @@
1/** Node entry point for service launchers installed before executable releases. */
2export function legacyCliLauncherScript(distribution: "npm" | "archive"): string {
3 const executable =
4 distribution === "npm"
5 ? 'join(dirname(require.resolve("@t3code/t3-" + process.platform + "-" + process.arch + "/package.json")), executableName)'
6 : 'resolve(dirname(fileURLToPath(import.meta.url)), "../../..", executableName)';
7 return `import { spawn } from "node:child_process";
8import { constants } from "node:os";
9import { dirname, join, resolve } from "node:path";
10import { fileURLToPath } from "node:url";
11import { createRequire } from "node:module";
12const require = createRequire(import.meta.url);
13const executableName = process.platform === "win32" ? "t3.exe" : "t3";
14const executable = ${executable};
15const ipc = process.send !== undefined;
16const child = spawn(executable, process.argv.slice(2), {
17 stdio: ipc ? ["inherit", "inherit", "inherit", "ipc"] : "inherit",
18});
19const fail = (error) => {
20 if (!error) return;
21 process.stderr.write("t3: " + error.message + "\\n");
22 child.kill("SIGTERM");
23 process.exitCode = 1;
24};
25if (ipc) {
26 process.on("message", (message) => { if (child.connected) child.send(message, fail); });
27 child.on("message", (message) => { if (process.connected) process.send(message, fail); });
28 process.on("disconnect", () => { if (child.connected) child.disconnect(); });
29}
30for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
31 process.on(signal, () => child.kill(signal));
32}
33child.on("error", (error) => { fail(error); process.exit(1); });
34child.on("exit", (code, signal) => process.exit(code ?? 128 + (constants.signals[signal] || 1)));
35`;
36}
scripts/build-npm-platform-packages.test.ts+45-7
......@@ -1,3 +1,4 @@
1import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
12import * as NodeServices from "@effect/platform-node/NodeServices";
23import { assert, it } from "@effect/vitest";
34import * as Effect from "effect/Effect";
......@@ -151,7 +152,7 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => {
151152 assert.equal(launcherManifest.name, "t3");
152153 assert.equal(launcherManifest.version, VERSION);
153154 assert.deepStrictEqual(launcherManifest.bin, { t3: "./bin/t3.js" });
154 assert.deepStrictEqual(launcherManifest.files, ["bin"]);
155 assert.deepStrictEqual(launcherManifest.files, ["bin", "dist"]);
155156 assert.deepStrictEqual(launcherManifest.optionalDependencies, {
156157 "@t3code/t3-darwin-arm64": VERSION,
157158 "@t3code/t3-linux-x64": VERSION,
......@@ -182,13 +183,50 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => {
182183
183184 // NODE_PATH stands in for node_modules: require.resolve finds the
184185 // platform package there exactly as it would after `npm install`.
186 const hostPlatform = yield* HostProcessPlatform;
187 const hostArch = yield* HostProcessArchitecture;
185188 const env = { ...process.env, NODE_PATH: fixture.outputDir } as Record<string, string>;
186 const passthrough = yield* run(process.execPath, ["bin/t3.js", "serve", "--port", "1234"], {
187 cwd: launcherDir,
188 env,
189 });
190 assert.equal(passthrough.stdout.trim(), "stub linux-x64 serve --port 1234");
191 assert.equal(passthrough.exitCode, 7);
189 if (KEYS.some((key) => key === `${hostPlatform}-${hostArch}`)) {
190 const passthrough = yield* run(process.execPath, ["bin/t3.js", "serve", "--port", "1234"], {
191 cwd: launcherDir,
192 env,
193 });
194 assert.equal(
195 passthrough.stdout.trim(),
196 `stub ${hostPlatform}-${hostArch} serve --port 1234`,
197 );
198 assert.equal(passthrough.exitCode, 7);
199
200 // Run the entry point used by already-installed service updaters from
201 // the published tarball, including their preflight arguments.
202 const installedLauncher = path.join(fixture.root, "installed-launcher");
203 yield* fs.makeDirectory(installedLauncher);
204 const unpack = yield* run(
205 "tar",
206 ["-xf", path.join(fixture.outputDir, "t3.tgz"), "-C", installedLauncher],
207 {
208 cwd: fixture.root,
209 },
210 );
211 assert.equal(unpack.exitCode, 0, unpack.stderr);
212 const legacy = yield* run(
213 process.execPath,
214 [
215 "dist/bin.mjs",
216 "__service-preflight",
217 "--database-path",
218 "a database.sqlite",
219 "--launcher-protocol",
220 "2",
221 ],
222 { cwd: path.join(installedLauncher, "package"), env },
223 );
224 assert.equal(
225 legacy.stdout.trim(),
226 `stub ${hostPlatform}-${hostArch} __service-preflight --database-path a database.sqlite --launcher-protocol 2`,
227 );
228 assert.equal(legacy.exitCode, 7);
229 }
192230
193231 const unsupported = yield* run(process.execPath, ["bin/t3.js", "--version"], {
194232 cwd: launcherDir,
scripts/build-npm-platform-packages.ts+6-1
......@@ -19,6 +19,7 @@
1919 * bundleDependencies needs an arborist tree these flattened installs are
2020 * not), whereas `npm publish <tarball>` uploads the bytes as given.
2121 */
22import { legacyCliLauncherScript } from "@t3tools/shared/legacyCliLauncher";
2223import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
2324import * as NodeServices from "@effect/platform-node/NodeServices";
2425import * as Effect from "effect/Effect";
......@@ -137,7 +138,7 @@ export function npmLauncherPackageManifest(
137138 license: serverPackageJson.license,
138139 repository: serverPackageJson.repository,
139140 bin: { t3: "./bin/t3.js" },
140 files: ["bin"],
141 files: ["bin", "dist"],
141142 optionalDependencies: Object.fromEntries(
142143 platformKeys.map((key) => [npmPlatformPackageName(key), version]),
143144 ),
......@@ -342,6 +343,10 @@ const stageLauncherPackage = Effect.fn("stageLauncherPackage")(function* (input:
342343 const launcherScript = path.join(stageDir, "bin/t3.js");
343344 yield* fs.writeFileString(launcherScript, NPM_LAUNCHER_SCRIPT);
344345 yield* fs.chmod(launcherScript, 0o755);
346 // Older service updaters and launchers run this exact path with Node.
347 // Keep it in the package so they can preflight and start the new executable.
348 yield* fs.makeDirectory(path.join(stageDir, "dist"));
349 yield* fs.writeFileString(path.join(stageDir, "dist/bin.mjs"), legacyCliLauncherScript("npm"));
345350 const readme = yield* path.fromFileUrl(new URL("../apps/server/README.md", import.meta.url));
346351 if (yield* fs.exists(readme)) {
347352 yield* fs.copyFile(readme, path.join(stageDir, "README.md"));