| author | |
| committer | |
| log | 67b175a4c916ac0d5dbac809da22271db3f542d7 |
| tree | 2925f5ff8b2e78551e810f132c90062fe28d29fa |
| parent | 0cf482b08bca0249ec94fbcbd0eb04fff28d9b3d |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 19 | ServerCliDevelopmentIconTargetMissingError, |
| 20 | 20 | ServerCliExecutableImportError, |
| 21 | 21 | } from "./cliErrors.ts"; |
| 22 | import { publishPlatformsThenLauncher } from "./publishOrder.ts"; | |
| 22 | 23 | |
| 23 | 24 | const RepoRoot = Effect.service(Path.Path).pipe( |
| 24 | 25 | Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))), |
| ... | ... | @@ -212,7 +213,7 @@ const publishCmd = Command.make( |
| 212 | 213 | if (config.provenance) args.push("--provenance"); |
| 213 | 214 | if (config.dryRun) args.push("--dry-run"); |
| 214 | 215 | |
| 215 | for (const tarball of [...platformTarballs, launcherTarball]) { | |
| 216 | const publish = Effect.fn("publish")(function* (tarball: string) { | |
| 216 | 217 | const spawnCommand = yield* resolveSpawnCommand("npm", [...args, tarball]); |
| 217 | 218 | yield* Effect.log(`[cli] npm ${args.join(" ")} ${path.basename(tarball)}`); |
| 218 | 219 | yield* runCommand( |
| ... | ... | @@ -223,7 +224,10 @@ const publishCmd = Command.make( |
| 223 | 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 | 232 | ).pipe( |
| 229 | 233 | Command.withDescription( |
apps/server/scripts/publishOrder.test.ts created+58| ... | ... | @@ -0,0 +1,58 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Exit from "effect/Exit"; | |
| 4 | import * as Latch from "effect/Latch"; | |
| 5 | ||
| 6 | import { publishPlatformsThenLauncher } from "./publishOrder.ts"; | |
| 7 | ||
| 8 | const platformTarballs = ["a.tgz", "b.tgz", "c.tgz"]; | |
| 9 | const 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. | |
| 14 | const 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 | ||
| 31 | it.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 | ||
| 46 | it.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 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * 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 | */ | |
| 11 | export 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 | 429 | yield* fs.makeDirectory(path.join(input.outputDir, NPM_PLATFORM_PACKAGE_SCOPE), { |
| 430 | 430 | recursive: true, |
| 431 | 431 | }); |
| 432 | const outputs: Array<NpmPackageOutput> = []; | |
| 433 | for (const { key, archive } of archives) { | |
| 434 | outputs.push( | |
| 435 | yield* stagePlatformPackage({ | |
| 436 | key, | |
| 437 | archive, | |
| 438 | outputDir: input.outputDir, | |
| 439 | version: input.version, | |
| 440 | }), | |
| 441 | ); | |
| 442 | } | |
| 443 | outputs.push( | |
| 432 | // Each archive stages in its own scratch dir, so all of them unpack and | |
| 433 | // compress at once. Sequentially this took about 45s for five archives. | |
| 434 | const platformOutputs = yield* Effect.forEach( | |
| 435 | archives, | |
| 436 | ({ key, archive }) => | |
| 437 | stagePlatformPackage({ key, archive, outputDir: input.outputDir, version: input.version }), | |
| 438 | { concurrency: "unbounded" }, | |
| 439 | ); | |
| 440 | const outputs = [ | |
| 441 | ...platformOutputs, | |
| 444 | 442 | yield* stageLauncherPackage({ |
| 445 | 443 | outputDir: input.outputDir, |
| 446 | 444 | version: input.version, |
| 447 | 445 | platformKeys: archives.map((entry) => entry.key), |
| 448 | 446 | }), |
| 449 | ); | |
| 447 | ]; | |
| 450 | 448 | |
| 451 | 449 | for (const output of outputs) { |
| 452 | 450 | yield* Effect.log(`[npm-packages] Wrote ${output.packageDir} and ${output.tarball}`); |