fix(release): retry npm registry propagation

This commit is contained in:
Tyler Slaton
2026-09-02 15:58:33 -07:00
parent 0dd132dc0a
commit 09f3611ee2
3 changed files with 150 additions and 29 deletions
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from "vitest";
import { loadPublishedChannelsManifest } from "./channels-registry.js";
function npm404(): Error & { stderr: string } {
return Object.assign(new Error("npm view failed"), {
stderr: "npm error code E404",
});
}
describe("loadPublishedChannelsManifest", () => {
it("waits for a just-published package to become visible", async () => {
const manifest = {
name: "@copilotkit/channels-whatsapp",
version: "0.9.1",
};
const lookup = vi
.fn<() => string>()
.mockImplementationOnce(() => {
throw npm404();
})
.mockReturnValueOnce(JSON.stringify(manifest));
const sleep = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
const onRetry = vi.fn();
await expect(
loadPublishedChannelsManifest("@copilotkit/channels-whatsapp", "0.9.1", {
lookup,
sleep,
onRetry,
maxAttempts: 3,
retryDelayMs: 10_000,
}),
).resolves.toEqual(manifest);
expect(lookup).toHaveBeenCalledTimes(2);
expect(sleep).toHaveBeenCalledOnce();
expect(sleep).toHaveBeenCalledWith(10_000);
expect(onRetry).toHaveBeenCalledWith(
"@copilotkit/channels-whatsapp@0.9.1 is not visible on npm yet; retrying in 10s (1/3).",
);
});
it("does not retry non-404 registry failures", async () => {
const failure = Object.assign(new Error("npm view failed"), {
stderr: "npm error code E429",
});
const lookup = vi.fn<() => string>(() => {
throw failure;
});
const sleep = vi.fn<() => Promise<void>>();
await expect(
loadPublishedChannelsManifest("@copilotkit/channels-core", "0.9.1", {
lookup,
sleep,
}),
).rejects.toBe(failure);
expect(sleep).not.toHaveBeenCalled();
});
it("fails with release guidance after the visibility window expires", async () => {
const lookup = vi.fn<() => string>(() => {
throw npm404();
});
await expect(
loadPublishedChannelsManifest("@copilotkit/channels-core", "0.9.1", {
lookup,
sleep: async () => {},
onRetry: () => {},
maxAttempts: 2,
}),
).rejects.toThrow(
"registry is missing @copilotkit/channels-core@0.9.1 after 2 attempts; publish channels-core and every adapter before publishing @copilotkit/channels",
);
expect(lookup).toHaveBeenCalledTimes(2);
});
});
+59
View File
@@ -0,0 +1,59 @@
import type { PackedManifest } from "./channels-umbrella.js";
const DEFAULT_MAX_ATTEMPTS = 31;
const DEFAULT_RETRY_DELAY_MS = 10_000;
interface LoadPublishedChannelsManifestOptions {
lookup: () => string;
maxAttempts?: number;
retryDelayMs?: number;
sleep?: (delayMs: number) => Promise<void>;
onRetry?: (message: string) => void;
}
function isRegistryMissing(error: unknown): boolean {
const stderr =
typeof error === "object" && error !== null && "stderr" in error
? String(error.stderr)
: "";
return stderr.includes("E404");
}
function sleep(delayMs: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, delayMs));
}
export async function loadPublishedChannelsManifest(
name: string,
version: string,
{
lookup,
maxAttempts = DEFAULT_MAX_ATTEMPTS,
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
sleep: wait = sleep,
onRetry = console.warn,
}: LoadPublishedChannelsManifestOptions,
): Promise<PackedManifest> {
const spec = `${name}@${version}`;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return JSON.parse(lookup()) as PackedManifest;
} catch (error) {
if (!isRegistryMissing(error)) throw error;
if (attempt === maxAttempts) {
throw new Error(
`registry is missing ${spec} after ${maxAttempts} attempts; publish channels-core and every adapter before publishing @copilotkit/channels`,
{ cause: error },
);
}
onRetry(
`${spec} is not visible on npm yet; retrying in ${retryDelayMs / 1000}s (${attempt}/${maxAttempts}).`,
);
await wait(retryDelayMs);
}
}
throw new Error(`unreachable registry lookup state for ${spec}`);
}
+13 -29
View File
@@ -20,6 +20,7 @@ import {
packPackage,
workspaceDependencyClosure,
} from "./lib/pack-workspace.js";
import { loadPublishedChannelsManifest } from "./lib/channels-registry.js";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
@@ -80,30 +81,10 @@ function packLocalFamily(tarballDir: string): {
return { manifests, tarballs };
}
function loadRegistryManifest(name: string, version: string): PackedManifest {
try {
return JSON.parse(
capture("npm", ["view", `${name}@${version}`, "--json"]),
) as PackedManifest;
} catch (error) {
const stderr =
typeof error === "object" && error !== null && "stderr" in error
? String(error.stderr)
: "";
if (stderr.includes("E404")) {
throw new Error(
`registry is missing ${name}@${version}; publish channels-core and every adapter before publishing @copilotkit/channels`,
{ cause: error },
);
}
throw error;
}
}
function loadRegistrySnapshot(tarballDir: string): {
async function loadRegistrySnapshot(tarballDir: string): Promise<{
manifests: Map<string, PackedManifest>;
tarballs: Map<string, string>;
} {
}> {
const umbrellaName = "@copilotkit/channels";
const { manifest: umbrella, tarball } = packPackage(umbrellaName, tarballDir);
const manifests = new Map<string, PackedManifest>([[umbrellaName, umbrella]]);
@@ -115,7 +96,12 @@ function loadRegistrySnapshot(tarballDir: string): {
throw new Error(`packed umbrella is missing ${name}`);
}
manifests.set(name, loadRegistryManifest(name, version));
manifests.set(
name,
await loadPublishedChannelsManifest(name, version, {
lookup: () => capture("npm", ["view", `${name}@${version}`, "--json"]),
}),
);
}
return {
@@ -237,7 +223,7 @@ void [createChannel, slack, teams, discord, telegram, whatsapp, view];
);
}
function main(): void {
async function main(): Promise<void> {
const registryMode = process.argv.includes("--registry");
const temp = mkdtempSync(join(tmpdir(), "channels-umbrella-"));
const tarballDir = join(temp, "tarballs");
@@ -247,7 +233,7 @@ function main(): void {
try {
const { manifests, tarballs } = registryMode
? loadRegistrySnapshot(tarballDir)
? await loadRegistrySnapshot(tarballDir)
: packLocalFamily(tarballDir);
const problems = validatePackedManifests(manifests);
if (problems.length) {
@@ -277,9 +263,7 @@ function main(): void {
}
}
try {
main();
} catch (error) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
}
});