# Release process

The ONE place the release process is stated. Everything else points here.

`bun run release` cuts a release end to end:

```
verify → draft the changelog from the unreleased commits → the human writes the
operator paragraph → bump → commit → tag → npm publish → push the tag
```

Entry point: `scripts/release.ts` (`bun run release …`). The pure rules it
enforces — boundary selection, commit grouping, noise filtering, changelog
insertion ordering, version formatting, preconditions, the publish gate — live
in `src/release.ts` and are pinned by `test/release.test.ts` with fixtures that
touch neither git nor npm.

## Usage

```
bun run release <major|minor|patch|x.y.z> [--since <sha>] [--branch <name>] [--dry-run] [--yes]
```

| Flag | Meaning |
|---|---|
| `<major\|minor\|patch\|x.y.z>` | required; the bump, or an explicit target version |
| `--since <sha>` | the boundary of the unreleased range **when no `v*` tag exists** (see Boundary) |
| `--branch <name>` | the branch a release is cut from (default `main`) |
| `--yes` | skip the confirmation **prompt** only. Preconditions, the operator paragraph and the publish gate are still enforced — `--yes` never bypasses a precondition |
| `--dry-run` | steps 1–6 plus `npm publish --dry-run`; creates **no commit and no tag** |

## The steps

1. **Preconditions** — refuse loud and change nothing when any fails:
   - HEAD is on the release branch (`main` by default);
   - the tree is clean (`git status --porcelain` empty);
   - `npm whoami` succeeds. When it fails on a TTY the script offers `npm login`
     (spawned with stdio `inherit`) and re-checks; still failing → stop. Without
     a TTY it tells you to run `npm login` yourself and re-run;
   - the target version is not already published
     (`npm view <name>@<version> version` returns nothing);
   - `v<version>` does not already exist.

   Two additional refusals guard the same "never do the wrong thing silently"
   rule: a target version that already has a `## [<version>]` section in
   `CHANGELOG.md`, and an empty unreleased range after noise filtering (both
   mean the boundary or the version is wrong).

2. **Boundary** — the last release is the newest `v*` tag if one exists, else
   the `--since <sha>` argument. If neither is available the script **refuses**
   and says so; a boundary is never guessed.

   This repo currently has **no `v*` tags** (`git tag --list 'v0.9*'` is empty):
   past releases were not retro-tagged. So a release is cut with
   `--since <last-publish-sha>` — e.g. `--since fe66d23` for the 0.9.2 prep.
   Do **not** retro-tag past releases.

3. **Draft** — reads `git log --format='%H%x09%s' <boundary>..HEAD`, groups
   commits by item key (`ITEM-\d+` generally; this repo's keys are
   `AUTOPILOT-\d+`), keeps the shas, and drops release noise: `ship <ITEM>
   (post-approval merge-finisher…)`, `docs(release): …`, `chore(release): …`, and
   pure revert commits. Anything else is kept — including commits with no item
   key, which are rendered under `### (no ITEM key)` rather than silently
   dropped. The draft is written **outside the repo tree** (under the system temp
   dir) so the tree stays clean until the bump.

4. **The operator paragraph (HUMAN-ONLY)** — the script opens `$EDITOR` on the
   draft (or prints its path and waits for Enter when `$EDITOR` is unset). The
   draft carries a `<!-- TODO: operator paragraph -->` marker and the script
   **refuses to continue while the marker is present**. The paragraph is one
   short paragraph naming the batch and saying which changes an operator
   experiences as a runtime shift, then the items — model it on the `## [0.9.1]`
   section of `CHANGELOG.md`; the script prints that section's paragraph as the
   voice reference before opening the editor. An agent cannot write this; it is
   the human's read of what the release means.

5. **Bump + insert** — `npm version <spec> --no-git-tag-version`, then the new
   section is inserted **above** the previous one and the rest of `CHANGELOG.md`
   is preserved byte-for-byte.

6. **Show, then confirm** — the diff, `npm pack --dry-run`'s file list and the
   commit range are printed, then an explicit confirmation is required. The
   publish gate re-checks the operator paragraph and the confirmation
   immediately before publishing; there is no code path to the publish step that
   skips either.

7. **Commit + tag** — `chore(release): v<version>` over `CHANGELOG.md` +
   `package.json`, then `git tag v<version>`.

8. **Publish + push** — `npm publish` with stdio `inherit`, so npm's own 2FA/OTP
   prompt works. No OTP or token is ever passed on a command line. On success,
   `git push` and `git push --tags`.

9. **On failure after the commit, the script stops.** It does not push, does not
   retry the publish, and prints exactly what state the repo is in (commit
   created? tag created? published? pushed?) so a human can decide.

10. **`--dry-run`** runs steps 1–6 plus `npm publish --dry-run` and creates no
    commit and no tag. It *does* leave the computed bump in the working tree so
    the printed diff is the real one; discard it with
    `git checkout -- CHANGELOG.md package.json`.

## Human-only steps

- **Writing the operator paragraph** (step 4). The release cannot proceed
  without it.
- **Any 2FA/OTP prompt** npm raises during `npm publish` (step 8). The script
  hands stdio to npm and never supplies a code itself.
- **The decision to publish** — the explicit confirmation at step 6. `--yes`
  can carry that decision in advance, but nothing else can.
- **`npm login`**, when there is no usable npm session (step 1).

## Manual fallback

Cutting 0.9.1 by hand (`fe66d23`) is what this replaces. If the script cannot be
used, the manual equivalent is the same sequence: pick the boundary from the
last publish commit, draft the changelog section in the file's `- since x.y.z`
convention, write the operator paragraph, `npm version <spec>
--no-git-tag-version`, insert above the previous section, commit
`chore(release): v<version>`, `git tag v<version>`, `npm publish`, `git push`,
`git push --tags`.
