| 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,6 +19,7 @@ import { |
| 19 | ServerCliDevelopmentIconTargetMissingError, | 19 | ServerCliDevelopmentIconTargetMissingError, |
| 20 | ServerCliExecutableImportError, | 20 | ServerCliExecutableImportError, |
| 21 | } from "./cliErrors.ts"; | 21 | } from "./cliErrors.ts"; |
| 22 | import { publishPlatformsThenLauncher } from "./publishOrder.ts"; | ||
| 22 | 23 | ||
| 23 | const RepoRoot = Effect.service(Path.Path).pipe( | 24 | const 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"); |
| 214 | 215 | ||
| 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 @@ | ||
| 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,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 | ]; |
| 450 | 448 | ||
| 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}`); |