From d76ac07de79c37417ce9094ec22499ca3d138ea1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 27 Sep 2026 18:47:23 -0700 Subject: [PATCH 1/2] perf(release): build and publish npm platform packages concurrently Publish CLI to npm took about 226s, all of it after the last desktop build. It unpacked and repacked the five CLI archives one at a time (44s), then dry-ran (46s) and published (95s) six packages one at a time. The five platform packages stage in separate scratch dirs and do not depend on each other, so they now build, dry-run, and publish at once. The launcher still publishes last, after every platform package. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/scripts/cli.ts | 9 +++++++-- scripts/build-npm-platform-packages.ts | 24 +++++++++++------------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 319440a7ec24..d54aaee1f551 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -212,7 +212,7 @@ const publishCmd = Command.make( if (config.provenance) args.push("--provenance"); if (config.dryRun) args.push("--dry-run"); - for (const tarball of [...platformTarballs, launcherTarball]) { + const publish = Effect.fn("publish")(function* (tarball: string) { const spawnCommand = yield* resolveSpawnCommand("npm", [...args, tarball]); yield* Effect.log(`[cli] npm ${args.join(" ")} ${path.basename(tarball)}`); yield* runCommand( @@ -223,7 +223,12 @@ const publishCmd = Command.make( shell: spawnCommand.shell, }), ); - } + }); + + // The platform packages do not depend on each other, so they publish at + // once; each takes about 17s. The launcher still goes last. + yield* Effect.forEach(platformTarballs, publish, { concurrency: "unbounded", discard: true }); + yield* publish(launcherTarball); }), ).pipe( Command.withDescription( diff --git a/scripts/build-npm-platform-packages.ts b/scripts/build-npm-platform-packages.ts index 6a134d99e2c5..4f56f5b449e6 100644 --- a/scripts/build-npm-platform-packages.ts +++ b/scripts/build-npm-platform-packages.ts @@ -429,24 +429,22 @@ export const buildNpmPlatformPackages = Effect.fn("buildNpmPlatformPackages")(fu yield* fs.makeDirectory(path.join(input.outputDir, NPM_PLATFORM_PACKAGE_SCOPE), { recursive: true, }); - const outputs: Array = []; - for (const { key, archive } of archives) { - outputs.push( - yield* stagePlatformPackage({ - key, - archive, - outputDir: input.outputDir, - version: input.version, - }), - ); - } - outputs.push( + // Each archive stages in its own scratch dir, so all of them unpack and + // compress at once. Sequentially this took about 45s for five archives. + const platformOutputs = yield* Effect.forEach( + archives, + ({ key, archive }) => + stagePlatformPackage({ key, archive, outputDir: input.outputDir, version: input.version }), + { concurrency: "unbounded" }, + ); + const outputs = [ + ...platformOutputs, yield* stageLauncherPackage({ outputDir: input.outputDir, version: input.version, platformKeys: archives.map((entry) => entry.key), }), - ); + ]; for (const output of outputs) { yield* Effect.log(`[npm-packages] Wrote ${output.packageDir} and ${output.tarball}`); From ea3e9a4eafdd26b00d5cb74def09b0d9ed075e6e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 27 Sep 2026 18:54:20 -0700 Subject: [PATCH 2/2] fix(release): let concurrent npm publishes finish when one fails Effect.forEach interrupted the other in-flight platform publishes on the first failure. An interrupted upload may already be live, and npm never accepts the same version twice, so the log could hide which platforms shipped. Collect every platform result first, then fail before the launcher if any platform failed. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/scripts/cli.ts | 7 ++- apps/server/scripts/publishOrder.test.ts | 58 ++++++++++++++++++++++++ apps/server/scripts/publishOrder.ts | 26 +++++++++++ 3 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 apps/server/scripts/publishOrder.test.ts create mode 100644 apps/server/scripts/publishOrder.ts diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index d54aaee1f551..a2292fbf9a7d 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -19,6 +19,7 @@ import { ServerCliDevelopmentIconTargetMissingError, ServerCliExecutableImportError, } from "./cliErrors.ts"; +import { publishPlatformsThenLauncher } from "./publishOrder.ts"; const RepoRoot = Effect.service(Path.Path).pipe( Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))), @@ -225,10 +226,8 @@ const publishCmd = Command.make( ); }); - // The platform packages do not depend on each other, so they publish at - // once; each takes about 17s. The launcher still goes last. - yield* Effect.forEach(platformTarballs, publish, { concurrency: "unbounded", discard: true }); - yield* publish(launcherTarball); + // Each publish takes about 17s, so the platform packages go at once. + yield* publishPlatformsThenLauncher({ platformTarballs, launcherTarball, publish }); }), ).pipe( Command.withDescription( diff --git a/apps/server/scripts/publishOrder.test.ts b/apps/server/scripts/publishOrder.test.ts new file mode 100644 index 000000000000..3951511eec83 --- /dev/null +++ b/apps/server/scripts/publishOrder.test.ts @@ -0,0 +1,58 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Latch from "effect/Latch"; + +import { publishPlatformsThenLauncher } from "./publishOrder.ts"; + +const platformTarballs = ["a.tgz", "b.tgz", "c.tgz"]; +const launcherTarball = "t3.tgz"; + +// A fake publish that records when each upload starts and ends. Platform +// uploads only end once every platform upload has started, so they have to +// run at the same time. `failing` fails as soon as it starts. +const fakePublish = Effect.fn("fakePublish")(function* (failing?: string) { + const events: Array = []; + const allStarted = yield* Latch.make(); + let started = 0; + const publish = (tarball: string) => + Effect.gen(function* () { + events.push(`start ${tarball}`); + if (tarball === launcherTarball) return; + started += 1; + if (started === platformTarballs.length) yield* allStarted.open; + if (tarball === failing) return yield* Effect.fail(`${tarball} failed`); + yield* allStarted.await; + events.push(`end ${tarball}`); + }); + return { events, publish }; +}); + +it.effect("publishes the platform packages at once and the launcher last", () => + Effect.gen(function* () { + const { events, publish } = yield* fakePublish(); + + yield* publishPlatformsThenLauncher({ platformTarballs, launcherTarball, publish }); + + assert.deepStrictEqual(events.slice(0, 3).toSorted(), [ + "start a.tgz", + "start b.tgz", + "start c.tgz", + ]); + assert.strictEqual(events.at(-1), `start ${launcherTarball}`); + }), +); + +it.effect("finishes the other uploads and skips the launcher when one fails", () => + Effect.gen(function* () { + const { events, publish } = yield* fakePublish("b.tgz"); + + const exit = yield* Effect.exit( + publishPlatformsThenLauncher({ platformTarballs, launcherTarball, publish }), + ); + + assert.deepStrictEqual(exit, Exit.fail("b.tgz failed")); + assert.includeMembers(events, ["end a.tgz", "end c.tgz"]); + assert.notInclude(events, `start ${launcherTarball}`); + }), +); diff --git a/apps/server/scripts/publishOrder.ts b/apps/server/scripts/publishOrder.ts new file mode 100644 index 000000000000..b53fbebdc736 --- /dev/null +++ b/apps/server/scripts/publishOrder.ts @@ -0,0 +1,26 @@ +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; + +/** + * Publishes every platform package at once, then the launcher, which must + * never be installable before the executables it points to. When one platform + * fails, the other uploads still finish rather than being interrupted: npm + * never accepts the same version twice, and an interrupted upload may already + * be live. The launcher is then skipped and the first failure is returned. + */ +export const publishPlatformsThenLauncher = (input: { + readonly platformTarballs: ReadonlyArray; + readonly launcherTarball: string; + readonly publish: (tarball: string) => Effect.Effect; +}) => + Effect.gen(function* () { + const results = yield* Effect.all(input.platformTarballs.map(input.publish), { + concurrency: "unbounded", + mode: "result", + }); + const failed = results.find(Result.isFailure); + if (failed) { + return yield* Effect.fail(failed.failure); + } + yield* input.publish(input.launcherTarball); + });