mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
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.
250 lines
8.8 KiB
JavaScript
250 lines
8.8 KiB
JavaScript
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);
|
|
});
|
|
});
|