diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 74a51dd7..6d7624f4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -39,7 +39,11 @@ jobs: id: release with: token: ${{ steps.generate-token.outputs.token }} - release-type: node + # Do not add release-type here. The action ignores config-file whenever + # release-type is set, which silently disables the pre-1.0 versioning + # rules in release-please-config.json and lets a breaking change jump + # straight to 1.0.0. The node release type is declared in that config + # instead, so nothing is lost by leaving this out. config-file: release-please-config.json manifest-file: .release-please-manifest.json outputs: diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..273a8a4c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,42 @@ +# Agent guide + +An ACP-compatible coding agent powered by the Claude Agent SDK. Source in `src/`, +TypeScript, ESM, Node >= 22. + +## Commands + +```sh +npm run build # tsc +npm run test:run # vitest, single pass +npm run check # eslint + prettier --check +npm run lint:fix # eslint --fix +npm run format # prettier --write +``` + +CI runs `format:check`, `lint`, `build` and `test:run`. Run `npm run check` before +opening a PR. + +## Pull requests + +Squash merges use the PR title as the commit subject, and release-please parses it +to compute the next version. Titles must be conventional commits using one of: +`feat`, `fix`, `perf`, `revert`, `docs`, `style`, `chore`, `refactor`, `test`, +`build`, `ci`. `conventional-prs.yml` rejects anything else. + +The title also decides the release: `feat:` bumps the minor, `fix:`/`perf:` the +patch, and `chore:`/`ci:`/`test:` and friends do not release at all. + +## Releasing + +Releases are fully automated by release-please. There is no manual release +workflow, and the version is never chosen by hand — it follows from the commit +history. + +```sh +npm run release:preflight # verifies it is safe to release, prints the PR and version +gh pr merge --squash +``` + +The preflight is the guard-list as code; if it exits non-zero, follow what it +prints rather than merging. Full runbook, including how to recover a stalled +release: [`docs/RELEASES.md`](docs/RELEASES.md). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/docs/RELEASES.md b/docs/RELEASES.md index 067e1318..e37e057d 100644 --- a/docs/RELEASES.md +++ b/docs/RELEASES.md @@ -1,24 +1,111 @@ -## How to publish a new release +# Releases -1. Create a new branch prep-vX.X.X +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, 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. + +## Releasing ```sh -git checkout -b prep-vX.X.X +npm run release:preflight ``` -2. Bump the version in package.json +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 -npm version vX.X.X +gh pr merge --squash +gh run watch "$(gh run list --workflow=publish.yml --limit 1 --json databaseId --jq '.[0].databaseId')" ``` -3. Add a new entry in CHANGELOG.md -4. Create a PR for `prev-vX.X.X` & merge it -5. Create tag and push it +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 -git tag vX.X.X -git push origin vX.X.X +gh release view "v" +npm view "@agentclientprotocol/claude-agent-acp@" ``` -6. Paste CHANGELOG into release notes and publish the version on GitHub +## 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. + +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 '/^## \[\]/{f=1;print;next} /^## \[/{f=0} f' CHANGELOG.md > notes.md +gh release create "v" --target --notes-file notes.md +gh pr edit --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" -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. + +## 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. diff --git a/package.json b/package.json index e9c2a6f8..a7784f29 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "check": "npm run lint && npm run format:check", + "release:preflight": "bash scripts/release-preflight.sh", "test": "vitest", "test:integration": "RUN_INTEGRATION_TESTS=true vitest run", "test:run": "vitest run", diff --git a/release-please-config.json b/release-please-config.json index ea933f2e..3def4863 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,12 +1,10 @@ { "bump-minor-pre-major": true, - "bump-patch-for-minor-pre-major": true, "packages": { ".": { "changelog-path": "CHANGELOG.md", "release-type": "node", - "bump-minor-pre-major": true, - "bump-patch-for-minor-pre-major": true + "bump-minor-pre-major": true } }, "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" diff --git a/scripts/release-preflight.sh b/scripts/release-preflight.sh new file mode 100755 index 00000000..08c29636 --- /dev/null +++ b/scripts/release-preflight.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# +# Preflight checks for a release. +# +# release-please decides what the next version is; this script only verifies the +# repository is in a state where merging the open release PR produces a correct +# release. Merging main needs no review, so these checks are the only thing +# standing between a mistake and a published package — keep them as code rather +# than as a list someone has to remember. See docs/RELEASES.md. +# +# Requires an authenticated GitHub CLI. Nothing else: gh has a jq engine builtin. + +set -euo pipefail + +PENDING_LABEL="autorelease: pending" + +fail() { + printf 'FAIL %s\n' "$1" >&2 + exit 1 +} + +pass() { + printf 'ok %s\n' "$1" +} + +command -v gh >/dev/null 2>&1 || + fail "GitHub CLI (gh) is not installed." +gh auth status >/dev/null 2>&1 || + fail "GitHub CLI is not authenticated. Run 'gh auth login'." + +repo=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null) || + fail "cannot work out which GitHub repository this is. Run from a checkout." + +# A merged release PR that still carries the pending label means release-please +# never tagged it. In that state it refuses to open any new release PR at all, +# so every later release stalls silently until it is recovered by hand. +stuck=$(gh pr list --repo "$repo" --state merged --label "$PENDING_LABEL" \ + --json number --jq 'map("#\(.number)") | join(", ")') +[ -z "$stuck" ] || + fail "merged release PR still labelled '$PENDING_LABEL': $stuck. + release-please is jammed and will not open new release PRs. + See docs/RELEASES.md, 'Recovering a stalled release'." +pass "no untagged merged release PR" + +rows=$(gh pr list --repo "$repo" --state open --label "$PENDING_LABEL" \ + --json number,title,headRefName --jq '.[] | [.number, .headRefName, .title] | @tsv') +count=$(printf '%s\n' "$rows" | grep -c . || true) + +if [ "$count" -eq 0 ]; then + fail "no open release PR. + Nothing has landed since the last tag that release-please puts in a + changelog, so there is nothing to release. Land a feat/fix/perf/revert/docs + change first, or check that the Publish and Release workflow is running." +fi +if [ "$count" -gt 1 ]; then + fail "$count open release PRs, expected exactly one: +$rows" +fi +IFS=$(printf '\t') read -r pr_number head_ref pr_title </dev/null 2>&1; then + fail "tag v$title_version already exists. Merging would try to release it twice." +fi +pass "tag v$title_version does not exist yet" + +build=$(gh pr view "$pr_number" --repo "$repo" --json statusCheckRollup --jq ' + [.statusCheckRollup[]? | select(.name == "Build")] + | if length == 0 then "MISSING" else (.[0].conclusion // .[0].status // "PENDING") end') +[ "$build" = "SUCCESS" ] || + fail "required check 'Build' is $build on #$pr_number, expected SUCCESS." +pass "required check 'Build' passed" + +cat <