Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,7 @@ jobs:
]
if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.test.result == 'success' && needs.test_server.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }}
runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 15
timeout-minutes: 35
permissions:
contents: read
id-token: write
Expand Down
20 changes: 18 additions & 2 deletions apps/server/scripts/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as FileSystem from "effect/FileSystem";
import * as Logger from "effect/Logger";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import { Command, Flag } from "effect/cli";
import { ChildProcess, ChildProcessSpawner } from "effect/process";

Expand All @@ -19,7 +20,7 @@ import {
ServerCliDevelopmentIconTargetMissingError,
ServerCliExecutableImportError,
} from "./cliErrors.ts";
import { publishPlatformsThenLauncher } from "./publishOrder.ts";
import { publishPlatformsThenLauncher, waitUntilServed } from "./publishOrder.ts";

const RepoRoot = Effect.service(Path.Path).pipe(
Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))),
Expand Down Expand Up @@ -166,6 +167,11 @@ const buildExeCmd = Command.make(
// publish subcommand
// ---------------------------------------------------------------------------

const LauncherManifest = Schema.Struct({
optionalDependencies: Schema.Record(Schema.String, Schema.String),
});
const decodeLauncherManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(LauncherManifest));

/**
* Publishes the tarballs scripts/build-npm-platform-packages.ts produced:
* every `@t3code/t3-<platform>.tgz` first, `t3.tgz` (the launcher) last, so
Expand Down Expand Up @@ -208,6 +214,9 @@ const publishCmd = Command.make(
if (!(yield* fs.exists(launcherTarball))) {
return yield* new ServerCliBuildAssetMissingError({ assetPath: launcherTarball });
}
const launcherManifest = yield* decodeLauncherManifest(
yield* fs.readFileString(path.join(packagesDir, "t3", "package.json")),
);

const args = ["publish", "--access", config.access, "--tag", config.tag];
if (config.provenance) args.push("--provenance");
Expand All @@ -227,7 +236,14 @@ const publishCmd = Command.make(
});

// Each publish takes about 17s, so the platform packages go at once.
yield* publishPlatformsThenLauncher({ platformTarballs, launcherTarball, publish });
// npm serves a new version only after scanning it for minutes (#14409).
const platforms = config.dryRun ? {} : launcherManifest.optionalDependencies;
yield* publishPlatformsThenLauncher({
platformTarballs,
launcherTarball,
publish,
waitForPlatforms: waitUntilServed(platforms),
});
}),
).pipe(
Command.withDescription(
Expand Down
79 changes: 78 additions & 1 deletion apps/server/scripts/publishOrder.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { assert, it } from "@effect/vitest";
import * as Cause from "effect/Cause";
import * as Clock from "effect/Clock";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Fiber from "effect/Fiber";
import * as Latch from "effect/Latch";
import * as TestClock from "effect/testing/TestClock";

import { publishPlatformsThenLauncher } from "./publishOrder.ts";
import { npmServesTarball, publishPlatformsThenLauncher, waitUntilServed } from "./publishOrder.ts";

const platformTarballs = ["a.tgz", "b.tgz", "c.tgz"];
const launcherTarball = "t3.tgz";
Expand Down Expand Up @@ -56,3 +60,76 @@ it.effect("finishes the other uploads and skips the launcher when one fails", ()
assert.notInclude(events, `start ${launcherTarball}`);
}),
);

it.effect("waits for the platform packages before publishing the launcher", () =>
Effect.gen(function* () {
const { events, publish } = yield* fakePublish();
const waitForPlatforms = Effect.sync(() => events.push("wait"));

yield* publishPlatformsThenLauncher({
platformTarballs,
launcherTarball,
publish,
waitForPlatforms,
});

assert.deepStrictEqual(events.slice(-2), ["wait", `start ${launcherTarball}`]);

const failed = yield* fakePublish();
const exit = yield* Effect.exit(
publishPlatformsThenLauncher({
platformTarballs,
launcherTarball,
publish: failed.publish,
waitForPlatforms: Effect.fail("not served"),
}),
);
assert.deepStrictEqual(exit, Exit.fail("not served"));
assert.notInclude(failed.events, `start ${launcherTarball}`);
}),
);

// npm accepts a version long before it serves it (#14409); each fake package
// is served from its own virtual time on.
const fakeRegistry = (servedAt: Record<string, number>) => (name: string) =>
Effect.map(Clock.currentTimeMillis, (now) => now >= servedAt[name]!);
const packages = { a: "1.0.0", b: "1.0.0" };

it.effect("waits until npm serves every listed platform version", () =>
Effect.gen(function* () {
const isServed = fakeRegistry({ a: 0, b: 10 * 60_000 });
const fiber = yield* Effect.forkChild(waitUntilServed(packages, isServed));

yield* TestClock.adjust("9 minutes");
assert.isUndefined(fiber.pollUnsafe());
yield* TestClock.adjust("2 minutes");
assert.deepStrictEqual(yield* Fiber.await(fiber), Exit.void);
}),
);

it.effect("fails once npm still does not serve a version after 20 minutes", () =>
Effect.gen(function* () {
const isServed = fakeRegistry({ a: 0, b: Number.POSITIVE_INFINITY });
const fiber = yield* Effect.forkChild(waitUntilServed(packages, isServed));

yield* TestClock.adjust("20 minutes");
assert.isTrue(Cause.isTimeoutError(yield* Effect.flip(Fiber.join(fiber))));
}),
);

it.effect("counts a version as served only once npm serves its tarball", () =>
Effect.gen(function* () {
const tarball = "https://registry.example/t3-a-1.0.0.tgz";
const fetchServing = (tarballStatus: number) =>
(async (input: Parameters<typeof globalThis.fetch>[0]) =>
input === tarball
? new Response(null, { status: tarballStatus })
: Response.json({ dist: { tarball } })) as typeof globalThis.fetch;

assert.isFalse(yield* npmServesTarball("a", "1.0.0", fetchServing(404)));
assert.isTrue(yield* npmServesTarball("a", "1.0.0", fetchServing(200)));

const offline = (() => Promise.reject(new Error("offline"))) as typeof globalThis.fetch;
assert.isFalse(yield* npmServesTarball("a", "1.0.0", offline));
}),
);
29 changes: 28 additions & 1 deletion apps/server/scripts/publishOrder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import * as Result from "effect/Result";
* 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.
* `waitForPlatforms` runs between the two, and a failure there skips it too.
*/
export const publishPlatformsThenLauncher = <E, R>(input: {
export const publishPlatformsThenLauncher = <E, R, WE = never, WR = never>(input: {
readonly platformTarballs: ReadonlyArray<string>;
readonly launcherTarball: string;
readonly publish: (tarball: string) => Effect.Effect<void, E, R>;
readonly waitForPlatforms?: Effect.Effect<void, WE, WR>;
}) =>
Effect.gen(function* () {
const results = yield* Effect.all(input.platformTarballs.map(input.publish), {
Expand All @@ -22,5 +24,30 @@ export const publishPlatformsThenLauncher = <E, R>(input: {
if (failed) {
return yield* Effect.fail(failed.failure);
}
if (input.waitForPlatforms) yield* input.waitForPlatforms;
yield* input.publish(input.launcherTarball);
});

/** Whether npm serves the tarball of `name@version`, not only accepts it. */
export const npmServesTarball = (name: string, version: string, fetch = globalThis.fetch) =>
Effect.tryPromise(async (signal) => {
const response = await fetch(`https://registry.npmjs.org/${name}/${version}`, { signal });
const { dist } = response.ok ? ((await response.json()) as NpmVersion) : {};
return !!dist?.tarball && (await fetch(dist.tarball, { method: "HEAD", signal })).ok;
}).pipe(Effect.orElseSucceed(() => false));

type NpmVersion = { readonly dist?: { readonly tarball?: string } };

/** Polls until npm serves every `name: version`, logging the rest, for up to 20 minutes. */
export const waitUntilServed = (packages: Record<string, string>, isServed = npmServesTarball) =>
Effect.gen(function* () {
let pending = Object.entries(packages);
while (true) {
const served = yield* Effect.forEach(pending, ([name, version]) => isServed(name, version));
pending = pending.filter((_, index) => !served[index]);
if (pending.length === 0) return;
const missing = pending.map(([name, version]) => `${name}@${version}`).join(", ");
yield* Effect.log(`[cli] waiting for npm to serve ${missing}`);
yield* Effect.sleep("15 seconds");
}
}).pipe(Effect.timeout("20 minutes"));
Loading