Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions apps/server/scripts/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))),
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
58 changes: 58 additions & 0 deletions apps/server/scripts/publishOrder.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> = [];
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}`);
}),
);
26 changes: 26 additions & 0 deletions apps/server/scripts/publishOrder.ts
Original file line number Diff line number Diff line change
@@ -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 = <E, R>(input: {
readonly platformTarballs: ReadonlyArray<string>;
readonly launcherTarball: string;
readonly publish: (tarball: string) => Effect.Effect<void, E, R>;
}) =>
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);
});
24 changes: 11 additions & 13 deletions scripts/build-npm-platform-packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NpmPackageOutput> = [];
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}`);
Expand Down
Loading