---
name: release
description: Autonomously cut a Rove (`@sma1lboy/rove`) release end-to-end — detect the semver bump from pending changesets (flagging an upstream `minor` you didn't intend), run the release gates, dispatch the Changesets workflow (or run `scripts/release.sh` locally), then poll the GitHub Actions Release workflow with `gh` until npm publish completes, diagnosing CI failures (npm token, registry 404, lint, branch mismatch) instead of leaving them silent. Use when the user says "cut a release", "ship a version", "release Rove", "release rove", "发版", "release.sh", or "bump the version". Never force-pushes; always verifies the release landed on `main`.
metadata:
  internal: true
---

# Release Rove

Autonomous release driver for `@sma1lboy/rove`. This is the supervised loop the
manual flow in [`docs/RELEASING.md`](../../../docs/RELEASING.md) describes — read
that doc once if anything here is ambiguous; it is the source of truth and this
skill must never contradict it.

> **Releases are on demand.** Merging a PR only banks its changeset;
> nothing ships until someone runs `.github/workflows/changesets.yml`
> (CI-green wait → version + commit → tag → dispatch `release.yml`). That
> workflow is the default path: do Step 1 against `origin/main` (the workflow
> releases whatever is banked there), then `gh workflow run changesets.yml
> --ref main`, find the run with `gh run list --workflow=changesets.yml
> --limit 1`, watch it with `gh run watch <run-id> --exit-status`, and go to
> Step 4. Use the local `scripts/release.sh` flow (Steps 0–3) only when
> Actions is unavailable or the user asks for it, and never while a
> changesets.yml run is in flight on the same version (they'd race to tag it).

The job is: **detect the bump → gate → bump/tag/push → watch CI → confirm
published, or stop with a precise report.** Do the whole chain without
hand-holding, but stop and surface (never guess) at the two human-judgment gates
marked **⚠ ASK** below.

## Hard rules (non-negotiable)

- **Never force-push.** No `git push -f`, no `--force-with-lease`, no
  `git reset --hard` on a shared branch, no retag-over-existing. If a tag or push
  conflicts, stop and report — recovery is the user's call.
- **Bump default is `patch`.** Per AGENTS.md: pre-1.0 Rove ships features as
  patches. A `minor`/`major` only happens when the user *explicitly* said so this
  turn, OR a pending changeset already carries that bump — and the second case is
  exactly the trap to flag (see Step 1).
- **Release lands on `main` only.** Verify branch before *and* after. A release on
  a stray feature branch is the #1 historical failure — catch it early.
- **No `--no-verify`, no skipping hooks.** If a gate fails, fix the cause or stop.
- The release commit is `chore: release — X.Y.Z`. No AI/Claude attribution
  anywhere (commit, tag, GitHub release body).

## Step 0 — Preflight

Confirm the working tree is sane and you're where you think you are:

```bash
git rev-parse --abbrev-ref HEAD          # MUST be main (see Step 3)
git status --porcelain                    # working tree must be clean
git fetch origin && git log --oneline origin/main..HEAD   # any unpushed commits?
git log --oneline HEAD..origin/main       # are we behind? if so, surface — don't auto-merge
gh auth status                            # gh must be authed for CI polling
```

`scripts/release.sh` itself refuses a dirty tree (except the files it rewrites),
but do this first so you fail fast with a clear message instead of mid-script.

If `origin/main` is ahead of HEAD, **stop and surface** — Rove main moves fast
(often several releases/day); releasing from a stale base is how versions
collide. Let the user decide whether to pull/rebase.

## Step 1 — Detect the bump (and flag the surprise minor) ⚠ ASK

The bump is **not** chosen by you — it's the max of the pending `.changeset/*.md`
bump types, computed by `changeset version`. Inspect before consuming:

```bash
bun run changeset:status                  # shows pending changesets + resulting bump
ls .changeset/*.md | grep -v README.md    # raw list
# read each one — the first line frontmatter is the bump type:
#   ---
#   "@sma1lboy/rove": minor      ← THIS is the bump that file forces
#   ---
```

Then decide:

- **No pending changesets** → nothing to release. `release.sh` will abort. Tell
  the user and offer to draft one (the `changelog-generator` skill does this).
- **All pending are `patch`** → proceed silently; this is the normal case.
- **Any pending is `minor` or `major`** → **⚠ STOP AND ASK.** This is the
  documented annoyance: an upstream/peer changeset silently promotes the release
  to a minor the user didn't intend. Quote the offending file + its bump line and
  confirm: *"`.changeset/foo.md` carries a `minor` — the release will be X.(Y+1).0,
  not a patch. Intended?"* Only continue on an explicit yes. Do **not** edit
  someone's changeset bump without permission.

Record the predicted next version (current `packages/rove/package.json` version
applied with the detected bump) so you can verify it later.

## Step 2 — Run the gates locally (abort on failure)

`scripts/release.sh` enforces `lint && typecheck && (cd packages/rove && bun
run test)` itself before touching version/CHANGELOG, and `release.yml`
re-runs lint + typecheck + test + build + the behavior suite before
`npm publish`. Running the same set here first just fails fast, before burning a
`changeset version` cycle:

```bash
bun run lint
bun run typecheck
bun run test            # fast Vitest + unix-socket daemon/bridge suite
bun run build
cd packages/rove && bun run perf:golden   # golden perf doctor (~90s incl. binary compile smoke; docs/HARNESS.md §Performance contracts)
```

`perf:golden` ceilings are 2-3× the reference numbers, so a FAIL means a real
structural regression (startup, PTY spawn/wake, per-tab memory, park reclaim)
— treat it like a red test, not jitter; rerun once to confirm before digging.
`perf:golden` is not part of the enforced `release.sh`/`release.yml` gate (opt-in,
local/pre-release only per docs/HARNESS.md), so run it manually here.

`bun run test:behavior` exercises the built CLI against an isolated daemon and
standalone PTY Host with a fake `claude` shim; cases that drive the outer terminal
also need native `node-pty` support. `release.yml` runs the same black-box suite
before `npm publish`. Running it locally first is optional but catches a failure
before the tag push.

If a gate fails: report the exact failing command + output, fix it if it's an
obvious in-scope issue (and re-run the full set), or stop. Never proceed to tag a
red tree.

## Step 3 — Verify branch, then bump/tag/push

```bash
git rev-parse --abbrev-ref HEAD     # MUST print: main
```

If not on `main`, **stop** — do not `checkout`/`merge` to "fix" it autonomously
(concurrent sessions + branch juggling is the documented git-tangle failure).
Surface the actual branch and ask.

On `main` with gates green, run the release script. It is the single source of the
bump→version→CHANGELOG→commit→tag→push sequence — don't reimplement those steps by
hand:

```bash
scripts/release.sh
```

What it does (don't fight it): gate (`lint` → `typecheck` → `test` → `build` →
`behavior`) → `changeset version` → `bun install` + `--frozen-lockfile` →
`lint:fix` on the regenerated JSON → commits `chore: release — X.Y.Z` (**no tag
yet**) → **prompts**, pushes the release commit to `main`, **waits for that
commit's `ci.yml` run to go green** (the Linux/macOS gates the local macOS run
can't prove — v0.8.66 died exactly there), and only then tags `vX.Y.Z` and
pushes the tag.

- Confirm the printed `CURRENT → NEW (vX.Y.Z)` matches your Step 1 prediction. A
  mismatch means a changeset changed under you — stop and re-inspect.
- The script asks `Push now? [y/N]`. Answer `y` only after the version line checks
  out. If the user wanted a dry run / review-before-push, answer `N` — re-running
  the script later resumes (push → wait CI → tag).
- If the CI wait comes back RED, no tag exists and the version is NOT burned:
  land the fix on `main` (no new changeset) and re-run `scripts/release.sh` —
  with zero pending changesets and an untagged committed version it enters
  resume mode and tags the same version at the fixed HEAD.

The push of tag `vX.Y.Z` is what triggers `.github/workflows/release.yml`.

## Step 4 — Poll CI until publish completes

The tag push starts the **Release** workflow (`publish` job: gates → npm publish →
GitHub release). npm is the sole distribution channel — standalone binaries were
dropped 2026-08-02, so an empty release-assets list is normal. Watch the run to
terminal state — don't declare success on push alone:

```bash
gh run list --workflow=release.yml --limit 5          # find the run for this tag
gh run watch <run-id> --exit-status                    # blocks until done; nonzero on failure
# or poll:  gh run view <run-id> --json status,conclusion,jobs
```

On success, verify the packages actually landed (don't trust the green check alone):

```bash
npm view @sma1lboy/rove@<new-version> version          # the published package; must echo the new version
# Every Rove release checks the SDK's current version and publishes it if missing.
npm view @sma1lboy/rove-plugin-sdk@<sdk-version> version
gh release view v<new-version> --json name -q .name    # GitHub release exists
```

Confirm: `@sma1lboy/rove` and both SDK names report their expected versions,
the Rove version matches the tag and `packages/rove/package.json`,
and the release landed on `main` (`git log --oneline -1 origin/main` is the `chore: release` commit).
Then report done with the version, the npm dist-tag it went to (`latest` for
plain semver), and the release URL.

## Step 5 — Diagnose CI failure (auto-fix or stop precisely)

If the run fails, identify the job + step before doing anything:

```bash
gh run view <run-id> --log-failed
```

Map the failure to a cause and act. **Never** retry blindly or force-push.

| Symptom in the log | Likely cause | Action |
|---|---|---|
| `npm publish` → `401`/`403`, `ENEEDAUTH`, `EOTP` | `NPM_TOKEN` secret missing/expired/wrong scope | Code is fine and the tag is published-or-not — **stop and report**. Token rotation is the user's job (Settings → secrets → `NPM_TOKEN`, automation token with `@sma1lboy` publish rights). After they fix it, a re-publish needs a *new* version (npm won't overwrite) — never retag the same version. |
| `npm publish` → `404` on registry / scope | registry URL or scope access wrong | Report; check `.npmrc` auth line + `access: public`. Don't mutate published state. |
| `Verify tag matches package.json` step fails | tag ≠ `package.json` version (retag drift) | Means the tag and the committed version disagree — surface it; do **not** force-retag. The fix is to bump+commit then tag fresh, which is the user's call. |
| Typecheck / test / build red | real regression that local gates somehow missed | Reproduce locally (`bun run typecheck|test|build`), fix in-scope, and note that the tag already pushed — a fix needs a **new** patch release, not a force-push over the tag. |
| `npm publish` → `E409`/`cannot publish over` | version already on npm | The version is already out — likely a double-run. Stop; the next release is a new version. |
| A sibling job (`behavior`/`render-track`/`visual-ground-truth`) fails but `publish` succeeded | flake in a non-blocking rerun | npm already has the package; report it. `gh run rerun <run-id> --failed` is safe for those jobs; re-running `publish` is NOT — it'll hit E409. |

The principle: anything that *changes published artifacts or rewrites history*
(retag, force-push, republish) is **stop-and-report**, not auto-fix. Anything
local and idempotent (re-run a flaky binary matrix, fix a lint/type error for the
*next* release) you may do.

## Prerelease note

For `vX.Y.Z-<id>.N` tags (e.g. `v0.7.0-experimental.0`), the workflow publishes to
the npm dist-tag named after the identifier (`experimental`), so `latest` stays
stable. These come from Changesets prerelease mode (`changeset pre enter <id>` …
`changeset pre exit`), not `release.sh`. If the user asks for a prerelease, follow
RELEASING.md's prerelease section rather than this default flow.
