mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
ci: set up preview publishing (#1084)
Today nothing reaches npm between two release-please releases, so trying a merged but unreleased change means building from source. This adds a continuously published preview channel. Every CI-green push to `main` that is not a release merge publishes `<package.json version, patch bumped>-preview.N` under the `preview` dist-tag, tags the commit `v<version>`, and dispatches the same agent registry update a stable release does. With `main` at 0.73.0 that gives `0.73.1-preview.1`, `0.73.1-preview.2`, and so on; `N` restarts at 1 whenever a release moves the base. `latest` is untouched. ## Approach - **Added to `publish.yml` rather than a new workflow.** npm binds a trusted publisher to one repo + workflow filename, and a package gets only one. A separate file would fail OIDC and cost the stable path its publisher. - **Gated on the `CI` workflow succeeding** (`workflow_run`), so a red commit is never published. release-please's release merge is excluded by the head commit's author and subject — its `releases_created` output is not reachable from a different workflow run. - **`N` is the max of the npm registry and local git tags.** npm reserves versions forever, even after `unpublish`, so a reused `N` is a hard failure; but the registry is CDN-served and lags, which the tags cover. A registry read failure aborts rather than guessing. - **Base is patch+1, not release-please's prediction**, which moves mid-flight and would reset `N` under already-published previews. - **The version is applied to the CI working tree only**, never committed — `package.json` and the release-please manifest stay release-please's to own. - **`--tag preview` is mandatory:** `npm publish` defaults to `latest` even for a semver prerelease. - **Three jobs.** `publish-npm-preview` mirrors `publish-npm` and only publishes (no repo write access); `publish-tag-preview` only tags, so a failed tag is retryable with _Re-run failed jobs_ without re-publishing an immutable version; `trigger-registry-update` is shared with the stable path. Both downstream jobs gate on a `published` output the publish step sets, not on job status — the tag push happens after the publish, so a red job can still mean npm has the version. ## Next step The publish is `npm publish --dry-run` for now. The `workflow_run` trigger and the `release` environment's protected-branch policy mean this job cannot run off the default branch at all, so merging this is the only way to exercise the trigger, the gate and the version calculation — and with `published` unset, the rehearsal neither tags nor touches the registry. Once one run looks right, a follow-up drops `--dry-run` and adds `echo "published=true" >> "$GITHUB_OUTPUT"` to that step. Nothing else changes.
This commit is contained in:
202
scripts/next-preview-version.mjs
Executable file
202
scripts/next-preview-version.mjs
Executable file
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env node
|
||||
//
|
||||
// Prints the next preview version for this package, e.g. `0.73.1-preview.4`.
|
||||
//
|
||||
// The base is package.json's version with the patch incremented, so a preview always
|
||||
// sorts above the last release and below whatever release follows it. `-preview.N`
|
||||
// counts from 1 per base and restarts whenever release-please moves package.json on.
|
||||
//
|
||||
// Basing the preview on release-please's *predicted* next version would be more
|
||||
// truthful — it would give `0.74.0-preview.N` when a `feat:` is queued — but the
|
||||
// prediction moves mid-flight: a `fix:` opens a 0.73.1 release PR, a later `feat:`
|
||||
// moves it to 0.74.0, and N resets under previews that were already published. If the
|
||||
// prediction ever moved down, the next preview would sort below the previous one.
|
||||
// package.json only ever increases, so patch+1 is monotonic in every case:
|
||||
// 0.73.1-preview.9 < 0.73.2-preview.1 and < 0.74.1-preview.1, whichever way the next
|
||||
// release goes. The cost is that `0.73.1-preview.4` is not a promise that 0.73.1 will
|
||||
// ship.
|
||||
//
|
||||
// N has to be a number nobody has used, and two sources are consulted because either
|
||||
// alone is unsafe:
|
||||
//
|
||||
// * the npm registry, authoritative for what is taken — npm versions are immutable
|
||||
// and stay reserved even after `npm unpublish`, so a taken N is a hard E403 at
|
||||
// publish time — but served from a CDN, so it can lag a publish by minutes;
|
||||
// * git tags, written by the workflow after each publish and strongly consistent,
|
||||
// which cover that window and any unpublish.
|
||||
//
|
||||
// The workflow publishes before it tags, so a version can exist on npm without a tag
|
||||
// but never the other way round. That asymmetry is why the registry is the authority
|
||||
// and the tags are only a floor, and why a registry read failure is fatal here rather
|
||||
// than falling back to the tags alone.
|
||||
//
|
||||
// See docs/RELEASES.md.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const DEFAULT_REGISTRY = "https://registry.npmjs.org";
|
||||
const PREVIEW_ID = "preview";
|
||||
const FETCH_ATTEMPTS = 3;
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const BACKOFF_MS = 500;
|
||||
|
||||
/** X.Y.Z: no prerelease, no build metadata, no leading zeros. */
|
||||
const RELEASE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
||||
|
||||
/** The base a preview anticipates: package.json's version with the patch bumped. */
|
||||
export function bumpPatch(version) {
|
||||
const match = RELEASE_VERSION.exec(String(version ?? "").trim());
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`expected a plain X.Y.Z release version, got ${JSON.stringify(version)}. ` +
|
||||
`release-please owns package.json's version; a prerelease or a range here means ` +
|
||||
`something else has edited it.`,
|
||||
);
|
||||
}
|
||||
const [, major, minor, patch] = match;
|
||||
return `${major}.${minor}.${Number(patch) + 1}`;
|
||||
}
|
||||
|
||||
function escapeRegExp(literal) {
|
||||
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Matcher for `<base>-preview.<n>`. `0731-preview.1` would match a base of `0.73.1`. `n`
|
||||
* is a semver-legal numeric identifier, so leading zeros are rejected; npm would never
|
||||
* have accepted `preview.01` in the first place.
|
||||
*/
|
||||
export function previewPattern(base) {
|
||||
return new RegExp(`^${escapeRegExp(base)}-${PREVIEW_ID}\\.(0|[1-9]\\d*)$`);
|
||||
}
|
||||
|
||||
/** The N in `<base>-preview.N`, or null for anything else. */
|
||||
export function previewNumber(candidate, base) {
|
||||
const match = previewPattern(base).exec(String(candidate ?? "").trim());
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
/** `refs/tags/v1.2.3`, `v1.2.3` and `1.2.3` all become `1.2.3`. */
|
||||
export function versionFromTag(tag) {
|
||||
return String(tag ?? "")
|
||||
.trim()
|
||||
.replace(/^refs\/tags\//, "")
|
||||
.replace(/^v/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Highest N among the candidates, or 0 when none match.
|
||||
*/
|
||||
export function highestPreviewNumber(candidates, base) {
|
||||
let highest = 0;
|
||||
for (const candidate of candidates) {
|
||||
const n = previewNumber(candidate, base);
|
||||
if (n !== null && n > highest) {
|
||||
highest = n;
|
||||
}
|
||||
}
|
||||
return highest;
|
||||
}
|
||||
|
||||
export function nextPreviewVersion({ base, registryVersions = [], tags = [] }) {
|
||||
const taken = Math.max(
|
||||
highestPreviewNumber(registryVersions, base),
|
||||
highestPreviewNumber(tags.map(versionFromTag), base),
|
||||
);
|
||||
return `${base}-${PREVIEW_ID}.${taken + 1}`;
|
||||
}
|
||||
|
||||
/** Every version this package has ever published, per the registry. */
|
||||
export async function fetchPublishedVersions(name, options = {}) {
|
||||
const {
|
||||
registry = DEFAULT_REGISTRY,
|
||||
fetchImpl = fetch,
|
||||
attempts = FETCH_ATTEMPTS,
|
||||
backoffMs = BACKOFF_MS,
|
||||
} = options;
|
||||
const url = `${registry}/${name.replace("/", "%2f")}`;
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
// The abbreviated packument is a fraction of the full one — tens of kilobytes
|
||||
// rather than megabytes — and still carries every version key.
|
||||
headers: { accept: "application/vnd.npm.install-v1+json" },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (response.status === 404) {
|
||||
// Never published. A definite answer, not a failure.
|
||||
return [];
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`${url} answered ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const packument = await response.json();
|
||||
// `versions` on a packument is always an object keyed by version, so there is
|
||||
// none of the "a single version comes back as a bare string" shape that
|
||||
// `npm view <pkg> versions --json` has.
|
||||
return Object.keys(packument.versions ?? {});
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < attempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, backoffMs * 2 ** (attempt - 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`could not read published versions for ${name} after ${attempts} attempts: ${lastError}. ` +
|
||||
`Refusing to guess a preview number: the registry is the only record of versions ` +
|
||||
`that were published and then unpublished, and those can never be reused.`,
|
||||
{ cause: lastError },
|
||||
);
|
||||
}
|
||||
|
||||
function localPreviewTags(base) {
|
||||
try {
|
||||
return execFileSync("git", ["tag", "--list", `v${base}-${PREVIEW_ID}.*`], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
})
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
// No git, no repository, or a clone without tags. Tags are only a floor under the
|
||||
// registry, so carry on rather than fail.
|
||||
process.stderr.write("warning: could not list git tags; using the registry alone\n");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function readFlag(argv, name) {
|
||||
const index = argv.indexOf(name);
|
||||
return index === -1 ? undefined : argv[index + 1];
|
||||
}
|
||||
|
||||
async function main(argv) {
|
||||
const packageJsonPath = readFlag(argv, "--package-json") ?? "package.json";
|
||||
const manifest = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
||||
const base = bumpPatch(manifest.version);
|
||||
const registryVersions = await fetchPublishedVersions(manifest.name, {
|
||||
registry: readFlag(argv, "--registry") ?? DEFAULT_REGISTRY,
|
||||
});
|
||||
const tags = argv.includes("--no-git-tags") ? [] : localPreviewTags(base);
|
||||
|
||||
// Diagnostics on stderr so stdout stays a single machine-readable line.
|
||||
process.stderr.write(
|
||||
`${manifest.name} ${manifest.version} -> base ${base}; registry high-water ` +
|
||||
`${highestPreviewNumber(registryVersions, base)}, tag high-water ` +
|
||||
`${highestPreviewNumber(tags.map(versionFromTag), base)}\n`,
|
||||
);
|
||||
process.stdout.write(`${nextPreviewVersion({ base, registryVersions, tags })}\n`);
|
||||
}
|
||||
|
||||
// Only when invoked as a program, so the helpers above can be imported by the test
|
||||
// without doing any I/O.
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
main(process.argv.slice(2)).catch((error) => {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
249
scripts/next-preview-version.test.mjs
Normal file
249
scripts/next-preview-version.test.mjs
Normal file
@@ -0,0 +1,249 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
bumpPatch,
|
||||
previewNumber,
|
||||
versionFromTag,
|
||||
highestPreviewNumber,
|
||||
nextPreviewVersion,
|
||||
fetchPublishedVersions,
|
||||
} from "./next-preview-version.mjs";
|
||||
|
||||
const PACKAGE = "@agentclientprotocol/claude-agent-acp";
|
||||
|
||||
/** A packument response, abbreviated the way the registry returns it. */
|
||||
function packument(versions) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ name: PACKAGE, versions }),
|
||||
};
|
||||
}
|
||||
|
||||
function failure(status, statusText = "Internal Server Error") {
|
||||
return { ok: false, status, statusText, json: async () => ({}) };
|
||||
}
|
||||
|
||||
describe("bumpPatch", () => {
|
||||
it("increments the patch", () => {
|
||||
expect(bumpPatch("0.73.0")).toBe("0.73.1");
|
||||
expect(bumpPatch("1.0.0")).toBe("1.0.1");
|
||||
expect(bumpPatch("0.0.0")).toBe("0.0.1");
|
||||
});
|
||||
|
||||
it("increments numerically rather than by concatenation", () => {
|
||||
expect(bumpPatch("0.73.9")).toBe("0.73.10");
|
||||
expect(bumpPatch("0.73.19")).toBe("0.73.20");
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace", () => {
|
||||
expect(bumpPatch(" 0.73.0 ")).toBe("0.73.1");
|
||||
});
|
||||
|
||||
// release-please owns package.json's version, so anything that is not a plain
|
||||
// release means something else has edited it. Failing loudly beats guessing.
|
||||
it.each([
|
||||
["a two-part version", "0.73"],
|
||||
["a four-part version", "0.73.0.1"],
|
||||
["a prerelease", "0.73.0-preview.1"],
|
||||
["build metadata", "0.73.0+build.1"],
|
||||
["a v prefix", "v0.73.0"],
|
||||
["a range", "^0.73.0"],
|
||||
["leading zeros", "01.2.3"],
|
||||
["the empty string", ""],
|
||||
["undefined", undefined],
|
||||
])("throws on %s", (_label, version) => {
|
||||
expect(() => bumpPatch(version)).toThrow(/plain X\.Y\.Z release version/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewNumber", () => {
|
||||
it("reads N out of a matching version", () => {
|
||||
expect(previewNumber("0.73.1-preview.1", "0.73.1")).toBe(1);
|
||||
expect(previewNumber("0.73.1-preview.10", "0.73.1")).toBe(10);
|
||||
});
|
||||
|
||||
it("treats zero as a number rather than as no match", () => {
|
||||
expect(previewNumber("0.73.1-preview.0", "0.73.1")).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a plain release", "0.73.1"],
|
||||
["a different base", "0.73.2-preview.1"],
|
||||
["a missing number", "0.73.1-preview"],
|
||||
["leading zeros in N", "0.73.1-preview.01"],
|
||||
["a dotted N", "0.73.1-preview.1.2"],
|
||||
["build metadata", "0.73.1-preview.1+build.5"],
|
||||
["another prerelease id", "0.73.1-alpha.1"],
|
||||
// Proves the base's dots are escaped rather than matching any character.
|
||||
["a base whose dots are elided", "0731-preview.1"],
|
||||
// Proves the pattern is anchored at the start.
|
||||
["a base with a numeric prefix", "10.73.1-preview.1"],
|
||||
])("returns null for %s", (_label, candidate) => {
|
||||
expect(previewNumber(candidate, "0.73.1")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("versionFromTag", () => {
|
||||
it("strips a refs/tags/ prefix and a v", () => {
|
||||
expect(versionFromTag("refs/tags/v0.73.1-preview.3")).toBe("0.73.1-preview.3");
|
||||
expect(versionFromTag("v0.73.1")).toBe("0.73.1");
|
||||
expect(versionFromTag("0.73.1")).toBe("0.73.1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("highestPreviewNumber", () => {
|
||||
it("is 0 when nothing matches", () => {
|
||||
expect(highestPreviewNumber([], "0.73.1")).toBe(0);
|
||||
expect(highestPreviewNumber(["0.73.1", "0.72.1-preview.4"], "0.73.1")).toBe(0);
|
||||
});
|
||||
|
||||
// Sorted as strings, `preview.9` would beat `preview.10`.
|
||||
it("compares numerically, not lexicographically", () => {
|
||||
expect(highestPreviewNumber(["0.73.1-preview.9", "0.73.1-preview.10"], "0.73.1")).toBe(10);
|
||||
expect(highestPreviewNumber(["0.73.1-preview.10", "0.73.1-preview.9"], "0.73.1")).toBe(10);
|
||||
});
|
||||
|
||||
it("ignores order and duplicates", () => {
|
||||
const candidates = [
|
||||
"0.73.1-preview.2",
|
||||
"0.73.1-preview.7",
|
||||
"0.73.1-preview.2",
|
||||
"0.73.1-preview.5",
|
||||
];
|
||||
expect(highestPreviewNumber(candidates, "0.73.1")).toBe(7);
|
||||
});
|
||||
|
||||
it("counts only the requested base", () => {
|
||||
const candidates = ["0.72.1-preview.99", "0.73.1-preview.3", "0.74.1-preview.42"];
|
||||
expect(highestPreviewNumber(candidates, "0.73.1")).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextPreviewVersion", () => {
|
||||
it("starts at 1 with no history", () => {
|
||||
expect(nextPreviewVersion({ base: "0.73.1" })).toBe("0.73.1-preview.1");
|
||||
});
|
||||
|
||||
it("continues from the registry", () => {
|
||||
const registryVersions = ["0.73.0", "0.73.1-preview.1", "0.73.1-preview.2", "0.73.1-preview.3"];
|
||||
expect(nextPreviewVersion({ base: "0.73.1", registryVersions })).toBe("0.73.1-preview.4");
|
||||
});
|
||||
|
||||
// The registry is CDN-served and can lag a publish by minutes; the tag the previous
|
||||
// run wrote is what closes that window.
|
||||
it("uses the tags as a floor when the registry is behind", () => {
|
||||
const next = nextPreviewVersion({
|
||||
base: "0.73.1",
|
||||
registryVersions: ["0.73.1-preview.1"],
|
||||
tags: ["refs/tags/v0.73.1-preview.3"],
|
||||
});
|
||||
expect(next).toBe("0.73.1-preview.4");
|
||||
});
|
||||
|
||||
// The converse: publish succeeded but the tag step did not, so the registry is
|
||||
// ahead. It stays authoritative.
|
||||
it("keeps the registry authoritative when the tags are behind", () => {
|
||||
const next = nextPreviewVersion({
|
||||
base: "0.73.1",
|
||||
registryVersions: ["0.73.1-preview.1", "0.73.1-preview.2", "0.73.1-preview.3"],
|
||||
tags: ["v0.73.1-preview.1"],
|
||||
});
|
||||
expect(next).toBe("0.73.1-preview.4");
|
||||
});
|
||||
|
||||
it("restarts at 1 once a release moves the base", () => {
|
||||
const next = nextPreviewVersion({
|
||||
base: "0.73.1",
|
||||
registryVersions: ["0.72.1-preview.7", "0.73.0"],
|
||||
tags: ["v0.72.1-preview.7"],
|
||||
});
|
||||
expect(next).toBe("0.73.1-preview.1");
|
||||
});
|
||||
|
||||
it("composes with bumpPatch", () => {
|
||||
const next = nextPreviewVersion({
|
||||
base: bumpPatch("0.73.0"),
|
||||
registryVersions: ["0.73.0"],
|
||||
tags: ["v0.73.0"],
|
||||
});
|
||||
expect(next).toBe("0.73.1-preview.1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchPublishedVersions", () => {
|
||||
const options = (fetchImpl) => ({ fetchImpl, backoffMs: 0 });
|
||||
|
||||
it("returns the version keys of the packument", async () => {
|
||||
const fetchImpl = vi.fn(async () => packument({ "0.73.0": {}, "0.73.1-preview.1": {} }));
|
||||
await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).resolves.toEqual([
|
||||
"0.73.0",
|
||||
"0.73.1-preview.1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("requests the abbreviated packument at the scope-encoded URL", async () => {
|
||||
const fetchImpl = vi.fn(async () => packument({}));
|
||||
await fetchPublishedVersions(PACKAGE, options(fetchImpl));
|
||||
const [url, init] = fetchImpl.mock.calls[0];
|
||||
expect(url).toBe("https://registry.npmjs.org/@agentclientprotocol%2fclaude-agent-acp");
|
||||
expect(init.headers.accept).toBe("application/vnd.npm.install-v1+json");
|
||||
});
|
||||
|
||||
it("honours a custom registry", async () => {
|
||||
const fetchImpl = vi.fn(async () => packument({}));
|
||||
await fetchPublishedVersions(PACKAGE, {
|
||||
...options(fetchImpl),
|
||||
registry: "http://127.0.0.1:4873",
|
||||
});
|
||||
expect(fetchImpl.mock.calls[0][0]).toBe(
|
||||
"http://127.0.0.1:4873/@agentclientprotocol%2fclaude-agent-acp",
|
||||
);
|
||||
});
|
||||
|
||||
it("tolerates a packument with no versions", async () => {
|
||||
const fetchImpl = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ name: PACKAGE }),
|
||||
}));
|
||||
await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
// A 404 is a definite answer — the package has never been published — so it must not
|
||||
// be retried or turned into a failure.
|
||||
it("treats 404 as an empty history without retrying", async () => {
|
||||
const fetchImpl = vi.fn(async () => failure(404, "Not Found"));
|
||||
await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).resolves.toEqual([]);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries a server error and resolves", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(failure(500))
|
||||
.mockResolvedValueOnce(packument({ "0.73.0": {} }));
|
||||
await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).resolves.toEqual(["0.73.0"]);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retries a network failure and resolves", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new TypeError("fetch failed"))
|
||||
.mockResolvedValueOnce(packument({ "0.73.0": {} }));
|
||||
await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).resolves.toEqual(["0.73.0"]);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// Guessing N after a failed read risks reusing a version that was published and then
|
||||
// unpublished, which npm reserves forever.
|
||||
it("gives up rather than guessing when every attempt fails", async () => {
|
||||
const fetchImpl = vi.fn(async () => failure(500));
|
||||
await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).rejects.toThrow(
|
||||
/could not read published versions for @agentclientprotocol\/claude-agent-acp after 3 attempts/,
|
||||
);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user