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:
Andrey Bragin
2026-09-08 14:16:37 +02:00
committed by GitHub
parent d870ebd2fd
commit 0dd58685b1
8 changed files with 768 additions and 8 deletions

202
scripts/next-preview-version.mjs Executable file
View 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);
});
}

View 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);
});
});