Files
claude-agent-acp/docs/RELEASES.md
Ben Brandt e3f0b6c89c feat: ship single-file executables built with bun (#1524)
Brings back the standalone executables that #525 and #572 turned off.
npm publishing is unchanged. Each stable release also attaches 8
archives (Linux glibc and musl, macOS, Windows, on x64 and arm64) to its
GitHub release.

## Why it works now

The old executables ran the SDK's JS CLI from inside Bun's `$bunfs`, and
it couldn't reach its `vendor/` files there. The SDK's CLI is now a
native binary shipped in a per-platform npm package. So each archive
ships that binary as `claude` next to `claude-agent-acp`, and the agent
runs it from its own directory. Nothing is embedded or extracted, and
the agent has no `node_modules` to resolve.

The first commit embedded the CLI and extracted it with the SDK's
`@anthropic-ai/claude-agent-sdk/extract` helper. That works, but the OS
can only run a real file, and Bun-embedded files only exist inside the
agent process. So it wrote a second ~300 MB copy to `/tmp` on first run,
once per version. Shipping the CLI next to the executable avoids that,
and the download size is the same (~180 MB `.tar.gz`).

Claude Code's helper tools work too. Native Claude Code has no Grep or
Glob tools: its Bash shell wraps `grep` and `find` in functions that run
the ugrep and bfs built into the binary at `$CLAUDE_CODE_EXECPATH`. That
variable points at the `claude` shipped next to the agent.

## Changes

- `src/claude-cli.ts` now owns `claudeCliPath()`, moved out of
`acp-agent.ts`. `useBundledClaudeCli(dir)` makes it return
`<dir>/claude[.exe]`. If that file is missing, it throws an error saying
to keep `claude` next to `claude-agent-acp` or set
`CLAUDE_CODE_EXECUTABLE`; before, the user got a bare ENOENT.
`CLAUDE_CODE_EXECUTABLE` still wins.
- `src/binary-entry.ts` is the executable's entry. It registers the
directory of `realpath(process.execPath)`, so a symlink in
`/usr/local/bin` works, then imports the agent. It's left out of the npm
tarball.
- `scripts/build-binary.mjs` (`npm run build:binary`) runs `bun build
--compile` for a target and copies that target's `claude` next to the
output.
- `scripts/smoke-binary.mjs` (`npm run smoke:binary`) runs:
  - `--version` and `--cli --version`
  - a symlinked executable
  - the missing-`claude` error
  - `initialize` over v1 and v2
- the `terminal-auth` login command, run with `--version` (checks that
no `$bunfs` path leaks into it)

  With `--full` it also drives a real session:
- Bash `grep`/`find`, checking that they ran Claude's embedded ugrep/bfs
  - Read on text and on a PNG
  - Edit
  - a subagent
  - `session/cancel` during a running command
  - `session/list`
  - `session/load` in a new process
  - `session/fork` and `session/delete`
- `src/agent-command.ts` builds the `terminal-auth` login command, which
reruns the agent. Under Node that's `node <script> ...flags`. In a
compiled executable, `argv[1]` is a path inside the executable
(`/$bunfs/root/claude-agent-acp`), so it's dropped. Passing it back, as
the old `argv.slice(1)` did, handed it to the Claude CLI as an argument
and broke `auth login`.
- `.github/workflows/binaries.yml` builds and smoke-tests each target on
its own platform's runner; the musl targets are tested in an Alpine
container. CI runs it on every PR, using the basic smoke test, since CI
has no Claude login. On a stable release, `publish.yml` attaches the
archives (same names as before). These jobs don't gate the npm publish,
and previews get no executables. On stable releases the registry
dispatch waits for `upload-binaries`, so the archives (and the digests
the registry reads) exist first. It only waits: a failed upload doesn't
stop the dispatch.

## Testing

I also ran the whole unit suite on Bun's runtime (`bun --bun vitest
run`). 3,573 of 3,575 tests pass. The two failures are test artifacts:
Bun's `os.homedir()` ignores a `HOME` changed at runtime, and one
steer-and-cancel test asserts before the mock delivers its last idle. An
idle-accounting trace is identical on both runtimes.


On macOS arm64 with Bun 1.4.4, `smoke:binary --full` passes all checks.
`CLAUDE_CODE_EXECPATH` pointed at the shipped `claude`, and nothing was
written to the temp directory. The basic smoke test also passes from an
unpacked archive outside the repo with `PATH=/usr/bin:/bin`, and `node
dist/index.js` passes the full run. On Linux, Windows and Intel macOS,
only the basic smoke test runs, in this PR's CI.
2026-11-04 11:00:25 +00:00

258 lines
12 KiB
Markdown

# Releases
Releases are automated by [release-please](https://github.com/googleapis/release-please)
in [`.github/workflows/publish.yml`](../.github/workflows/publish.yml). Every push
to `main` re-computes the next version from the conventional commit messages and
keeps a single release PR open, titled `chore(main): release X.Y.Z` and labelled
`autorelease: pending`.
Merging that PR is what releases. It tags `vX.Y.Z`, creates the GitHub release,
publishes to npm, dispatches a version update to the agent registry, and
attaches the [standalone executables](../README.md#standalone-executables) to
the GitHub release. Previews get no executables.
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
npm run release:preflight
```
This reports the open release PR, the version it will ship, and checks that the
repository is in a state where merging is safe. Nothing has to be remembered —
if it exits non-zero, follow what it prints instead of merging.
Then merge it, using the PR number the preflight printed:
```sh
gh pr merge <pr-number> --squash
gh run watch "$(gh run list --workflow=publish.yml --limit 1 --json databaseId --jq '.[0].databaseId')"
```
The run is looked up rather than picked interactively, so this is safe to script.
If the workflow has already finished, `gh run list --workflow=publish.yml` shows
the outcome instead.
Merging main requires no review, so a green preflight and `Build` are the only
gates. Once the workflow finishes, confirm both outputs landed:
```sh
gh release view "v<version>"
npm view "@agentclientprotocol/claude-agent-acp@<version>"
```
The release should list 8 `claude-agent-acp-*` archives. They are attached a
few minutes after the release is created, once every platform has built.
## 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<version>` 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. Before dispatching,
the registry job polls npm for the exact published version for up to 12.5 minutes,
including downloading its tarball, so the registry never checks while npm is
still propagating the package. 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 only after `npm publish --tag preview` succeeds. This ensures tagging runs
only after publication and the registry update runs only after that exact version
can also be downloaded from npm.
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. Previews start directly on pushes
to `main`, without waiting for CI or the `release-please` job. The commit checks
let previews run independently of release-please's outputs.
To publish a preview by hand from any commit:
```sh
gh workflow run publish.yml --ref main \
-f channel=preview -f ref=<commit-or-branch> -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
next version. [`conventional-prs.yml`](../.github/workflows/conventional-prs.yml)
rejects titles release-please would not understand.
| PR title prefix | Effect |
| --------------------------------------------------------- | --------------------------- |
| `fix:`, `perf:`, `revert:`, `docs:` | patch, e.g. 0.66.0 → 0.66.1 |
| `feat:` | minor, e.g. 0.66.0 → 0.67.0 |
| any of the above with `!`, or BREAKING CHANGE | minor while below 1.0.0 |
| `chore:`, `ci:`, `build:`, `test:`, `refactor:`, `style:` | no release on their own |
Breaking changes bump the minor rather than the major because
`bump-minor-pre-major` is set in
[`release-please-config.json`](../release-please-config.json) and the package is
still below 1.0.0. That is deliberate: it keeps a single `!` in a PR title from
shipping 1.0.0 by accident.
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<x>-preview.<n>` 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
`vX.Y.Z`. That renames the tag every step here looks up, and because no tag under
the new scheme exists, it also walks the entire commit history into the changelog
rather than just what landed since the last release. The preflight checks the tag
release-please is going to use, so this cannot reach a published release.
Releasing 1.0.0 is therefore an explicit act: add `"release-as": "1.0.0"` to
`release-please-config.json` in its own PR, release, then remove it again.
## Recovering a stalled release
### The release PR merged but nothing was tagged
The preflight fails with `release-please is jammed`. While a merged release PR
still carries `autorelease: pending`, release-please refuses to open any new
release PR at all, so every later release stalls silently until this is cleared.
Take the release notes release-please already wrote into the changelog, create
the missing release, then move the label the way release-please would have:
```sh
awk '/^## \[<version>\]/{f=1;print;next} /^## \[/{f=0} f' CHANGELOG.md > notes.md
gh release create "v<version>" --target <merge-commit-sha> --notes-file notes.md
gh pr edit <pr-number> --remove-label "autorelease: pending" \
--add-label "autorelease: tagged"
```
Then publish the tag as described below.
### The tag exists but npm or the registry is missing
npm publishes through OIDC from inside the workflow, so this cannot be done from
a laptop. Re-run the publish workflow against the existing tag:
```sh
gh workflow run publish.yml -f ref="v<version>" -f publish_npm=true
```
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.
### The release has no executables
`build-binaries` and `upload-binaries` don't gate npm, so a failed build only
leaves the release without archives. Re-run the failed jobs of that publish run
(`gh run rerun <run-id> --failed`); the upload uses `--clobber`, so archives
already attached are replaced rather than duplicated.
### 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 <run-id> --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<version>" -f sha="<commit-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 |
| ------------------------------------------------------------- | ----------------------------------------------------------------- |
| `RELEASE_PLZ_APP_ID`, `RELEASE_PLZ_APP_PRIVATE_KEY` | App token for release PRs and tags, so they can trigger workflows |
| `REGISTRY_UPDATER_APP_ID`, `REGISTRY_UPDATER_APP_PRIVATE_KEY` | App token scoped to the `registry` repository |
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.