authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-30 21:28:25-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-30 21:28:25-07:00
log67b175a4c916ac0d5dbac809da22271db3f542d7
tree2925f5ff8b2e78551e810f132c90062fe28d29fa
parent0cf482b08bca0249ec94fbcbd0eb04fff28d9b3d
signature Signed by PGP key B5690EEEBB952194

perf(release): build and publish npm platform packages concurrently (#14028)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

4 files changed, 101 insertions(+), 15 deletions(-)

apps/server/scripts/cli.ts+6-2
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19 ServerCliDevelopmentIconTargetMissingError,19 ServerCliDevelopmentIconTargetMissingError,
20 ServerCliExecutableImportError,20 ServerCliExecutableImportError,
21} from "./cliErrors.ts";21} from "./cliErrors.ts";
22import { publishPlatformsThenLauncher } from "./publishOrder.ts";
2223
23const RepoRoot = Effect.service(Path.Path).pipe(24const RepoRoot = Effect.service(Path.Path).pipe(
24 Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))),25 Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))),
...@@ -212,7 +213,7 @@ const publishCmd = Command.make(...@@ -212,7 +213,7 @@ const publishCmd = Command.make(
212 if (config.provenance) args.push("--provenance");213 if (config.provenance) args.push("--provenance");
213 if (config.dryRun) args.push("--dry-run");214 if (config.dryRun) args.push("--dry-run");
214215
215 for (const tarball of [...platformTarballs, launcherTarball]) {216 const publish = Effect.fn("publish")(function* (tarball: string) {
216 const spawnCommand = yield* resolveSpawnCommand("npm", [...args, tarball]);217 const spawnCommand = yield* resolveSpawnCommand("npm", [...args, tarball]);
217 yield* Effect.log(`[cli] npm ${args.join(" ")} ${path.basename(tarball)}`);218 yield* Effect.log(`[cli] npm ${args.join(" ")} ${path.basename(tarball)}`);
218 yield* runCommand(219 yield* runCommand(
...@@ -223,7 +224,10 @@ const publishCmd = Command.make(...@@ -223,7 +224,10 @@ const publishCmd = Command.make(
223 shell: spawnCommand.shell,224 shell: spawnCommand.shell,
224 }),225 }),
225 );226 );
226 }227 });
228
229 // Each publish takes about 17s, so the platform packages go at once.
230 yield* publishPlatformsThenLauncher({ platformTarballs, launcherTarball, publish });
227 }),231 }),
228).pipe(232).pipe(
229 Command.withDescription(233 Command.withDescription(
apps/server/scripts/publishOrder.test.ts created+58
...@@ -0,0 +1,58 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Exit from "effect/Exit";
4import * as Latch from "effect/Latch";
5
6import { publishPlatformsThenLauncher } from "./publishOrder.ts";
7
8const platformTarballs = ["a.tgz", "b.tgz", "c.tgz"];
9const launcherTarball = "t3.tgz";
10
11// A fake publish that records when each upload starts and ends. Platform
12// uploads only end once every platform upload has started, so they have to
13// run at the same time. `failing` fails as soon as it starts.
14const fakePublish = Effect.fn("fakePublish")(function* (failing?: string) {
15 const events: Array<string> = [];
16 const allStarted = yield* Latch.make();
17 let started = 0;
18 const publish = (tarball: string) =>
19 Effect.gen(function* () {
20 events.push(`start ${tarball}`);
21 if (tarball === launcherTarball) return;
22 started += 1;
23 if (started === platformTarballs.length) yield* allStarted.open;
24 if (tarball === failing) return yield* Effect.fail(`${tarball} failed`);
25 yield* allStarted.await;
26 events.push(`end ${tarball}`);
27 });
28 return { events, publish };
29});
30
31it.effect("publishes the platform packages at once and the launcher last", () =>
32 Effect.gen(function* () {
33 const { events, publish } = yield* fakePublish();
34
35 yield* publishPlatformsThenLauncher({ platformTarballs, launcherTarball, publish });
36
37 assert.deepStrictEqual(events.slice(0, 3).toSorted(), [
38 "start a.tgz",
39 "start b.tgz",
40 "start c.tgz",
41 ]);
42 assert.strictEqual(events.at(-1), `start ${launcherTarball}`);
43 }),
44);
45
46it.effect("finishes the other uploads and skips the launcher when one fails", () =>
47 Effect.gen(function* () {
48 const { events, publish } = yield* fakePublish("b.tgz");
49
50 const exit = yield* Effect.exit(
51 publishPlatformsThenLauncher({ platformTarballs, launcherTarball, publish }),
52 );
53
54 assert.deepStrictEqual(exit, Exit.fail("b.tgz failed"));
55 assert.includeMembers(events, ["end a.tgz", "end c.tgz"]);
56 assert.notInclude(events, `start ${launcherTarball}`);
57 }),
58);
apps/server/scripts/publishOrder.ts created+26
...@@ -0,0 +1,26 @@
1import * as Effect from "effect/Effect";
2import * as Result from "effect/Result";
3
4/**
5 * Publishes every platform package at once, then the launcher, which must
6 * never be installable before the executables it points to. When one platform
7 * fails, the other uploads still finish rather than being interrupted: npm
8 * never accepts the same version twice, and an interrupted upload may already
9 * be live. The launcher is then skipped and the first failure is returned.
10 */
11export const publishPlatformsThenLauncher = <E, R>(input: {
12 readonly platformTarballs: ReadonlyArray<string>;
13 readonly launcherTarball: string;
14 readonly publish: (tarball: string) => Effect.Effect<void, E, R>;
15}) =>
16 Effect.gen(function* () {
17 const results = yield* Effect.all(input.platformTarballs.map(input.publish), {
18 concurrency: "unbounded",
19 mode: "result",
20 });
21 const failed = results.find(Result.isFailure);
22 if (failed) {
23 return yield* Effect.fail(failed.failure);
24 }
25 yield* input.publish(input.launcherTarball);
26 });
scripts/build-npm-platform-packages.ts+11-13
...@@ -429,24 +429,22 @@ export const buildNpmPlatformPackages = Effect.fn("buildNpmPlatformPackages")(fu...@@ -429,24 +429,22 @@ export const buildNpmPlatformPackages = Effect.fn("buildNpmPlatformPackages")(fu
429 yield* fs.makeDirectory(path.join(input.outputDir, NPM_PLATFORM_PACKAGE_SCOPE), {429 yield* fs.makeDirectory(path.join(input.outputDir, NPM_PLATFORM_PACKAGE_SCOPE), {
430 recursive: true,430 recursive: true,
431 });431 });
432 const outputs: Array<NpmPackageOutput> = [];432 // Each archive stages in its own scratch dir, so all of them unpack and
433 for (const { key, archive } of archives) {433 // compress at once. Sequentially this took about 45s for five archives.
434 outputs.push(434 const platformOutputs = yield* Effect.forEach(
435 yield* stagePlatformPackage({435 archives,
436 key,436 ({ key, archive }) =>
437 archive,437 stagePlatformPackage({ key, archive, outputDir: input.outputDir, version: input.version }),
438 outputDir: input.outputDir,438 { concurrency: "unbounded" },
439 version: input.version,439 );
440 }),440 const outputs = [
441 );441 ...platformOutputs,
442 }
443 outputs.push(
444 yield* stageLauncherPackage({442 yield* stageLauncherPackage({
445 outputDir: input.outputDir,443 outputDir: input.outputDir,
446 version: input.version,444 version: input.version,
447 platformKeys: archives.map((entry) => entry.key),445 platformKeys: archives.map((entry) => entry.key),
448 }),446 }),
449 );447 ];
450448
451 for (const output of outputs) {449 for (const output of outputs) {
452 yield* Effect.log(`[npm-packages] Wrote ${output.packageDir} and ${output.tarball}`);450 yield* Effect.log(`[npm-packages] Wrote ${output.packageDir} and ${output.tarball}`);