diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6d7624f4..ceb64187 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,14 +3,29 @@ on: push: branches: - main + # Previews are gated on CI, so they hang off the CI workflow finishing rather than + # off the push itself. `branches` filters on the *validated* branch, which keeps + # every PR CI run from spawning a Publish and Release run with all jobs skipped. + workflow_run: + workflows: [CI] + types: [completed] + branches: [main] workflow_dispatch: inputs: + channel: + description: Which channel to publish + required: true + default: stable + type: choice + options: + - stable + - preview ref: description: Tag or commit to publish required: true type: string publish_npm: - description: Publish the package before updating the registry + description: Publish the package before updating the registry (stable only) required: true default: true type: boolean @@ -54,7 +69,7 @@ jobs: name: Publish to npm runs-on: ubuntu-latest needs: [release-please] - if: ${{ always() && ((github.event_name == 'workflow_dispatch' && inputs.publish_npm) || needs.release-please.outputs.release_created == 'true') }} + if: ${{ always() && ((github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && inputs.publish_npm) || needs.release-please.outputs.release_created == 'true') }} environment: release # Optional: for enhanced security permissions: contents: read @@ -72,9 +87,141 @@ jobs: - run: npm run build - run: npm publish + # Same shape as publish-npm. Tagging is a separate job, so nothing here needs + # write access to the repository. + publish-npm-preview: + name: Publish preview to npm + runs-on: ubuntu-latest + # Every CI-green push to main gets a preview, except release-please's own release + # merge: merging that PR already published this exact tree as a stable version, so + # a preview of it would be a double release. release-please's `releases_created` + # output would be the sharper signal, but it belongs to the push-triggered run and + # this job runs in the workflow_run one, so the commit is checked instead — by + # author and by the subject release-please generates, either of which is enough. + if: >- + ${{ + (github.event_name == 'workflow_dispatch' && inputs.channel == 'preview') || + (github.event_name == 'workflow_run' + && github.event.workflow_run.conclusion == 'success' + && github.event.workflow_run.event == 'push' + && github.event.workflow_run.head_repository.full_name == github.repository + && github.event.workflow_run.head_commit.author.name != 'acp-release-bot[bot]' + && !startsWith(github.event.workflow_run.head_commit.message, 'chore(main): release ')) + }} + # Same environment as the stable job, so the npm trusted-publisher binding holds. + # It carries no required reviewers, so previews never wait for an approval. + environment: release + permissions: + contents: read + id-token: write # npm OIDC trusted publishing + timeout-minutes: 15 + # Serialise previews so two pushes cannot read the same registry state and compute + # the same -preview.N. Job-level rather than workflow-level: a workflow-level group + # would also serialise release-please, and cancelling a queued release-please run + # means a release PR that silently stops updating. The trade-off is that GitHub + # keeps only one run pending per group, so a third push landing while one preview + # runs and another waits drops the waiting one — that commit gets no preview, but a + # version is never reused. + concurrency: + group: publish-npm-preview + cancel-in-progress: false + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + # workflow_run runs default to the tip of the default branch, so the commit + # CI actually validated has to be asked for explicitly. + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.event.workflow_run.head_sha }} + # Brings the tags the version calculation reads as its floor. The repo is a + # few megabytes packed, so a full fetch is cheap and sidesteps every + # shallow-clone tag caveat. + fetch-depth: 0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: "lts/*" + registry-url: "https://registry.npmjs.org" + # Ahead of the version bump, so this still validates the committed lockfile. + - run: npm ci + - name: Compute the preview version + id: preview + run: | + version="$(node scripts/next-preview-version.mjs)" + sha="$(git rev-parse HEAD)" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "sha=$sha" >> "$GITHUB_OUTPUT" + echo "Preview \`$version\` from \`$sha\`" >> "$GITHUB_STEP_SUMMARY" + - name: Apply the version to the working tree only + # Never committed: package.json and .release-please-manifest.json on main stay + # release-please's to own, and this only changes what goes into the tarball. + # `npm version` keeps package-lock.json's version fields in step, and + # --no-git-tag-version skips every git operation. + run: npm version "${{ steps.preview.outputs.version }}" --no-git-tag-version + - run: npm run build + # TODO(preview-releases): once one run has proved the trigger, the gate and the + # version calculation on main, drop --dry-run and add + # `echo "published=true" >> "$GITHUB_OUTPUT"` to this step. Nothing else needs + # touching — leaving `published` unset is what keeps the tag and the registry + # dispatch dormant while the publish is only a rehearsal. The workflow_run + # trigger and the `release` environment's protected-branch policy mean this job + # cannot be exercised from a feature branch at all. + - name: Publish + id: publish + # --tag is mandatory: npm publish defaults to `latest` even for a semver + # prerelease, which would point every plain `npm install` at a preview. + run: npm publish --dry-run --tag preview + outputs: + published: ${{ steps.publish.outputs.published }} + version: ${{ steps.preview.outputs.version }} + sha: ${{ steps.preview.outputs.sha }} + + publish-tag-preview: + name: Tag the published preview + runs-on: ubuntu-latest + needs: publish-npm-preview + if: ${{ needs.publish-npm-preview.outputs.published == 'true' }} + environment: release + permissions: + contents: write # create refs/tags/v + timeout-minutes: 5 + steps: + # A re-run carries over the outputs of jobs it did not re-run. If that ever stops + # holding, fail with the manual recipe rather than tagging the wrong commit. + - name: Check the publish handed over a version and a commit + env: + VERSION: ${{ needs.publish-npm-preview.outputs.version }} + SHA: ${{ needs.publish-npm-preview.outputs.sha }} + run: | + if [ -z "$VERSION" ] || [ -z "$SHA" ]; then + echo "::error::publish-npm-preview reported version='$VERSION' sha='$SHA'." \ + "Tag it by hand — see docs/RELEASES.md, 'A preview published but the" \ + "commit was not tagged'." + exit 1 + fi + - name: Create the tag + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.publish-npm-preview.outputs.version }} + SHA: ${{ needs.publish-npm-preview.outputs.sha }} + run: | + gh api "repos/$GITHUB_REPOSITORY/git/refs" \ + -f ref="refs/tags/v$VERSION" \ + -f sha="$SHA" + echo "Tagged \`v$VERSION\` at \`$SHA\`" >> "$GITHUB_STEP_SUMMARY" + trigger-registry-update: - needs: publish-npm - if: ${{ always() && (needs.publish-npm.result == 'success' || (github.event_name == 'workflow_dispatch' && !inputs.publish_npm)) }} + needs: [publish-npm, publish-npm-preview] + # Both channels dispatch this: the registry has its own handling for preview + # versions. The two never fire together — a release merge publishes stable and + # skips the preview, and every other push does the reverse — so the registry sees + # exactly one dispatch per published version. The payload is deliberately + # unchanged: it names no version, and the registry resolves what it needs itself. + if: >- + ${{ + always() && ( + needs.publish-npm.result == 'success' + || needs.publish-npm-preview.outputs.published == 'true' + || (github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.publish_npm) + ) + }} runs-on: ubuntu-latest environment: release permissions: {} diff --git a/AGENTS.md b/AGENTS.md index 273a8a4c..6ef75f24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ patch, and `chore:`/`ci:`/`test:` and friends do not release at all. ## Releasing -Releases are fully automated by release-please. There is no manual release +Stable releases are fully automated by release-please. There is no manual release workflow, and the version is never chosen by hand — it follows from the commit history. @@ -38,5 +38,11 @@ gh pr merge --squash ``` The preflight is the guard-list as code; if it exits non-zero, follow what it -prints rather than merging. Full runbook, including how to recover a stalled -release: [`docs/RELEASES.md`](docs/RELEASES.md). +prints rather than merging. + +Every _other_ push to `main` publishes a preview to npm — `0.73.1-preview.4` and +so on — under the `preview` dist-tag, tags the commit it came from, and updates +the agent registry the same way a release does. Only `latest` is reserved for +real releases. So anything merged to `main` is published within minutes; there is +no staging branch. Full runbook, including how to recover a stalled release: +[`docs/RELEASES.md`](docs/RELEASES.md). diff --git a/README.md b/README.md index 0e1999a5..db427477 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,14 @@ This tool implements an ACP agent by using the official [Claude Agent SDK](https Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/). +To try changes that have landed on `main` but are not released yet, install from the +`preview` channel — every push to `main` publishes one. See +[`docs/RELEASES.md`](docs/RELEASES.md#preview-releases). + +```sh +npm install @agentclientprotocol/claude-agent-acp@preview +``` + ### Subagent sessions Subagents are exposed only after bilateral capability negotiation. Until the released ACP SDKs diff --git a/docs/RELEASES.md b/docs/RELEASES.md index cae59c0b..e881b4b9 100644 --- a/docs/RELEASES.md +++ b/docs/RELEASES.md @@ -12,6 +12,10 @@ publishes to npm, and dispatches a version update to the agent registry. There is no manual release button, and versions are never typed in by hand: the version is an output of the commit history, not an input. +Every _other_ push to `main` publishes a preview instead — see +[Preview releases](#preview-releases). Anything merged to `main` is on npm within +minutes; there is no staging branch. + ## Releasing ```sh @@ -41,6 +45,87 @@ gh release view "v" npm view "@agentclientprotocol/claude-agent-acp@" ``` +## Preview releases + +Every push to `main` that is not a release merge publishes a preview from the +same workflow. There is no GitHub release — only an npm publish under the +`preview` dist-tag, a `v` tag on the commit it came from, and the same +agent registry update a stable release dispatches, since the registry has its own +handling for preview versions. + +Those are three jobs, in that order: `publish-npm-preview` mirrors `publish-npm` +and does nothing but publish; `publish-tag-preview` creates the tag; and +`trigger-registry-update` is shared with the stable path. The tag is a separate +job so that a tag failure can be retried on its own with **Re-run failed jobs** +— re-running the publish is not an option, because npm versions are immutable and +publishing the same one twice fails outright. + +Both downstream jobs are gated on a `published` output that the publish step +sets, not on whether the publish job went green. That keeps the two concerns +apart — `published` means "npm has this version" and nothing else — and it means +a rehearsal run that only passes `--dry-run` neither tags nor dispatches. + +A stable and a preview dispatch can never collide — a release merge publishes +stable and skips the preview, every other push does the reverse — so the registry +sees exactly one dispatch per published version. + +```sh +npm install @agentclientprotocol/claude-agent-acp@preview +npm view @agentclientprotocol/claude-agent-acp dist-tags +git ls-remote --tags origin 'refs/tags/*preview*' +``` + +The version is the `package.json` version with the patch incremented, plus +`-preview.N`: with `main` at 0.73.0 the previews are `0.73.1-preview.1`, +`0.73.1-preview.2`, and so on. `N` restarts at 1 whenever release-please moves +`package.json`, which keeps the sequence monotonic whichever way the next release +goes — a patch release makes the next base 0.73.2, a minor makes it 0.74.1, and +both sort above every `0.73.1-preview.*`. + +`0.73.1-preview.4` is **not** a promise that 0.73.1 will ship. The base is a +patch bump because that is the only choice depending solely on `package.json`, +which release-please only ever increases. Using release-please's predicted next +version would read better but that prediction moves mid-flight: a `fix:` opens a +0.73.1 release PR, a later `feat:` moves it to 0.74.0, and `N` would reset under +previews that were already published. + +`N` comes from [`scripts/next-preview-version.mjs`](../scripts/next-preview-version.mjs), +which takes the larger of two sources. The npm registry says what is taken — npm +versions are immutable and stay reserved even after `npm unpublish`, so reusing +one is a hard failure — but it is CDN-served and can lag a publish by minutes. +The git tags this job writes are strongly consistent and cover that window. The +job publishes before it tags, so a version can exist on npm without a tag but +never the reverse; that is why a registry read failure aborts the run rather than +falling back to the tags alone. + +Two pushes landing together cannot collide, because the job takes a concurrency +group. GitHub keeps only one run pending per group, so a third push arriving +while one preview runs and another waits drops the waiting one — that commit +simply gets no preview. + +`latest` stays put because the job passes `npm publish --tag preview`. Without +it npm would move `latest` onto the preview: `--tag` defaults to `latest` even +for a semver prerelease. Right after a release the `preview` dist-tag can name a +version _below_ `latest` until the next push lands; that is cosmetic. + +Release merges are excluded by checking the head commit's author and the subject +release-please generates. Both are checked, either is enough, and the cost of a +miss is one wasted version number plus a `preview` tag briefly pointing at +already-released code — `latest` is untouched. release-please's own +`releases_created` output would be a sharper signal, but previews hang off the +`CI` workflow finishing rather than off the push, so they run in a different +workflow run from the `release-please` job and cannot read its outputs. + +To publish a preview by hand — from any commit, bypassing the CI gate: + +```sh +gh workflow run publish.yml --ref main \ + -f channel=preview -f ref= -f publish_npm=false +``` + +`--ref main` is required: the `release` environment only accepts protected +branches, so a dispatch from anywhere else is rejected before the job starts. + ## How the version is chosen Squash merges use the PR title as the commit subject, so the PR title decides the @@ -64,6 +149,14 @@ Note that `config-file` only takes effect while the workflow does **not** pass a `release-type` input to the action — with `release-type` set, the action ignores the config entirely. The release type is declared inside the config instead. +`release-type` also switches release-please from `Manifest.fromManifest` to +`Manifest.fromConfig`, which is a second and sharper reason never to set it. On +the manifest path the previous release is found by an exact string match against +the version in [`.release-please-manifest.json`](../.release-please-manifest.json), +which is why the `v-preview.` tags are invisible to it. On the config path +release-please instead sorts every candidate tag and release descending and takes +the highest — and there the preview tags _would_ be candidates. + Because the config is what is read, it also has to say `"include-component-in-tag": false`. Left at its default, release-please derives a component from the package name and tags `claude-agent-acp-vX.Y.Z` instead of @@ -108,6 +201,31 @@ npm versions are immutable. If the package already published and only the registry update failed, pass `-f publish_npm=false` so the run skips publishing and only re-dispatches the registry update. +### A preview published but the commit was not tagged + +Only the publish is irreversible, so re-run just the tag job: + +```sh +gh run rerun --failed +``` + +Or **Re-run failed jobs** on the run in the web or mobile UI. This re-runs +`publish-tag-preview` alone and leaves the successful publish untouched, which +matters because re-publishing an immutable npm version would fail. + +If the re-run reports that it received no version or commit, the run's carried +over job outputs are gone and it cannot tag anything safely. Do it by hand +instead, taking the version from the publish job's log: + +```sh +gh api "repos/$(gh repo view --json nameWithOwner --jq .nameWithOwner)/git/refs" \ + -f ref="refs/tags/v" -f sha="" +``` + +Either way nothing is broken in the meantime: the next preview still picks the +right `N` once the registry CDN catches up. The tag is how that number is known +immediately. + ## Credentials | Secret | Used for | @@ -117,3 +235,9 @@ and only re-dispatches the registry update. Publishing to npm uses OIDC trusted publishing, so there is no npm token. All release jobs run in the `release` environment. + +npm binds a trusted publisher to one repository, one **workflow filename** and +one environment, and a package may only have one such binding. That is why +preview publishing is another job inside `publish.yml` rather than a workflow of +its own: a separate file would fail to authenticate, and registering it would +cost the stable path its publisher. diff --git a/scripts/next-preview-version.mjs b/scripts/next-preview-version.mjs new file mode 100755 index 00000000..152142cf --- /dev/null +++ b/scripts/next-preview-version.mjs @@ -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 `-preview.`. `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 `-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 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); + }); +} diff --git a/scripts/next-preview-version.test.mjs b/scripts/next-preview-version.test.mjs new file mode 100644 index 00000000..2077d977 --- /dev/null +++ b/scripts/next-preview-version.test.mjs @@ -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); + }); +}); diff --git a/src/tests/create-session-options.test.ts b/src/tests/create-session-options.test.ts index d81c9b2e..d6d91e97 100644 --- a/src/tests/create-session-options.test.ts +++ b/src/tests/create-session-options.test.ts @@ -1078,9 +1078,22 @@ describe("createSession options merging", () => { ]; let originalAnthropicModel: string | undefined; + let originalClaudeConfigDir: string | undefined; + let configDir: string; beforeEach(() => { originalAnthropicModel = process.env.ANTHROPIC_MODEL; delete process.env.ANTHROPIC_MODEL; + // These tests assert which model a fresh session lands on with no + // override in play, so both override tiers above the SDK's default have + // to be neutralized: ANTHROPIC_MODEL and `model` from the user + // settings tier. Without the second one, a developer whose own + // ~/.claude/settings.json pins a model (e.g. "opus[1m]") sees the + // session start on that model instead of models[0]. resolveSettings + // reads the user tier from CLAUDE_CONFIG_DIR at call time, so pointing + // it at an empty directory is enough. + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + configDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-acp-model-config-")); + process.env.CLAUDE_CONFIG_DIR = configDir; }); afterEach(() => { if (originalAnthropicModel !== undefined) { @@ -1088,6 +1101,12 @@ describe("createSession options merging", () => { } else { delete process.env.ANTHROPIC_MODEL; } + if (originalClaudeConfigDir !== undefined) { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + } else { + delete process.env.CLAUDE_CONFIG_DIR; + } + fs.rmSync(configDir, { recursive: true, force: true }); }); it("tolerates a PreModelSwitch hook vetoing the fresh-session model pin", async () => { diff --git a/vitest.config.ts b/vitest.config.ts index c48e5921..0f8d1d9d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,12 @@ export default defineConfig({ watch: false, globals: true, environment: "node", - include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + include: [ + "src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}", + // Release tooling lives in scripts/ rather than src/ so it stays outside tsc's + // rootDir and out of the published tarball; its tests have to follow it there. + "scripts/**/*.{test,spec}.mjs", + ], coverage: { provider: "v8", reporter: ["text", "json", "html"],