Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 137 additions & 5 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,24 @@ on:
branches:
- main
# Recovery path for a release whose tag exists but whose npm publish or
# registry update did not land. See docs/RELEASES.md.
# registry update did not land, and the manual path for a preview from an
# arbitrary commit. See docs/RELEASES.md.
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
Expand Down Expand Up @@ -67,7 +76,7 @@ jobs:
# environment's deployment branch policy.
verify:
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') }}
runs-on: ubuntu-latest
# Without this a stalled step burns the full 6h runner limit before anyone
# notices the release did not publish. A hung apt mirror already cost one
Expand Down Expand Up @@ -149,9 +158,132 @@ jobs:
# prepublishOnly builds the bundle.
- run: npm publish --access public

# 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
# Every push to main gets a preview without waiting for CI or release-please,
# except release-please's own release merge, which publishes a stable version.
# Check the commit author and the generated release subject to avoid publishing
# the same tree twice; either match is enough to skip the preview.
if: >-
${{
(github.event_name == 'workflow_dispatch' && inputs.channel == 'preview') ||
(github.event_name == 'push'
&& github.event.head_commit.author.name != 'acp-release-bot[bot]'
&& !startsWith(github.event.head_commit.message, 'chore(main): release '))
}}
runs-on: ubuntu-latest
timeout-minutes: 15
# 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 trusted publishing, so there is no npm token
# 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@v7
with:
# Publish the exact pushed commit, or the explicitly requested manual ref.
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.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
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@v7
with:
node-version: "24"
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
- name: Publish
id: publish
# prepublishOnly builds the bundle.
#
# --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 --access public --tag preview
echo "published=true" >> "$GITHUB_OUTPUT"
outputs:
published: ${{ steps.publish.outputs.published }}
version: ${{ steps.preview.outputs.version }}
sha: ${{ steps.preview.outputs.sha }}

