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

View File

@@ -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<version>"
npm view "@agentclientprotocol/claude-agent-acp@<version>"
```
## 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. 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=<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
@@ -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<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
@@ -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 <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 |
@@ -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.