# Cut a release from the CLI (release 10 slice P) Written 2026-09-26 (night). Operator: "Are you able to cut releases with the CLI ops? add that ability if not." Answer today: NO. The only cutter is the `/sites` page's "Release notes" form (`editor/app/sites/components/CutReleaseForm.tsx` → `editor/app/sites/lib/cutReleaseAction.ts`), and the runbook's step 0 ("re-cut the release notes first") is a click. Anchors verified on `main` 5f577437. ## What exists (verified) - `common/lib/changelog.ts`: `cutRelease(source, version, dateISO)` (:58-88; semver + date checks, `## [Unreleased]` → `## [] - `, refuses when nothing is pending), `getLatestReleasedVersion` (:26), `suggestNextVersion` (:31), `extractUnreleasedSection` (:40), `CutReleaseError`. - `cutReleaseAction.ts` (the server action, "use server"): form fields `workspace` (editor|export), `version`, `commit`; the dirty-tree guard (`listDirtyPaths`, `common/lib/git.ts:24` — refuses to commit when anything but the changelog is dirty), `readFile` → `cutRelease(…, todayISO())` → `writeFileAtomic` → optional `commitPath(root, relPath, "Release ")` (`git.ts:36`), then `revalidatePath("/")`, `/changelog`, `/sites`. Paths: `getPaths().editorChangelogFile` / `.exportChangelogFile`, `monorepoRoot`. Returns `{ok:true, version, committed} | {ok:false, error}`. Version strings in `package.json` are never bumped (all 0.1.0) — the changelog heading IS the version; there are no git tags (0). - Release commits so far are one-line heading edits: `0e0d8f59 Release editor 0.9.0`, `31798769 Release export 0.9.0`. - Two CLIs: `pnpm ops` (`scripts/archilyzer-ops.mjs`, plain node, HTTP-only: `ACTIONS` :88-110 → `POST /api/ops/` with `Bearer WORKER_TOKEN`, usage :277; tests `archilyzer-ops.test.mjs` :15-45 pin "an action becomes a POST to its route" and the refused-unknown-action list) and `archilyzer` (`common/bin/archilyzer.ts`, a table of local tsx verbs over `_cli.ts`; tests `_cli.test.ts`; e.g. `index`, `compose:site`, `sync tick`, `settings-example`). - Ops API routes: `editor/app/api/ops//route.ts` on `ops(request, allowedKeys, handler)` from `_lib.ts` (worker-token auth, JSON body, `OpsInputError` → 400, `optString/optBool/reqSlug`, `actionResponse`); `refresh-report/route.ts` is the model for a synchronous (non-job) action. - e2e: `editor/e2e/cut-release.spec.ts` (:53, :74, :95) drives the form against the fixture's changelog copies. Ops routes' e2e precedent: grep `api/ops` in `editor/e2e/*.spec.ts`. ## Decisions (made) 1. **One writer.** New `common/controller/cutRelease.ts`: `cutReleaseForWorkspace({workspace, version, commit, date?})` = the body of `cutReleaseAction` verbatim (guard, read, cut, atomic write, commit), returning `{ok:true, workspace, version, committed, commitSha?, heading} | {ok:false, workspace, error}`. `version: "next"` resolves through `suggestNextVersion(getLatestReleasedVersion(source))` (patch bump) — `"next-minor"` bumps the minor. `cutReleaseAction` becomes a thin FormData adapter over it (keeps `revalidatePath`; the form's fields, labels and `cut-release.spec.ts` unchanged). `workspace: "all"` (CLI/API only) = editor then export with the SAME version, two commits in today's message form; stops at the first failure and reports what was done. 2. **Local verb, the primary path**: `archilyzer release cut [--commit] [--date YYYY-MM-DD]` in the `archilyzer.ts` table; prints one line per workspace (`editor: ## [0.10.0] - 2026-09-26 (committed 1a2b3c4d)`), exit 1 on any failure. Plus `archilyzer release show [editor|export]`: the latest released version, its date, and how many bullets are pending under `[Unreleased]` — the runbook's step-0 check. Works with NO editor running (this rollout's step 0 precedes step 1's restart, and the live editor is a pre-brand build). 3. **Ops verb, the remote path**: `pnpm ops cut-release --json '{"workspace":"editor","version":"next", "commit":true}'` → new `editor/app/api/ops/cut-release/route.ts` (synchronous, `ops()` + `OpsInputError` for a bad workspace/version, `actionResponse`-style body `{ok, results:[…]}`), `ACTIONS` + usage gain `cut-release` (usage says what the body takes and that `all` cuts both). Same controller. Note in usage: the route exists only on an editor running this release or later. 4. No version bump in `package.json`, no git tag (neither is a convention here — say so in the record). 5. `--date` is for a re-cut of an already-dated release only; default today (local time, as the action does). ## Implementation (one Opus slice, branch `cli/cut-release`, its own worktree) 1. Controller + `cutRelease.test.ts` (temp git repo via `git init`/`commit` in a temp dir: cut → heading + commit message; `next` → patch bump of the latest heading; `next-minor`; nothing pending → error; dirty tree with `commit: true` → refused naming the file; `all` stops at the first failure and reports the first as done; `--date` used verbatim; NEVER touches the real repo — every test passes an explicit root). 2. `cutReleaseAction.ts` → adapter; `cut-release.spec.ts` must pass unchanged. 3. `archilyzer.ts` verbs + `_cli.test.ts` cases (parse, usage listing). 4. `/api/ops/cut-release` + `ACTIONS` + usage + `archilyzer-ops.test.mjs` (+1: the action posts to its route; the unknown-action list includes it) + one editor e2e in the ops style (POST with the worker token against the fixture changelogs → 200 with the heading; a bad workspace → 400). 5. Docs: `README.md`/`AGENTS.md` only where they mention cutting a release (grep "Cut release", "re-cut"); the runbook is the parent's. Record `### Slice P, as shipped — cut a release from the CLI (2026-09-26)` in `plans/release-10.md` before `## Rollout`; one `[Unreleased]` bullet in `editor/CHANGELOG.md`. ## Gates (worktree root) tsc; common tests (1,987 baseline on this main → new count); editor unit (85); `pnpm run test:scripts` (173 + 1 → new count); mcp 269 unchanged; `pnpm --filter editor exec next build`; editor e2e detached + queued: `cut-release.spec.ts` + the new ops e2e + every spec that greps `api/ops` (list them). Numbers tool: none. ## Rollout (parent) The runbook's step 0 becomes `pnpm --filter yt-dlp-transcript-common exec tsx bin/archilyzer.ts release show` then `… release cut all next --commit` (or an explicit version) from the primary, before step 1's restart. Prove `pnpm ops cut-release` after the restart on a throwaway: `release show` only.