publish-tag-preview:
name: Tag the published preview
needs: publish-npm-preview
if: ${{ needs.publish-npm-preview.outputs.published == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 5
environment: release
permissions:
contents: write # create refs/tags/v<version>
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
timeout-minutes: 5
environment: release
Expand Down
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- `src/app-server/` — generated Codex app-server API types (regenerate via `npm run generate-types`).
- `dist/bin/` — release-ready single-file executables and `*.zip` archives.
- `.github/workflows/ci.yml` — CI mirrors the local workflow: typecheck → tests → bundle.
- `scripts/` — release tooling (`release-preflight.sh`, `next-preview-version.mjs`), kept outside `src/` so it stays out of `tsc`'s `rootDir` and the published tarball; its tests sit next to it as `*.test.mjs`.

## Coding Style & Naming Conventions

Expand All @@ -31,9 +32,12 @@

## Releasing

- 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.
- 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.
- `npm run release:preflight` verifies it is safe to release and prints the PR number and version; then `gh pr merge <pr-number> --squash`.
- The preflight is the guard-list as code; if it exits non-zero, follow what it prints rather than merging.
- Pushes to `main` trigger preview publishing directly, without waiting for CI or release-please. Automatic previews skip commits authored by `acp-release-bot[bot]` or whose message starts with `chore(main): release `.
- Previews build and publish the exact pushed commit to npm under the `preview` dist-tag, then independently tag it as `v<version>` and dispatch the agent registry update. Only `latest` is reserved for stable releases. Manual previews publish the requested ref; `publish_npm` applies only to the stable channel.
- Preview publish jobs are serialized without cancelling the running job, but newer pushes can replace a queued preview, so not every commit gets a preview. There is no staging branch.
- Full runbook, including how to recover a stalled release: [`docs/RELEASES.md`](docs/RELEASES.md).

## Docs
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ The npm package includes a compatible `@openai/codex` dependency. Set `CODEX_PAT
CODEX_PATH=/path/to/codex npx -y @agentclientprotocol/codex-acp
```

To try changes that have landed on `main` but are not released yet, install from the
`preview` channel. Pushes to `main` trigger preview publishing without waiting
for CI or release-please; release commits are excluded, and newer pushes can
replace queued previews. See
[docs/RELEASES.md](docs/RELEASES.md#preview-releases).

```bash
npx -y @agentclientprotocol/codex-acp@preview
```

## Authentication

The adapter advertises ACP auth methods during initialization. Clients can authenticate with:
Expand Down
136 changes: 136 additions & 0 deletions docs/RELEASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ 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.

Other pushes to `main` trigger preview publishing directly, without waiting for
CI or release-please — see [Preview releases](#preview-releases) for exclusions
and queue behavior. There is no staging branch.

## Releasing

```sh
Expand Down Expand Up @@ -44,6 +48,99 @@ gh release view "v<version>"
npm view "@agentclientprotocol/codex-acp@<version>"
```

## Preview releases

Each eligible push to `main` triggers a preview from the exact pushed commit in
the same workflow. Release commits are excluded as described below. 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.

`publish-npm-preview` installs dependencies, computes and applies the preview
version in the working tree, then publishes to npm. The `prepublishOnly` hook
builds the bundle before publication. After publishing, `publish-tag-preview`
creates the tag and `trigger-registry-update` dispatches the registry update
independently; neither waits for the other. The registry job is shared with the
stable path. A tag failure can be retried on its own with **Re-run failed jobs**,
leaving the successful npm publish untouched.

Previews start directly on push, without waiting for the
[`CI`](../.github/workflows/ci.yml) workflow or the `release-please` job. The
preview job does not run typecheck, unit tests or e2e tests. Stable publishing
still requires the `verify` job to pass.

The publish step runs `npm publish --access public --tag preview` and sets
`published=true` only after it succeeds. Both downstream jobs use that output
to proceed with preview tagging and registry dispatch. This is a real publish,
with no dry-run stage.

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
npx -y @agentclientprotocol/codex-acp@preview
npm view @agentclientprotocol/codex-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 1.7.0 the previews are `1.7.1-preview.1`,
`1.7.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 1.7.2, a minor makes it 1.8.1, and
both sort above every `1.7.1-preview.*`.

`1.7.1-preview.4` is **not** a promise that 1.7.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
1.7.1 release PR, a later `feat:` moves it to 1.8.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.

Preview publish jobs are serialized by a concurrency group with
`cancel-in-progress: false`. 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.

Automatic previews are skipped when the head commit's author name is
`acp-release-bot[bot]` or its message starts with `chore(main): release `.
Either match is enough to identify a release commit, and the cost of a
miss is one wasted version number plus a `preview` tag briefly pointing at
already-released code — `latest` is untouched. The preview job has no dependency
on `release-please`, so it uses the commit metadata without waiting for that
job's outputs.

To publish a preview by hand from a specific commit or branch:

```sh
gh workflow run publish.yml --ref main \
-f channel=preview -f ref=<commit-or-branch> -f publish_npm=false
```

Manual previews use the requested ref and bypass the automatic release-commit
exclusions. The `publish_npm` input applies only to stable publishing; setting it
to `false` does not disable preview publication.

`--ref main` is required: the `release` environment only accepts protected
branches and `v*` tags, 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
Expand Down Expand Up @@ -77,6 +174,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 `codex-acp-vX.Y.Z` instead of `vX.Y.Z`.
Expand Down Expand Up @@ -124,6 +229,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 verification
and 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 and repository settings

| Secret | Used for |
Expand All @@ -135,6 +265,12 @@ and publishing and only re-dispatches the registry update.
Publishing to npm uses OIDC trusted publishing, so there is no npm token. The
release-please, publish and registry 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.

Because those jobs are now triggered by pushes to `main` rather than by a `v*`
tag, the `release` environment's deployment branch policy has to allow the `main`
branch in addition to `v*` tags. Without it every release job fails before it
Expand Down
Loading