From 86d6aa0e2e7f3576ff149b1ddcd018d2dc4eff30 Mon Sep 17 00:00:00 2001 From: Andrey Bragin Date: Fri, 4 Sep 2026 16:09:41 +0200 Subject: [PATCH 1/3] ci: set up preview publishing --- .github/workflows/publish.yml | 164 ++++++++++++++++- AGENTS.md | 4 +- README.md | 8 + docs/RELEASES.md | 130 ++++++++++++++ scripts/next-preview-version.mjs | 202 +++++++++++++++++++++ scripts/next-preview-version.test.mjs | 247 ++++++++++++++++++++++++++ 6 files changed, 749 insertions(+), 6 deletions(-) create mode 100755 scripts/next-preview-version.mjs create mode 100644 scripts/next-preview-version.test.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c4763d9b..4a447912 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,16 +4,32 @@ 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] # 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 @@ -67,7 +83,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 @@ -149,9 +165,147 @@ 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. + # + # The gate is the `CI` workflow rather than the `verify` job the stable path runs: + # typecheck, unit tests and the bundle, but not the e2e suite. A preview is meant to + # be on npm minutes after a merge, and e2e drives a live model. + publish-npm-preview: + name: Publish preview to npm + # 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 ')) + }} + 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: + # 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 + # 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 + # 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 deployment branch policy mean this job + # cannot be exercised from a feature branch at all. + - name: Publish + id: publish + # prepublishOnly builds the bundle, for a dry run too. + # + # --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 --access public --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 + 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 + 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 diff --git a/AGENTS.md b/AGENTS.md index 31c96fd4..faad4f15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -31,9 +32,10 @@ ## 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 --squash`. - The preflight is the guard-list as code; if it exits non-zero, follow what it prints rather than merging. +- Every _other_ push to `main` publishes a preview to npm — `1.7.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). ## Docs diff --git a/README.md b/README.md index 88a2ad51..47ce9fbb 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,14 @@ 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 — every push to `main` publishes one. 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: diff --git a/docs/RELEASES.md b/docs/RELEASES.md index 0eef4ff5..9500b2b8 100644 --- a/docs/RELEASES.md +++ b/docs/RELEASES.md @@ -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. +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 @@ -44,6 +48,93 @@ gh release view "v" npm view "@agentclientprotocol/codex-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. + +The gate is the [`CI`](../.github/workflows/ci.yml) workflow finishing green — +typecheck, unit tests and the binary bundle — not the `verify` job the stable +path runs, so the e2e suite is the one thing a preview is not held to. A preview +is meant to be on npm minutes after a merge, and e2e drives a live model. + +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 +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. + +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 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 @@ -77,6 +168,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 `codex-acp-vX.Y.Z` instead of `vX.Y.Z`. @@ -124,6 +223,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 --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 and repository settings | Secret | Used for | @@ -135,6 +259,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 diff --git a/scripts/next-preview-version.mjs b/scripts/next-preview-version.mjs new file mode 100755 index 00000000..75113388 --- /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. `1.7.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 `1.8.0-preview.N` when a `feat:` is queued — but the +// prediction moves mid-flight: a `fix:` opens a 1.7.1 release PR, a later `feat:` +// moves it to 1.8.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: +// 1.7.1-preview.9 < 1.7.2-preview.1 and < 1.8.1-preview.1, whichever way the next +// release goes. The cost is that `1.7.1-preview.4` is not a promise that 1.7.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.`. `171-preview.1` would match a base of `1.7.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..bc2ac6cb --- /dev/null +++ b/scripts/next-preview-version.test.mjs @@ -0,0 +1,247 @@ +import { describe, it, expect, vi } from "vitest"; +import { + bumpPatch, + previewNumber, + versionFromTag, + highestPreviewNumber, + nextPreviewVersion, + fetchPublishedVersions, +} from "./next-preview-version.mjs"; + +const PACKAGE = "@agentclientprotocol/codex-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("1.7.0")).toBe("1.7.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("1.7.9")).toBe("1.7.10"); + expect(bumpPatch("1.7.19")).toBe("1.7.20"); + }); + + it("trims surrounding whitespace", () => { + expect(bumpPatch(" 1.7.0 ")).toBe("1.7.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", "1.7"], + ["a four-part version", "1.7.0.1"], + ["a prerelease", "1.7.0-preview.1"], + ["build metadata", "1.7.0+build.1"], + ["a v prefix", "v1.7.0"], + ["a range", "^1.7.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("1.7.1-preview.1", "1.7.1")).toBe(1); + expect(previewNumber("1.7.1-preview.10", "1.7.1")).toBe(10); + }); + + it("treats zero as a number rather than as no match", () => { + expect(previewNumber("1.7.1-preview.0", "1.7.1")).toBe(0); + }); + + it.each([ + ["a plain release", "1.7.1"], + ["a different base", "1.7.2-preview.1"], + ["a missing number", "1.7.1-preview"], + ["leading zeros in N", "1.7.1-preview.01"], + ["a dotted N", "1.7.1-preview.1.2"], + ["build metadata", "1.7.1-preview.1+build.5"], + ["another prerelease id", "1.7.1-alpha.1"], + // Proves the base's dots are escaped rather than matching any character. + ["a base whose dots are elided", "171-preview.1"], + // Proves the pattern is anchored at the start. + ["a base with a numeric prefix", "11.7.1-preview.1"], + ])("returns null for %s", (_label, candidate) => { + expect(previewNumber(candidate, "1.7.1")).toBeNull(); + }); +}); + +describe("versionFromTag", () => { + it("strips a refs/tags/ prefix and a v", () => { + expect(versionFromTag("refs/tags/v1.7.1-preview.3")).toBe("1.7.1-preview.3"); + expect(versionFromTag("v1.7.1")).toBe("1.7.1"); + expect(versionFromTag("1.7.1")).toBe("1.7.1"); + }); +}); + +describe("highestPreviewNumber", () => { + it("is 0 when nothing matches", () => { + expect(highestPreviewNumber([], "1.7.1")).toBe(0); + expect(highestPreviewNumber(["1.7.1", "1.6.1-preview.4"], "1.7.1")).toBe(0); + }); + + // Sorted as strings, `preview.9` would beat `preview.10`. + it("compares numerically, not lexicographically", () => { + expect(highestPreviewNumber(["1.7.1-preview.9", "1.7.1-preview.10"], "1.7.1")).toBe(10); + expect(highestPreviewNumber(["1.7.1-preview.10", "1.7.1-preview.9"], "1.7.1")).toBe(10); + }); + + it("ignores order and duplicates", () => { + const candidates = [ + "1.7.1-preview.2", + "1.7.1-preview.7", + "1.7.1-preview.2", + "1.7.1-preview.5", + ]; + expect(highestPreviewNumber(candidates, "1.7.1")).toBe(7); + }); + + it("counts only the requested base", () => { + const candidates = ["1.6.1-preview.99", "1.7.1-preview.3", "1.8.1-preview.42"]; + expect(highestPreviewNumber(candidates, "1.7.1")).toBe(3); + }); +}); + +describe("nextPreviewVersion", () => { + it("starts at 1 with no history", () => { + expect(nextPreviewVersion({ base: "1.7.1" })).toBe("1.7.1-preview.1"); + }); + + it("continues from the registry", () => { + const registryVersions = ["1.7.0", "1.7.1-preview.1", "1.7.1-preview.2", "1.7.1-preview.3"]; + expect(nextPreviewVersion({ base: "1.7.1", registryVersions })).toBe("1.7.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: "1.7.1", + registryVersions: ["1.7.1-preview.1"], + tags: ["refs/tags/v1.7.1-preview.3"], + }); + expect(next).toBe("1.7.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: "1.7.1", + registryVersions: ["1.7.1-preview.1", "1.7.1-preview.2", "1.7.1-preview.3"], + tags: ["v1.7.1-preview.1"], + }); + expect(next).toBe("1.7.1-preview.4"); + }); + + it("restarts at 1 once a release moves the base", () => { + const next = nextPreviewVersion({ + base: "1.7.1", + registryVersions: ["1.6.1-preview.7", "1.7.0"], + tags: ["v1.6.1-preview.7"], + }); + expect(next).toBe("1.7.1-preview.1"); + }); + + it("composes with bumpPatch", () => { + const next = nextPreviewVersion({ + base: bumpPatch("1.7.0"), + registryVersions: ["1.7.0"], + tags: ["v1.7.0"], + }); + expect(next).toBe("1.7.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({ "1.7.0": {}, "1.7.1-preview.1": {} })); + await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).resolves.toEqual([ + "1.7.0", + "1.7.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%2fcodex-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%2fcodex-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({ "1.7.0": {} })); + await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).resolves.toEqual(["1.7.0"]); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("retries a network failure and resolves", async () => { + const fetchImpl = vi + .fn() + .mockRejectedValueOnce(new TypeError("fetch failed")) + .mockResolvedValueOnce(packument({ "1.7.0": {} })); + await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).resolves.toEqual(["1.7.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\/codex-acp after 3 attempts/, + ); + expect(fetchImpl).toHaveBeenCalledTimes(3); + }); +}); From 6f543b6e66db904a2b35015ba7748d19c4bcf090 Mon Sep 17 00:00:00 2001 From: Andrey Bragin Date: Wed, 9 Sep 2026 14:22:48 +0200 Subject: [PATCH 2/3] ci: set up preview publishing --- .github/workflows/publish.yml | 48 ++++++++++------------------------- 1 file changed, 13 insertions(+), 35 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4a447912..df1316e1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,13 +4,6 @@ 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] # Recovery path for a release whose tag exists but whose npm publish or # registry update did not land, and the manual path for a preview from an # arbitrary commit. See docs/RELEASES.md. @@ -167,27 +160,18 @@ jobs: # Same shape as publish-npm. Tagging is a separate job, so nothing here needs # write access to the repository. - # - # The gate is the `CI` workflow rather than the `verify` job the stable path runs: - # typecheck, unit tests and the bundle, but not the e2e suite. A preview is meant to - # be on npm minutes after a merge, and e2e drives a live model. publish-npm-preview: name: Publish preview to npm - # 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. + # 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 == '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 ')) + (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 @@ -210,9 +194,8 @@ jobs: steps: - uses: actions/checkout@v7 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 }} + # 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. @@ -238,20 +221,15 @@ jobs: # `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 - # 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 deployment branch policy mean this job - # cannot be exercised from a feature branch at all. - name: Publish id: publish - # prepublishOnly builds the bundle, for a dry run too. + # 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 --dry-run --access public --tag preview + run: | + npm publish --access public --tag preview + echo "published=true" >> "$GITHUB_OUTPUT" outputs: published: ${{ steps.publish.outputs.published }} version: ${{ steps.preview.outputs.version }} From 76a1ce603eb5ecb0a9884e88148a738e4211bf69 Mon Sep 17 00:00:00 2001 From: Andrey Bragin Date: Wed, 9 Sep 2026 14:26:47 +0200 Subject: [PATCH 3/3] ci: set up preview publishing --- AGENTS.md | 4 ++- README.md | 4 ++- docs/RELEASES.md | 68 ++++++++++++++++++++++++++---------------------- 3 files changed, 43 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index faad4f15..d113aceb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,9 @@ - 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 --squash`. - The preflight is the guard-list as code; if it exits non-zero, follow what it prints rather than merging. -- Every _other_ push to `main` publishes a preview to npm — `1.7.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. +- 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` 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 diff --git a/README.md b/README.md index 47ce9fbb..f89438b7 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,9 @@ 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 — every push to `main` publishes one. See +`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 diff --git a/docs/RELEASES.md b/docs/RELEASES.md index 9500b2b8..fa12b866 100644 --- a/docs/RELEASES.md +++ b/docs/RELEASES.md @@ -13,9 +13,9 @@ 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. +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 @@ -50,28 +50,30 @@ npm view "@agentclientprotocol/codex-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 +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` +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. +`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. -The gate is the [`CI`](../.github/workflows/ci.yml) workflow finishing green — -typecheck, unit tests and the binary bundle — not the `verify` job the stable -path runs, so the e2e suite is the one thing a preview is not held to. A preview -is meant to be on npm minutes after a merge, and e2e drives a live model. +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. -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. +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 @@ -106,31 +108,35 @@ 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. +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. -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 +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. 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. +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 any commit, bypassing the CI gate: +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= -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.