diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 319440a7ec24..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))), @@ -212,7 +213,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 +224,10 @@ const publishCmd = Command.make( shell: spawnCommand.shell, }), ); - } + }); + + // 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); + }); 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}`);