@entro314labs/release-kit 1.0.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +146 -31
  2. package/package.json +2 -2
  3. package/release.mjs +636 -75
package/README.md CHANGED
@@ -54,7 +54,9 @@ Released v2.5.0
54
54
  | ----------------------------------------------------------------------- | --------------------------------------- |
55
55
  | [📦 Install](#-install) | package, `npx`, or vendored file |
56
56
  | [⚡ Usage](#-usage) | targets, bumps, flags |
57
- | [🔁 What a release does](#-what-a-release-does) | the seven steps, notes, dist-tags |
57
+ | [🧩 Steps](#-steps) | the seven steps and how to select them |
58
+ | [🤖 Assistant](#-assistant-optional) | optional AI drafting |
59
+ | [🌍 Any language](#-any-language) | Rust, Python, tag-only, anything |
58
60
  | [✅ Preflight](#-preflight) | what is checked before anything mutates |
59
61
  | [♻️ Recovering from a failed run](#️-recovering-from-a-failed-run) | why re-running is safe |
60
62
  | [⚙️ Configuration](#️-configuration) | `release.config.json`, publishing, auth |
@@ -136,35 +138,50 @@ Prerelease bumps need `--preid` unless the current version already carries one t
136
138
 
137
139
  ### Flags
138
140
 
139
- | Flag | Effect |
140
- | ------------------ | -------------------------------------------------------------------------- |
141
- | `--dry-run` | Print every step, execute nothing. Preflight still runs and still reports. |
142
- | `--yes`, `-y` | Skip the confirmation prompt. |
143
- | `--preid <id>` | Prerelease identifier: `alpha`, `beta`, `rc`, `next`, `nightly`, `canary`. |
144
- | `--tag <dist-tag>` | Override the npm dist-tag. Always wins over the derived one. |
145
- | `--skip-publish` | Do not publish to the registry. |
146
- | `--skip-release` | Do not create the GitHub release. |
147
- | `--sync <dir>...` | Copy this script into other projects and exit. Touches no git state. |
148
- | `--help`, `-h` | Full flag list. |
149
-
150
- ## 🔁 What a release does
151
-
152
- Steps that do not apply are skipped silently — a project with no changelog, or with
153
- `publish` disabled, simply has fewer steps.
154
-
155
- 1. **Write the version** into `package.json` and any configured `versionFiles`. The value
156
- is replaced in place, so key order, indentation, and trailing newline all survive. A
157
- `package-lock.json` is resynced, because it embeds the root version twice.
158
- 2. **Roll the changelog**: `## [Unreleased]` becomes `## [x.y.z] - YYYY-MM-DD`, with a
159
- fresh empty `## [Unreleased]` reopened above it for the next cycle — the
160
- [Keep a Changelog](https://keepachangelog.com/) convention.
161
- 3. **Commit** the files that actually changed.
162
- 4. **Tag**, annotated, with the release notes as the annotation — so a CI workflow can
163
- read the notes straight off the tag instead of re-deriving them.
164
- 5. **Push** with `git push --follow-tags`, which sends the commit and the tag in one
165
- call. Pushing them separately is how a tag ends up on the remote without its commit.
166
- 6. **Publish** to the registry.
167
- 7. **Create the GitHub release**, marked `--latest` or `--prerelease`.
141
+ | Flag | Effect |
142
+ | ------------------- | -------------------------------------------------------------------------- |
143
+ | `--dry-run` | Print every step, execute nothing. Preflight still runs and still reports. |
144
+ | `--yes`, `-y` | Skip the confirmation prompt. |
145
+ | `--preid <id>` | Prerelease identifier: `alpha`, `beta`, `rc`, `next`, `nightly`, `canary`. |
146
+ | `--dist-tag <name>` | Override the npm dist-tag. Always wins over the derived one. |
147
+ | `--only <steps>` | Run only these steps, comma-separated. |
148
+ | `--skip <steps>` | Run every step except these. |
149
+ | `--sync <dir>...` | Copy this script into other projects and exit. Touches no git state. |
150
+ | `--help`, `-h` | Full flag list. |
151
+
152
+ ## 🧩 Steps
153
+
154
+ A release is seven named steps. They always run in this order — `steps` selects which of
155
+ them execute, it never reorders them.
156
+
157
+ | Step | Default | What it does |
158
+ | ----------- | ------- | ----------------------------------------------------------------------------------------------- |
159
+ | `commit` | off | Commit a dirty working tree with a drafted message ([assistant](#-assistant-optional) required) |
160
+ | `version` | on | Write the version into `package.json` and `versionFiles` |
161
+ | `changelog` | on | Roll `[Unreleased]` into the version, or add drafted notes |
162
+ | `tag` | on | Annotated git tag carrying the release notes |
163
+ | `push` | on | Push the branch and tag together (`--follow-tags`) |
164
+ | `publish` | on | Run the configured `publish` command |
165
+ | `release` | on | Create the GitHub release |
166
+
167
+ `version` and `changelog` write files; those writes are persisted by a release commit made
168
+ automatically when either step runs.
169
+
170
+ ```sh
171
+ release-kit minor --skip publish # everything but publish
172
+ release-kit --only tag,push,release # a version already committed elsewhere
173
+ release-kit minor --commit # add the opt-in commit step
174
+ ```
175
+
176
+ Or fix it per project, and just run `release-kit minor`:
177
+
178
+ ```json
179
+ { "steps": ["commit", "version", "changelog", "tag", "push", "release"] }
180
+ ```
181
+
182
+ `steps` decides **what** runs. Every other key describes **how** a step behaves — `publish`
183
+ is the command, `changelog` is the file. A step whose configuration is `null` runs as a
184
+ no-op and says so, rather than silently meaning "skip".
168
185
 
169
186
  ### Release notes
170
187
 
@@ -194,7 +211,7 @@ version, never guessed:
194
211
 
195
212
  The refusal is deliberate: an unrecognised prerelease identifier has no safe channel, and
196
213
  falling through to `latest` would put a prerelease on the stable line where every
197
- `npm install` picks it up. Pass `--tag <dist-tag>` to choose a channel explicitly.
214
+ `npm install` picks it up. Pass `--dist-tag <name>` to choose a channel explicitly.
198
215
 
199
216
  ## ✅ Preflight
200
217
 
@@ -233,6 +250,54 @@ it stopped. There is no cleanup step, no `--resume`, and nothing to remember.
233
250
  The one case that is not recoverable by re-running is a tag that exists at a _different_
234
251
  commit than `HEAD`. That is a genuine conflict, and it aborts rather than guessing.
235
252
 
253
+ ## 🌍 Any language
254
+
255
+ Only one step is Node-specific: `publish`. Committing, changelog rolling, tagging, pushing
256
+ and GitHub releases are the same everywhere, so `versionFile` points at wherever a project
257
+ keeps its version and the rest works unchanged.
258
+
259
+ | Project | Config |
260
+ | ------------------------------ | ------------------------------------------------------------------------------- |
261
+ | Node (npm) | nothing — `package.json` and `npm publish` are the defaults |
262
+ | Node (pnpm / bun) | `{"publish": "pnpm publish --tag %d"}` or `{"publish": "bun publish --tag %d"}` |
263
+ | Rust | `{"versionFile": "Cargo.toml", "publish": "cargo publish"}` |
264
+ | Python | `{"versionFile": "pyproject.toml", "publish": "uv publish"}` |
265
+ | Go | `{"versionFile": null, "publish": "go list -m %n@%t"}` — the tag is the release |
266
+ | Anything with a `VERSION` file | `{"versionFile": "VERSION", "publish": null}` |
267
+ | Versioned only by tag | `{"versionFile": null}`, then `release-kit 1.2.3` |
268
+
269
+ The publish step also gets a preflight when the command is one it recognises:
270
+
271
+ | Publish command | Authentication | Already published? |
272
+ | --------------- | ------------------------------------- | ----------------------------------- |
273
+ | `npm` / `pnpm` | `whoami` | `view <name>@<version>` |
274
+ | `bun` | `bun pm whoami` | `bun pm view <name>@<version>` |
275
+ | `uv` | `UV_PUBLISH_TOKEN` in the environment | none — `uv` skips duplicates itself |
276
+ | `go` | none needed | `go list -m <module>@<tag>` |
277
+
278
+ Anything else runs as written with no preflight. The project name comes from the manifest —
279
+ `name` in `package.json`, `Cargo.toml` or `pyproject.toml`, `module` in `go.mod` — falling
280
+ back to the repository directory.
281
+
282
+ The format is inferred from the file name: `.json` reads the `"version"` field, `.toml`
283
+ reads the first `version = "x.y.z"` line, and any other file is treated as containing just
284
+ the version. Only the version itself is rewritten, so comments and formatting survive — and
285
+ because the TOML match is anchored to the start of a line, a dependency's
286
+ `serde = { version = "1.0" }` is left alone.
287
+
288
+ For anything else, give a pattern with one capture group around the version. `versionFiles`
289
+ takes the same entries, so several files stay in sync across formats:
290
+
291
+ ```json
292
+ {
293
+ "versionFile": { "path": "version.go", "pattern": "^const Version = \"(.+)\"" },
294
+ "versionFiles": [{ "path": "Chart.yaml", "pattern": "^version: (.+)$" }]
295
+ }
296
+ ```
297
+
298
+ The project name comes from the manifest when there is one (`name` in `package.json`,
299
+ `Cargo.toml` or `pyproject.toml`), and falls back to the repository directory.
300
+
236
301
  ## ⚙️ Configuration
237
302
 
238
303
  `release.config.json`, beside `package.json`. Every key is optional; unknown keys abort
@@ -313,6 +378,56 @@ A project releasing off a non-default branch with a different tag scheme:
313
378
  }
314
379
  ```
315
380
 
381
+ ## 🤖 Assistant (optional)
382
+
383
+ An assistant is an AI CLI already installed on your machine. When one is configured,
384
+ release-kit can write the Conventional Commits message for a dirty working tree and draft
385
+ release notes from the commit log. It is **off by default**, and every failure — not
386
+ installed, not authenticated, timed out, unusable answer — falls back to the behaviour you
387
+ already have. A release is never blocked because a text generator was unavailable.
388
+
389
+ ```sh
390
+ pnpm release minor --commit --assistant claude --assistant-model sonnet --assistant-effort low
391
+ ```
392
+
393
+ | Tool | Invocation | Model | Effort |
394
+ | -------- | ------------ | ------------------- | -------------------------------- |
395
+ | `claude` | `claude -p` | `--assistant-model` | `--assistant-effort` (low … max) |
396
+ | `codex` | `codex exec` | `-m` | `-c model_reasoning_effort=` |
397
+
398
+ Configure it once instead:
399
+
400
+ ```json
401
+ {
402
+ "assistant": { "tool": "claude", "model": "sonnet", "effort": "low" }
403
+ }
404
+ ```
405
+
406
+ `"assistant": "auto"` picks the first tool found on PATH; `"claude"` is shorthand for
407
+ `{ "tool": "claude" }`. Naming a tool that is not installed is an error rather than a silent
408
+ downgrade, so a configured pipeline fails loudly; `"auto"` degrades quietly by design.
409
+
410
+ ### What it does
411
+
412
+ - **`--commit`** stages the working tree, drafts a Conventional Commits message for the
413
+ staged diff, and commits — instead of refusing to release. The subject is validated
414
+ against the Conventional Commits grammar; an answer that does not parse is rejected rather
415
+ than committed. Attribution lines (`Co-Authored-By`, `Generated with`) are stripped, so
416
+ the tool never signs your commits.
417
+ - **Release notes** are drafted from the commits since the last tag when `CHANGELOG.md` has
418
+ no section for the version. They are written into the changelog, used as the tag
419
+ annotation, and posted as the GitHub release body — the same "written once, lands in three
420
+ places" path a hand-written section takes.
421
+
422
+ With `--commit`, notes are drafted _after_ that commit lands, so they describe the change it
423
+ just made. Merge, release, `WIP` and `fixup!`/`squash!` commits are excluded from the prompt.
424
+
425
+ ### Adding another tool
426
+
427
+ One row in `ASSISTANTS` in `release.mjs`: the command, the args that make it read a prompt on
428
+ stdin, and how it spells model and effort. Tools whose stdout carries session scaffolding
429
+ declare `outputFile` and the answer is read from there instead.
430
+
316
431
  ## 🔄 Keeping vendored copies in sync
317
432
 
318
433
  Installed as a dependency, updates come from your package manager and there is nothing to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entro314labs/release-kit",
3
- "version": "1.0.3",
3
+ "version": "2.1.0",
4
4
  "description": "Single-file, zero-dependency release mechanism for JS/TS/Node projects: version bump, changelog roll, commit, annotated tag, push, publish, GitHub release",
5
5
  "keywords": [
6
6
  "changelog",
@@ -49,6 +49,6 @@
49
49
  "oxlint": "^1.78.0"
50
50
  },
51
51
  "engines": {
52
- "node": ">=18"
52
+ "node": ">=22"
53
53
  }
54
54
  }
package/release.mjs CHANGED
@@ -32,7 +32,8 @@
32
32
  */
33
33
 
34
34
  import { execFileSync, execSync } from 'node:child_process'
35
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
35
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
36
+ import { tmpdir } from 'node:os'
36
37
  import { basename, join, relative, resolve, sep } from 'node:path'
37
38
  import { createInterface } from 'node:readline/promises'
38
39
 
@@ -50,22 +51,53 @@ import { createInterface } from 'node:readline/promises'
50
51
  * branch string the only branch a release may run from; null to allow any
51
52
  * remote string git remote to push to
52
53
  * changelog string changelog path; null to disable changelog handling
53
- * versionFiles string[] extra JSON files whose top-level "version" is kept in sync
54
+ * versionFile string|object|null where the project's version lives; null when the
55
+ * repository versions by git tag alone
56
+ * versionFiles array further files whose version is kept in sync; each is a path
57
+ * or { path, pattern }
54
58
  * publish string publish command; null to skip publishing entirely
55
59
  * commitMessage string release commit subject
56
60
  * releaseTitle string GitHub release title
57
61
  * assets string[] files attached to the GitHub release
62
+ * assistant string|object drafting CLI for commit messages and notes. A key of
63
+ * ASSISTANTS, "auto" for the first available, or null. The
64
+ * object form { tool, model, effort } also pins which model and
65
+ * reasoning effort that CLI is invoked with.
58
66
  */
67
+ /**
68
+ * The release pipeline, in the only order that is safe to run it. `steps` selects which of
69
+ * these execute; it never reorders them — publishing before tagging, or pushing before
70
+ * committing, is a mistake the tool should not let you express.
71
+ *
72
+ * commit commit a dirty working tree (opt-in; touches work that predates the release)
73
+ * version write the version into package.json and versionFiles
74
+ * changelog roll [Unreleased] into the version, or add drafted notes
75
+ * tag annotated git tag carrying the release notes
76
+ * push push the branch and the tag together
77
+ * publish run the configured publish command
78
+ * release create the GitHub release
79
+ *
80
+ * `version` and `changelog` write files; those writes are persisted by a release commit
81
+ * made automatically when either step runs.
82
+ */
83
+ const STEPS = ['commit', 'version', 'changelog', 'tag', 'push', 'publish', 'release']
84
+
85
+ /** Everything but `commit`, which is opt-in because it commits work you did not stage. */
86
+ const DEFAULT_STEPS = STEPS.filter((name) => name !== 'commit')
87
+
59
88
  const DEFAULTS = {
89
+ steps: DEFAULT_STEPS,
60
90
  tagPrefix: 'v',
61
91
  branch: 'main',
62
92
  remote: 'origin',
63
93
  changelog: 'CHANGELOG.md',
94
+ versionFile: 'package.json',
64
95
  versionFiles: [],
65
96
  publish: 'npm publish --tag %d',
66
97
  commitMessage: 'chore(release): %t',
67
98
  releaseTitle: '%t',
68
99
  assets: [],
100
+ assistant: null,
69
101
  }
70
102
 
71
103
  /**
@@ -94,13 +126,23 @@ Target (optional; defaults to the version already in package.json):
94
126
  prepatch preminor premajor prerelease
95
127
  prerelease bump; needs --preid unless it can be inferred
96
128
 
129
+ Steps, in the fixed order they run. All but "commit" run by default:
130
+ ${STEPS.join(' ')}
131
+
97
132
  Flags:
133
+ --only <steps> run only these steps, comma-separated
134
+ --skip <steps> run every step except these
135
+ --commit add the opt-in commit step: commit a dirty working tree with a
136
+ drafted Conventional Commits message instead of refusing to release
98
137
  --preid <id> prerelease identifier (alpha, beta, rc, next, nightly, canary)
99
- --tag <dist-tag> override the npm dist-tag (default: derived from the version)
138
+ --dist-tag <name> override the npm dist-tag (default: derived from the version)
100
139
  --dry-run print every step and execute nothing
101
140
  --yes, -y skip the confirmation prompt
102
- --skip-publish do not publish to the registry
103
- --skip-release do not create the GitHub release
141
+ --assistant <name> drafting CLI to use: auto, none, claude, codex
142
+ --assistant-model <name>
143
+ model the assistant runs with (e.g. sonnet, opus)
144
+ --assistant-effort <level>
145
+ reasoning effort the assistant runs with (low … max)
104
146
  --sync <dir>... copy this script into other projects' scripts/ and exit
105
147
  --help, -h show this
106
148
 
@@ -192,6 +234,203 @@ function tryRead(command, args) {
192
234
  */
193
235
  const succeeds = (command, args) => tryRead(command, args) !== null
194
236
 
237
+ // ─────────────────────────────────────────────────────────────────────────────
238
+ // ASSISTANTS — optional CLIs that draft prose (commit messages, release notes)
239
+ // ─────────────────────────────────────────────────────────────────────────────
240
+
241
+ /**
242
+ * Drafting tools, tried in this order when `assistant` is "auto". Each is a CLI that
243
+ * reads a prompt on stdin and writes plain text to stdout. Supporting another harness is
244
+ * one row here; nothing else in the file names a specific tool.
245
+ */
246
+ const ASSISTANTS = {
247
+ claude: {
248
+ command: 'claude',
249
+ args: ['-p'],
250
+ probe: ['--version'],
251
+ model: (m) => ['--model', m],
252
+ effort: (e) => ['--effort', e],
253
+ },
254
+ codex: {
255
+ command: 'codex',
256
+ args: ['exec', '--skip-git-repo-check', '--sandbox', 'read-only'],
257
+ probe: ['--version'],
258
+ model: (m) => ['-m', m],
259
+ effort: (e) => ['-c', `model_reasoning_effort="${e}"`],
260
+ // `codex exec` streams session scaffolding (MCP notices, hook logs) to stdout, so the
261
+ // answer is only clean when written to a file with --output-last-message.
262
+ outputFile: (path) => ['--output-last-message', path],
263
+ },
264
+ }
265
+
266
+ /** How long a draft may take before the release gives up and falls back. */
267
+ const DRAFT_TIMEOUT_MS = 180_000
268
+
269
+ /**
270
+ * Lines a drafting tool may append that must never reach a commit message or release
271
+ * notes: attribution for the tool itself, and the markdown fences models wrap output in.
272
+ */
273
+ function cleanDraft(text) {
274
+ return text
275
+ .replace(/^\s*```[a-z]*\s*\n?/i, '')
276
+ .replace(/\n?```\s*$/, '')
277
+ .split('\n')
278
+ .filter(
279
+ (line) =>
280
+ !/^\s*co-authored-by:/i.test(line) &&
281
+ !/^\s*(🤖\s*)?generated with/i.test(line) &&
282
+ !/^\s*signed-off-by:\s*claude/i.test(line),
283
+ )
284
+ .join('\n')
285
+ .trim()
286
+ }
287
+
288
+ /**
289
+ * Release notes carry two artefacts a commit message does not: stray code fences around
290
+ * part of the output, and a trailing paragraph explaining the model's reasoning. Keep the
291
+ * document only up to its last heading or list item; prose after that is commentary.
292
+ */
293
+ function cleanNotes(text) {
294
+ const lines = cleanDraft(text)
295
+ .split('\n')
296
+ .filter((line) => !/^\s*```/.test(line))
297
+ let end = lines.length
298
+ while (end > 0) {
299
+ const line = lines[end - 1]
300
+ if (!line.trim()) {
301
+ end -= 1
302
+ continue
303
+ }
304
+ // A heading, a bullet, or an indented continuation of a bullet: the document proper.
305
+ if (/^\s*[-*+] /.test(line) || /^#{1,6} /.test(line) || /^\s+\S/.test(line)) break
306
+ end -= 1
307
+ }
308
+ return lines.slice(0, end).join('\n').trim() || null
309
+ }
310
+
311
+ /**
312
+ * Run the selected assistant against a prompt.
313
+ *
314
+ * Every failure mode — not installed, not authenticated, timed out, empty answer — returns
315
+ * null rather than throwing. Drafting is a convenience; a release must never be blocked
316
+ * because a text generator was unavailable.
317
+ *
318
+ * @returns {string | null} the cleaned draft, or null when unavailable
319
+ */
320
+ function runAssistant(prompt) {
321
+ if (!assistant) return null
322
+
323
+ const args = [...assistant.args]
324
+ if (assistantModel && assistant.model) args.push(...assistant.model(assistantModel))
325
+ if (assistantEffort && assistant.effort) args.push(...assistant.effort(assistantEffort))
326
+
327
+ // Tools whose stdout carries session scaffolding hand back the answer through a file.
328
+ const answerFile = assistant.outputFile
329
+ ? join(mkdtempSync(join(tmpdir(), 'release-kit-')), 'answer.md')
330
+ : null
331
+ if (answerFile) args.push(...assistant.outputFile(answerFile))
332
+
333
+ try {
334
+ const stdout = execFileSync(assistant.command, args, {
335
+ input: prompt,
336
+ encoding: 'utf8',
337
+ timeout: DRAFT_TIMEOUT_MS,
338
+ stdio: ['pipe', 'pipe', 'ignore'],
339
+ maxBuffer: 10 * 1024 * 1024,
340
+ })
341
+ const answer = answerFile
342
+ ? existsSync(answerFile)
343
+ ? readFileSync(answerFile, 'utf8')
344
+ : ''
345
+ : stdout
346
+ return cleanDraft(answer) || null
347
+ } catch {
348
+ return null
349
+ }
350
+ }
351
+
352
+ /** Commit subjects since the last tag, with release and merge commits filtered out. */
353
+ function commitsSinceLastTag() {
354
+ const lastTag = tryRead('git', ['describe', '--tags', '--abbrev=0'])
355
+ const range = lastTag ? `${lastTag}..HEAD` : 'HEAD'
356
+ const log = tryRead('git', ['log', '--format=%s', range]) ?? ''
357
+ return {
358
+ lastTag,
359
+ subjects: log
360
+ .split('\n')
361
+ .filter(
362
+ (s) =>
363
+ s && !/^chore\(release\)/i.test(s) && !/^Merge (branch|pull request|remote)/i.test(s),
364
+ ),
365
+ }
366
+ }
367
+
368
+ const CONVENTIONAL_TYPES = 'build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test'
369
+ const CONVENTIONAL_RE = new RegExp(`^(${CONVENTIONAL_TYPES})(\\([^)]+\\))?!?: .+`)
370
+
371
+ /**
372
+ * Draft a Conventional Commits message for the staged changes.
373
+ *
374
+ * @returns {string | null} a message whose subject is valid Conventional Commits, or null
375
+ */
376
+ function draftCommitMessage() {
377
+ const stat = tryRead('git', ['diff', '--cached', '--stat'])
378
+ // Cap the diff: a large one wastes the context window and rarely improves the subject.
379
+ const diff = tryRead('git', ['diff', '--cached', '--unified=1'])?.slice(0, 12_000)
380
+ if (!stat) return null
381
+
382
+ const prompt = [
383
+ 'Write a Conventional Commits message for these staged changes.',
384
+ '',
385
+ 'Rules:',
386
+ `- Subject: "<type>(<optional scope>): <description>" where type is one of ${CONVENTIONAL_TYPES.split('|').join(', ')}.`,
387
+ '- Subject in the imperative mood, no trailing period, under 72 characters.',
388
+ '- Add a body only if the change needs explanation; separate it with a blank line.',
389
+ '- Output the raw commit message and nothing else: no markdown fences, no preamble.',
390
+ '- Do NOT add Co-Authored-By, Signed-off-by, or any attribution or tool credit.',
391
+ '',
392
+ 'Files changed:',
393
+ stat,
394
+ '',
395
+ 'Diff:',
396
+ diff ?? '(unavailable)',
397
+ ].join('\n')
398
+
399
+ const message = runAssistant(prompt)
400
+ if (!message) return null
401
+ const [subject] = message.split('\n')
402
+ return CONVENTIONAL_RE.test(subject) ? message : null
403
+ }
404
+
405
+ /**
406
+ * Draft release notes from the commits since the last tag, in the changelog's own style.
407
+ *
408
+ * @returns {string | null} markdown body (no version heading), or null
409
+ */
410
+ function draftReleaseNotes(version, subjects, lastTag) {
411
+ if (!subjects.length) return null
412
+
413
+ const prompt = [
414
+ `Write release notes for version ${version}.`,
415
+ '',
416
+ 'Rules:',
417
+ '- Group the changes under Keep a Changelog headings (`### Added`, `### Changed`,',
418
+ ' `### Fixed`, `### Removed`), including only the headings that apply.',
419
+ '- One bullet per user-visible change. Merge related commits into a single bullet.',
420
+ '- Omit internal chores: CI, linting, formatting, dependency bumps, version bumps.',
421
+ '- Write for someone upgrading: say what changed for them, not which files moved.',
422
+ '- Plain, factual language. No hype, no emoji, no concluding summary.',
423
+ '- Output only the markdown body: no version heading, no code fences, no attribution.',
424
+ '- Do NOT explain your reasoning or add any commentary before or after the notes.',
425
+ '',
426
+ `Commit subjects since ${lastTag ?? 'the start of the project'}:`,
427
+ ...subjects.map((s) => `- ${s}`),
428
+ ].join('\n')
429
+
430
+ const drafted = runAssistant(prompt)
431
+ return drafted ? cleanNotes(drafted) : null
432
+ }
433
+
195
434
  /** One-line rendering of an argv, so a multi-line arg (release notes) stays readable. */
196
435
  const formatCommand = (command, args) =>
197
436
  [
@@ -392,6 +631,17 @@ function rollUnreleased(text, version, date) {
392
631
  return text.slice(0, match.index) + released + text.slice(match.index + match[0].length)
393
632
  }
394
633
 
634
+ /**
635
+ * Insert a section for a version above the newest existing one, so drafted notes are kept
636
+ * in the changelog rather than only reaching the tag and the GitHub release.
637
+ */
638
+ function insertChangelogSection(text, version, date, body) {
639
+ const entry = `## [${version}] - ${date}\n\n${body}\n`
640
+ const firstSection = /^## /m.exec(text)
641
+ if (!firstSection) return `${text.trimEnd()}\n\n${entry}`
642
+ return `${text.slice(0, firstSection.index)}${entry}\n${text.slice(firstSection.index)}`
643
+ }
644
+
395
645
  // ─────────────────────────────────────────────────────────────────────────────
396
646
  // JSON FILES
397
647
  // ─────────────────────────────────────────────────────────────────────────────
@@ -399,18 +649,84 @@ function rollUnreleased(text, version, date) {
399
649
  const readJson = (path) => JSON.parse(readFileSync(path, 'utf8'))
400
650
 
401
651
  /**
402
- * Write a top-level "version" into a JSON file without reformatting the rest of it: the
403
- * value is replaced in place, so key order, indentation and trailing newline all survive.
652
+ * Where a project keeps its version. The format is inferred from the file name, so the
653
+ * common cases need nothing but a path:
654
+ *
655
+ * *.json the JSON `"version": "x.y.z"` field
656
+ * *.toml the first `version = "x.y.z"` line — the `[package]` / `[project]`
657
+ * table comes first in Cargo.toml and pyproject.toml
658
+ * anything else the whole file is the version (a plain VERSION file)
659
+ *
660
+ * An explicit `pattern` overrides inference for formats not listed. Whatever the source,
661
+ * it must capture the version in exactly one group, which is what gets replaced on write.
662
+ */
663
+ const VERSION_PATTERNS = {
664
+ json: /^\s*"version"\s*:\s*"([^"]*)"/m,
665
+ toml: /^version\s*=\s*"([^"]*)"/m,
666
+ }
667
+
668
+ /** The project name in the same files, used for display and the registry lookup. */
669
+ const NAME_PATTERNS = {
670
+ json: /^\s*"name"\s*:\s*"([^"]*)"/m,
671
+ toml: /^name\s*=\s*"([^"]*)"/m,
672
+ }
673
+
674
+ /** @returns {string | null} the project name recorded beside the version */
675
+ function readNameFrom(entry) {
676
+ const source = versionSource(entry)
677
+ const kind = source.path.endsWith('.json')
678
+ ? 'json'
679
+ : source.path.endsWith('.toml')
680
+ ? 'toml'
681
+ : null
682
+ if (!kind || !existsSync(source.path)) return null
683
+ return NAME_PATTERNS[kind].exec(readFileSync(source.path, 'utf8'))?.[1] ?? null
684
+ }
685
+
686
+ /** Normalise a versionFile / versionFiles entry to { path, pattern }. */
687
+ const versionSource = (entry) => (typeof entry === 'string' ? { path: entry } : entry)
688
+
689
+ /** The regex for a source, or null when the whole file is the version. */
690
+ function patternFor({ path, pattern }) {
691
+ if (pattern) return new RegExp(pattern, 'm')
692
+ if (path.endsWith('.json')) return VERSION_PATTERNS.json
693
+ if (path.endsWith('.toml')) return VERSION_PATTERNS.toml
694
+ return null
695
+ }
696
+
697
+ /** @returns {string | null} the version recorded in a source file */
698
+ function readVersionFrom(entry) {
699
+ const source = versionSource(entry)
700
+ const text = readFileSync(source.path, 'utf8')
701
+ const pattern = patternFor(source)
702
+ if (!pattern) return text.trim() || null
703
+ const match = pattern.exec(text)
704
+ return match ? match[1] : null
705
+ }
706
+
707
+ /**
708
+ * Replace the version in a source file, touching nothing else: only the captured range is
709
+ * rewritten, so formatting, key order and comments all survive.
404
710
  *
405
711
  * @returns {boolean} whether the file needed changing
406
712
  */
407
- function writeVersionInto(path, version) {
408
- const text = readFileSync(path, 'utf8')
409
- const field = /^(\s*"version"\s*:\s*)"[^"]*"/m
410
- if (!field.test(text)) throw new Error(`${path} has no top-level "version" field`)
411
- const updated = text.replace(field, `$1"${version}"`)
713
+ function writeVersionInto(entry, version) {
714
+ const source = versionSource(entry)
715
+ const text = readFileSync(source.path, 'utf8')
716
+ const pattern = patternFor(source)
717
+
718
+ let updated
719
+ if (pattern) {
720
+ const match = pattern.exec(text)
721
+ if (!match) throw new Error(`${source.path} has no version matching ${pattern}`)
722
+ const start = match.index + match[0].indexOf(match[1])
723
+ updated = text.slice(0, start) + version + text.slice(start + match[1].length)
724
+ } else {
725
+ updated = `${version}\n`
726
+ }
727
+
412
728
  if (updated === text) return false
413
- if (!dryRun) writeFileSync(path, updated)
729
+ if (!dryRun) writeFileSync(source.path, updated)
414
730
  return true
415
731
  }
416
732
 
@@ -429,10 +745,14 @@ const option = (name) => {
429
745
 
430
746
  const dryRun = flag('--dry-run')
431
747
  const assumeYes = flag('--yes') || flag('-y')
432
- const skipPublish = flag('--skip-publish')
433
- const skipRelease = flag('--skip-release')
434
- const explicitDistTag = option('--tag')
748
+ const onlySteps = option('--only')
749
+ const skippedSteps = option('--skip')
750
+ const explicitDistTag = option('--dist-tag')
435
751
  const requestedPreid = option('--preid')
752
+ const autoCommit = flag('--commit')
753
+ const requestedAssistant = option('--assistant')
754
+ const requestedModel = option('--assistant-model')
755
+ const requestedEffort = option('--assistant-effort')
436
756
 
437
757
  if (flag('--help') || flag('-h')) {
438
758
  console.log(USAGE)
@@ -472,7 +792,31 @@ if (flag('--sync')) {
472
792
  process.exit(0)
473
793
  }
474
794
 
475
- const target = argv.find((a) => !a.startsWith('-') && a !== explicitDistTag && a !== requestedPreid)
795
+ /**
796
+ * Options that consume the argument after them. Without this list a positional target is
797
+ * found by guessing, and `--only tag,push` gets read as the version to release.
798
+ */
799
+ const VALUE_OPTIONS = new Set([
800
+ '--only',
801
+ '--skip',
802
+ '--preid',
803
+ '--dist-tag',
804
+ '--assistant',
805
+ '--assistant-model',
806
+ '--assistant-effort',
807
+ ])
808
+
809
+ /** The version or bump target: the first argument that is neither a flag nor a flag's value. */
810
+ const target = (() => {
811
+ for (let i = 0; i < argv.length; i += 1) {
812
+ if (VALUE_OPTIONS.has(argv[i])) {
813
+ i += 1
814
+ continue
815
+ }
816
+ if (!argv[i].startsWith('-')) return argv[i]
817
+ }
818
+ return undefined
819
+ })()
476
820
 
477
821
  // ─────────────────────────────────────────────────────────────────────────────
478
822
  // SETUP
@@ -497,11 +841,6 @@ if (existsSync(localManifest) && localManifest !== rootManifest) {
497
841
  }
498
842
  process.chdir(root)
499
843
 
500
- if (!existsSync('package.json')) abort(`no package.json at ${root}`)
501
- const pkg = readJson('package.json')
502
- if (!pkg.version) abort('package.json has no "version" field')
503
- if (!parseVersion(pkg.version)) abort(`package.json version "${pkg.version}" is not semver`)
504
-
505
844
  const config = {
506
845
  ...DEFAULTS,
507
846
  ...(existsSync('release.config.json') ? readJson('release.config.json') : {}),
@@ -509,25 +848,125 @@ const config = {
509
848
  const unknownKeys = Object.keys(config).filter((key) => !(key in DEFAULTS))
510
849
  if (unknownKeys.length) abort(`release.config.json has unknown keys: ${unknownKeys.join(', ')}`)
511
850
 
851
+ /**
852
+ * Which steps run: config provides the baseline, --only replaces it, --skip subtracts, and
853
+ * --commit adds the opt-in step. Unknown names are an error rather than a silent no-op.
854
+ */
855
+ const parseStepList = (value) =>
856
+ value
857
+ .split(',')
858
+ .map((name) => name.trim())
859
+ .filter(Boolean)
860
+
861
+ /**
862
+ * The project's current version and name. Both normally come from package.json, but the
863
+ * only Node-specific thing about a release is publishing: `versionFile` points at whatever
864
+ * file this project keeps its version in, and `null` means the repository versions by git
865
+ * tag alone and the version has to be passed explicitly.
866
+ */
867
+ const manifest = existsSync('package.json') ? readJson('package.json') : null
868
+ const versionFile = config.versionFile ? versionSource(config.versionFile) : null
869
+
870
+ if (versionFile && !existsSync(versionFile.path)) {
871
+ abort(`versionFile ${versionFile.path} does not exist`)
872
+ }
873
+
874
+ const currentVersion = versionFile ? readVersionFrom(versionFile) : null
875
+ if (versionFile && !currentVersion) {
876
+ abort(`could not read a version from ${versionFile.path}`)
877
+ }
878
+ if (currentVersion && !parseVersion(currentVersion)) {
879
+ abort(`${versionFile.path} version "${currentVersion}" is not semver`)
880
+ }
881
+
882
+ /** Used for display, the registry lookup, and the %n token. */
883
+ const goModule = existsSync('go.mod')
884
+ ? (/^module\s+(\S+)/m.exec(readFileSync('go.mod', 'utf8'))?.[1] ?? null)
885
+ : null
886
+ const projectName =
887
+ manifest?.name ?? (versionFile ? readNameFrom(versionFile) : null) ?? goModule ?? basename(root)
888
+
889
+ // Validate every name that was asked for, not just the ones that survive: a typo in
890
+ // --skip would otherwise delete nothing and silently run the step you meant to drop.
891
+ const requestedStepNames = [
892
+ ...(onlySteps ? parseStepList(onlySteps) : []),
893
+ ...(skippedSteps ? parseStepList(skippedSteps) : []),
894
+ ...(Array.isArray(config.steps) ? config.steps : []),
895
+ ]
896
+ const unknownSteps = [...new Set(requestedStepNames)].filter((name) => !STEPS.includes(name))
897
+ if (unknownSteps.length) {
898
+ abort(`unknown step(s): ${unknownSteps.join(', ')}\n Known steps: ${STEPS.join(', ')}`)
899
+ }
900
+
901
+ const steps = new Set(onlySteps ? parseStepList(onlySteps) : (config.steps ?? DEFAULT_STEPS))
902
+ if (skippedSteps) for (const name of parseStepList(skippedSteps)) steps.delete(name)
903
+ if (autoCommit) steps.add('commit')
904
+ const runs = (name) => steps.has(name)
905
+
906
+ /**
907
+ * The drafting tool, resolved from --assistant then config. "auto" picks the first one
908
+ * present on PATH; a named tool must be known and installed, otherwise it is an error
909
+ * rather than a silent downgrade to no drafting.
910
+ */
911
+ const assistantConfig =
912
+ config.assistant && typeof config.assistant === 'object' ? config.assistant : {}
913
+ const assistantChoice =
914
+ requestedAssistant ??
915
+ (typeof config.assistant === 'string' ? config.assistant : assistantConfig.tool) ??
916
+ 'none'
917
+ const assistantModel = requestedModel ?? assistantConfig.model ?? null
918
+ const assistantEffort = requestedEffort ?? assistantConfig.effort ?? null
919
+ let assistantName = null
920
+ if (assistantChoice !== 'none' && assistantChoice !== null) {
921
+ const candidates =
922
+ assistantChoice === 'auto'
923
+ ? Object.keys(ASSISTANTS)
924
+ : [assistantChoice].filter((name) => {
925
+ if (name in ASSISTANTS) return true
926
+ abort(
927
+ `unknown assistant "${name}" (known: ${Object.keys(ASSISTANTS).join(', ')}, auto, none)`,
928
+ )
929
+ return false
930
+ })
931
+ assistantName =
932
+ candidates.find((name) => succeeds(ASSISTANTS[name].command, ASSISTANTS[name].probe)) ?? null
933
+ if (!assistantName && assistantChoice !== 'auto') {
934
+ abort(
935
+ `assistant "${assistantChoice}" is configured but \`${ASSISTANTS[assistantChoice].command}\` is not on PATH`,
936
+ )
937
+ }
938
+ }
939
+ const assistant = assistantName ? ASSISTANTS[assistantName] : null
940
+
512
941
  // ─────────────────────────────────────────────────────────────────────────────
513
942
  // RESOLVE THE TARGET VERSION
514
943
  // ─────────────────────────────────────────────────────────────────────────────
515
944
 
516
945
  console.log(
517
- bold(`${pkg.name} release`) + (dryRun ? ` ${yellow('(dry run — nothing will execute)')}` : ''),
946
+ bold(`${projectName} release`) +
947
+ (dryRun ? ` ${yellow('(dry run — nothing will execute)')}` : ''),
518
948
  )
519
949
 
520
950
  let version
521
951
  if (!target) {
522
- ;({ version } = pkg)
952
+ if (!currentVersion) {
953
+ abort(
954
+ 'this repository has no versionFile, so there is no version to default to.\n' +
955
+ ' Pass one explicitly: release-kit 1.2.3',
956
+ )
957
+ }
958
+ version = currentVersion
523
959
  } else if (BUMPS.has(target)) {
524
- const preid = requestedPreid ?? preidOf(pkg.version)
960
+ if (!currentVersion) {
961
+ abort(`a ${target} bump needs a versionFile to bump from. Pass a version explicitly instead.`)
962
+ }
963
+ const preid = requestedPreid ?? preidOf(currentVersion)
525
964
  if (target.startsWith('pre') && !preid) {
526
965
  abort(
527
966
  `a ${target} bump from a stable version needs --preid <${[...KNOWN_CHANNELS].sort().join('|')}>`,
528
967
  )
529
968
  }
530
- version = incrementVersion(pkg.version, target, preid)
969
+ version = incrementVersion(currentVersion, target, preid)
531
970
  } else if (parseVersion(target)) {
532
971
  version = target
533
972
  } else {
@@ -536,7 +975,7 @@ if (!target) {
536
975
 
537
976
  const tag = `${config.tagPrefix}${version}`
538
977
  const isPrerelease = parseVersion(version).pre.length > 0
539
- const bumping = version !== pkg.version
978
+ const bumping = !!versionFile && version !== currentVersion && runs('version')
540
979
 
541
980
  let distTag
542
981
  try {
@@ -549,7 +988,7 @@ const expandWith = (template, transform) =>
549
988
  template
550
989
  .replaceAll('%v', transform(version))
551
990
  .replaceAll('%t', transform(tag))
552
- .replaceAll('%n', transform(pkg.name))
991
+ .replaceAll('%n', transform(projectName))
553
992
  .replaceAll('%d', transform(distTag))
554
993
 
555
994
  /** Expand tokens for a message or title, which never reaches a shell. */
@@ -563,17 +1002,32 @@ const expand = (template) => expandWith(template, (value) => value)
563
1002
  const shellQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`
564
1003
  const expandShell = (template) => expandWith(template, shellQuote)
565
1004
 
566
- const publishCommand = config.publish && !skipPublish ? expandShell(config.publish) : null
1005
+ const publishCommand = runs('publish') && config.publish ? expandShell(config.publish) : null
567
1006
 
568
1007
  /**
569
- * npm and pnpm answer `whoami` and `view` identically and share `~/.npmrc`, so whichever
570
- * one publishes can also run the registry preflight. Checking with the wrong one mislabels
571
- * the result. A publish command driving anything else (vsce, a shell pipeline) is left
572
- * alone — it cannot be introspected, and guessing would invent failures.
1008
+ * Registries whose preflight can be run, keyed by the first word of the publish command.
1009
+ * Each declares how that CLI answers "who am I" and "does this version already exist";
1010
+ * either may be null when the tool has no such notion. A publish command outside this
1011
+ * table (vsce, a shell pipeline) is run as written with no preflight — it cannot be
1012
+ * introspected, and guessing would invent failures.
573
1013
  */
574
- const REGISTRY_CLIS = new Set(['npm', 'pnpm'])
1014
+ const REGISTRIES = {
1015
+ npm: { whoami: ['whoami'], published: (name, v) => ['view', `${name}@${v}`, 'version'] },
1016
+ pnpm: { whoami: ['whoami'], published: (name, v) => ['view', `${name}@${v}`, 'version'] },
1017
+ bun: {
1018
+ whoami: ['pm', 'whoami'],
1019
+ published: (name, v) => ['pm', 'view', `${name}@${v}`, 'version'],
1020
+ },
1021
+ // uv authenticates with a token from the environment rather than a logged-in session,
1022
+ // and skips duplicate uploads itself via --check-url, so there is no version lookup.
1023
+ uv: { env: ['UV_PUBLISH_TOKEN', 'UV_PUBLISH_PASSWORD'], login: 'set UV_PUBLISH_TOKEN' },
1024
+ // For Go the tag is the release; `go list` warms the module proxy and doubles as the
1025
+ // check for whether this version is already resolvable.
1026
+ go: { published: (name, v) => ['list', '-m', `${name}@${v}`] },
1027
+ }
1028
+
575
1029
  const publishCli = publishCommand?.trim().split(/\s+/)[0]
576
- const registryCli = REGISTRY_CLIS.has(publishCli) ? publishCli : null
1030
+ const registry = publishCli ? REGISTRIES[publishCli] : null
577
1031
 
578
1032
  /**
579
1033
  * CI publishing over OIDC ("trusted publishing") carries no token at all: `whoami` fails
@@ -587,7 +1041,10 @@ const isTrustedPublishing =
587
1041
  !!process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN) ||
588
1042
  !!process.env.NPM_ID_TOKEN
589
1043
 
590
- console.log(` ${dim(`${pkg.version} → ${version} tag ${tag} dist-tag ${distTag}`)}`)
1044
+ console.log(
1045
+ ` ${dim(`${currentVersion ?? '(no version file)'} → ${version} tag ${tag} dist-tag ${distTag}`)}`,
1046
+ )
1047
+ console.log(` ${dim(`steps: ${STEPS.filter(runs).join(' → ')}`)}`)
591
1048
 
592
1049
  // ─────────────────────────────────────────────────────────────────────────────
593
1050
  // PREFLIGHT — every check runs, then it aborts once with all of the failures
@@ -601,18 +1058,43 @@ const fail = (message) => {
601
1058
  problems.push(message)
602
1059
  }
603
1060
 
604
- if (bumping && compareVersions(version, pkg.version) <= 0) {
605
- fail(`${version} is not greater than the current version ${pkg.version}`)
1061
+ if (bumping && compareVersions(version, currentVersion) <= 0) {
1062
+ fail(`${version} is not greater than the current version ${currentVersion}`)
606
1063
  } else if (bumping) {
607
- ok(`version ${pkg.version} → ${version}`)
1064
+ ok(`version ${currentVersion} → ${version}`)
1065
+ } else if (versionFile) {
1066
+ ok(`releasing the version already in ${versionFile.path} (${version})`)
608
1067
  } else {
609
- ok(`releasing the version already in package.json (${version})`)
1068
+ ok(`releasing ${version} (no version file; the tag is the version)`)
610
1069
  }
611
1070
 
612
1071
  const dirty = tryRead('git', ['status', '--porcelain'])
613
1072
  if (dirty === null) fail('could not read git status')
614
- else if (dirty) fail(`working tree is not clean:\n${indent(formatStatus(dirty))}`)
615
- else ok('working tree clean')
1073
+ else if (dirty && runs('commit')) {
1074
+ ok(`working tree has ${dirty.split('\n').length} change(s) — will be committed first`)
1075
+ console.log(dim(indent(formatStatus(dirty))))
1076
+ } else if (dirty) {
1077
+ fail(`working tree is not clean:\n${indent(formatStatus(dirty))}`)
1078
+ } else ok('working tree clean')
1079
+
1080
+ if (runs('commit') && !assistant) {
1081
+ fail(
1082
+ 'the commit step needs a drafting assistant to write the message. Configure one with ' +
1083
+ '`--assistant auto`, or set "assistant" in release.config.json.',
1084
+ )
1085
+ } else if (assistant) {
1086
+ const detail = [
1087
+ assistantModel && `model ${assistantModel}`,
1088
+ assistantEffort && `effort ${assistantEffort}`,
1089
+ ]
1090
+ .filter(Boolean)
1091
+ .join(', ')
1092
+ ok(`assistant: ${assistantName}${detail ? ` (${detail})` : ''}`)
1093
+ if (assistantModel && !assistant.model)
1094
+ warn(`${assistantName} takes no model flag — --model ignored`)
1095
+ if (assistantEffort && !assistant.effort)
1096
+ warn(`${assistantName} takes no effort flag — --effort ignored`)
1097
+ }
616
1098
 
617
1099
  const branch = tryRead('git', ['rev-parse', '--abbrev-ref', 'HEAD'])
618
1100
  if (!branch) fail('could not read the current branch')
@@ -642,7 +1124,9 @@ if (!succeeds('git', ['remote', 'get-url', config.remote])) {
642
1124
 
643
1125
  const head = tryRead('git', ['rev-parse', 'HEAD'])
644
1126
  const taggedCommit = tryRead('git', ['rev-list', '-n', '1', tag])
645
- if (taggedCommit && bumping) {
1127
+ if (!runs('tag')) {
1128
+ note('tag step not selected')
1129
+ } else if (taggedCommit && bumping) {
646
1130
  fail(`tag ${tag} already exists — release a different version`)
647
1131
  } else if (taggedCommit && taggedCommit !== head) {
648
1132
  fail(`tag ${tag} already exists at ${taggedCommit.slice(0, 8)}, not at HEAD`)
@@ -653,8 +1137,8 @@ if (taggedCommit && bumping) {
653
1137
  }
654
1138
 
655
1139
  let releaseExists = false
656
- if (skipRelease) {
657
- note('GitHub release skipped (--skip-release)')
1140
+ if (!runs('release')) {
1141
+ note('release step not selected')
658
1142
  } else if (!succeeds('gh', ['--version'])) {
659
1143
  fail('the GitHub CLI (`gh`) is not installed — https://cli.github.com')
660
1144
  } else if (!succeeds('gh', ['auth', 'status'])) {
@@ -667,34 +1151,58 @@ if (skipRelease) {
667
1151
 
668
1152
  let alreadyPublished = false
669
1153
  if (!publishCommand) {
670
- note(skipPublish ? 'publish skipped (--skip-publish)' : 'publish disabled in config')
671
- } else if (pkg.private) {
1154
+ note(runs('publish') ? 'no publish command configured' : 'publish step not selected')
1155
+ } else if (manifest?.private) {
672
1156
  fail('package.json is private but a publish command is configured')
673
- } else if (!registryCli) {
1157
+ } else if (!registry) {
674
1158
  ok(`publish: ${publishCommand}`)
675
1159
  } else {
676
1160
  if (isTrustedPublishing) {
677
1161
  ok('trusted publishing (OIDC) — no token needed')
678
- } else {
679
- const user = tryRead(registryCli, ['whoami'])
1162
+ } else if (registry.env) {
1163
+ // Token-in-the-environment auth: there is no session to interrogate, only credentials.
1164
+ const found = registry.env.find((name) => process.env[name])
1165
+ if (found) ok(`${publishCli} credentials found (${found})`)
1166
+ else fail(`${publishCli} has no publish credentials — ${registry.login}`)
1167
+ } else if (registry.whoami) {
1168
+ const user = tryRead(publishCli, registry.whoami)
680
1169
  if (user === null) {
681
1170
  // npm replaced long-lived tokens with two-hour sessions in December 2025, so the
682
1171
  // usual cause is an expired session rather than a missing login.
683
1172
  fail(
684
- `${registryCli} is not authenticated — run \`${registryCli} login\`. ` +
1173
+ `${publishCli} is not authenticated — run \`${publishCli} login\`. ` +
685
1174
  'npm logins are two-hour sessions, so an earlier one may have expired.',
686
1175
  )
687
- } else ok(`${registryCli} authenticated (${user || 'unknown user'})`)
1176
+ } else ok(`${publishCli} authenticated (${user || 'unknown user'})`)
688
1177
  }
689
- alreadyPublished = succeeds(registryCli, ['view', `${pkg.name}@${version}`, 'version'])
690
- if (alreadyPublished) {
691
- note(`${pkg.name}@${version} is already on the registry — will skip publishing`)
1178
+
1179
+ if (registry.published) {
1180
+ alreadyPublished = succeeds(publishCli, registry.published(projectName, version))
1181
+ if (alreadyPublished) {
1182
+ note(`${projectName}@${version} is already published — will skip the publish step`)
1183
+ }
692
1184
  }
693
1185
  }
694
1186
 
695
- // Notes: the changelog section for this version, else GitHub generates them from commits.
1187
+ // Notes: the changelog section for this version, else a draft, else GitHub generates them.
696
1188
  let notes = null
697
1189
  let rolledChangelog = null
1190
+ let draftedNotes = null
1191
+
1192
+ /**
1193
+ * True when --commit still has to create a commit. Notes drafted before that commit would
1194
+ * describe an incomplete release, so drafting waits until the working tree is committed.
1195
+ */
1196
+ const notesDeferred = !!(dirty && runs('commit') && assistant)
1197
+
1198
+ /** Draft notes from the commit log, reporting what it is doing since it takes a moment. */
1199
+ function draftNotesFor(v) {
1200
+ if (!assistant) return null
1201
+ const { lastTag, subjects } = commitsSinceLastTag()
1202
+ if (!subjects.length) return null
1203
+ note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
1204
+ return draftReleaseNotes(v, subjects, lastTag)
1205
+ }
698
1206
  if (config.changelog && existsSync(config.changelog)) {
699
1207
  const text = readFileSync(config.changelog, 'utf8')
700
1208
  notes = changelogSection(text, version)
@@ -706,13 +1214,26 @@ if (config.changelog && existsSync(config.changelog)) {
706
1214
  notes = changelogSection(rolledChangelog, version)
707
1215
  ok(`${config.changelog}: [Unreleased] will become [${version}]`)
708
1216
  } else {
709
- warn(
710
- `${config.changelog} has no ${version} or [Unreleased] section — GitHub will generate the notes`,
711
- )
1217
+ draftedNotes = notesDeferred ? null : draftNotesFor(version)
1218
+ if (notesDeferred) {
1219
+ ok(`${config.changelog}: a ${version} section will be drafted after the commit`)
1220
+ } else if (draftedNotes) {
1221
+ notes = draftedNotes
1222
+ ok(`${config.changelog}: a ${version} section will be drafted by ${assistantName}`)
1223
+ } else {
1224
+ warn(
1225
+ `${config.changelog} has no ${version} or [Unreleased] section — GitHub will generate the notes`,
1226
+ )
1227
+ }
712
1228
  }
713
1229
  }
714
- } else if (config.changelog) {
715
- note(`no ${config.changelog} — GitHub will generate the notes`)
1230
+ } else {
1231
+ // No changelog file at all: there is nothing to roll, but notes can still be drafted for
1232
+ // the tag annotation and the GitHub release.
1233
+ notes = notesDeferred ? null : draftNotesFor(version)
1234
+ if (notesDeferred) ok('release notes will be drafted after the commit')
1235
+ else if (notes) ok(`release notes drafted by ${assistantName}`)
1236
+ else if (config.changelog) note(`no ${config.changelog} — GitHub will generate the notes`)
716
1237
  }
717
1238
 
718
1239
  for (const asset of config.assets) {
@@ -737,7 +1258,7 @@ if (!assumeYes && !dryRun && process.stdin.isTTY) {
737
1258
  const rl = createInterface({ input: process.stdin, output: process.stdout })
738
1259
  let answer = ''
739
1260
  try {
740
- answer = await rl.question(`\nRelease ${bold(tag)} of ${pkg.name}? [y/N] `)
1261
+ answer = await rl.question(`\nRelease ${bold(tag)} of ${projectName}? [y/N] `)
741
1262
  } catch {
742
1263
  // Ctrl+C or Ctrl+D at the prompt rejects the question. That is a decline, not a
743
1264
  // crash — without this it exits on an unhandled AbortError and a stack trace.
@@ -753,13 +1274,36 @@ if (!assumeYes && !dryRun && process.stdin.isTTY) {
753
1274
 
754
1275
  const staged = []
755
1276
 
1277
+ if (dirty && runs('commit')) {
1278
+ step('Commit the working tree')
1279
+ mutate('git', ['add', '--all'])
1280
+ // Draft after staging: the message describes what is staged, not what happens to be
1281
+ // in the tree. Under --dry-run nothing was staged, so there is nothing to describe.
1282
+ const message = dryRun ? null : draftCommitMessage()
1283
+ if (!message && !dryRun) {
1284
+ abort(
1285
+ `${assistantName} could not draft a Conventional Commits message for the staged changes.\n\n` +
1286
+ ' Commit them yourself and re-run, or run without --commit.',
1287
+ )
1288
+ }
1289
+ console.log(indent(message ?? '<drafted at run time>'))
1290
+ mutate('git', ['commit', '-m', message ?? 'chore: working tree'])
1291
+
1292
+ // Now that the commit exists it is part of the release, so the notes can describe it.
1293
+ if (notesDeferred && !dryRun) {
1294
+ draftedNotes = draftNotesFor(version)
1295
+ if (draftedNotes) notes = draftedNotes
1296
+ }
1297
+ }
1298
+
756
1299
  if (bumping) {
757
1300
  step(`Write version ${version}`)
758
- for (const file of ['package.json', ...config.versionFiles]) {
759
- if (!existsSync(file)) abort(`versionFiles entry ${file} does not exist`)
760
- if (writeVersionInto(file, version)) {
761
- staged.push(file)
762
- console.log(` ${dryRun ? yellow('would write') : dim('wrote')} ${file}`)
1301
+ for (const entry of [versionFile, ...config.versionFiles]) {
1302
+ const source = versionSource(entry)
1303
+ if (!existsSync(source.path)) abort(`versionFiles entry ${source.path} does not exist`)
1304
+ if (writeVersionInto(source, version)) {
1305
+ staged.push(source.path)
1306
+ console.log(` ${dryRun ? yellow('would write') : dim('wrote')} ${source.path}`)
763
1307
  }
764
1308
  }
765
1309
  // A package-lock.json embeds the root version twice, so it goes stale on a bump.
@@ -769,11 +1313,26 @@ if (bumping) {
769
1313
  }
770
1314
  }
771
1315
 
772
- if (rolledChangelog) {
1316
+ if (rolledChangelog && runs('changelog')) {
773
1317
  step(`Roll ${config.changelog} to ${version}`)
774
1318
  if (dryRun) console.log(` ${yellow('would write')} ${config.changelog}`)
775
1319
  else writeFileSync(config.changelog, rolledChangelog)
776
1320
  staged.push(config.changelog)
1321
+ } else if (runs('changelog') && draftedNotes && config.changelog && existsSync(config.changelog)) {
1322
+ step(`Add the drafted ${version} section to ${config.changelog}`)
1323
+ if (dryRun) console.log(` ${yellow('would write')} ${config.changelog}`)
1324
+ else {
1325
+ writeFileSync(
1326
+ config.changelog,
1327
+ insertChangelogSection(
1328
+ readFileSync(config.changelog, 'utf8'),
1329
+ version,
1330
+ new Date().toISOString().slice(0, 10),
1331
+ draftedNotes,
1332
+ ),
1333
+ )
1334
+ }
1335
+ staged.push(config.changelog)
777
1336
  }
778
1337
 
779
1338
  if (staged.length) {
@@ -782,7 +1341,7 @@ if (staged.length) {
782
1341
  mutate('git', ['commit', '-m', expand(config.commitMessage)])
783
1342
  }
784
1343
 
785
- if (!taggedCommit) {
1344
+ if (runs('tag') && !taggedCommit) {
786
1345
  step(`Annotated tag ${tag}`)
787
1346
  // The notes become the tag annotation too, so a CI release workflow can read them
788
1347
  // straight off the tag instead of re-deriving them. --cleanup=verbatim is required:
@@ -794,21 +1353,23 @@ if (!taggedCommit) {
794
1353
  tag,
795
1354
  '--cleanup=verbatim',
796
1355
  '-m',
797
- `${notes ?? `${pkg.name} ${tag}`}\n`,
1356
+ `${notes ?? `${projectName} ${tag}`}\n`,
798
1357
  ])
799
1358
  }
800
1359
 
801
- step(`Push branch and tag to ${config.remote}`)
802
- // --follow-tags sends the commit and the tag in one call; pushing them separately is how
803
- // a tag ends up on the remote without its commit, or a release without its tag.
804
- mutate('git', ['push', '--follow-tags', config.remote, branch ?? 'HEAD'])
1360
+ if (runs('push')) {
1361
+ step(`Push branch and tag to ${config.remote}`)
1362
+ // --follow-tags sends the commit and the tag in one call; pushing them separately is how
1363
+ // a tag ends up on the remote without its commit, or a release without its tag.
1364
+ mutate('git', ['push', '--follow-tags', config.remote, branch ?? 'HEAD'])
1365
+ }
805
1366
 
806
1367
  if (publishCommand && !alreadyPublished) {
807
1368
  step(`Publish to the registry (dist-tag ${distTag})`)
808
1369
  mutateShell(publishCommand)
809
1370
  }
810
1371
 
811
- if (!skipRelease && !releaseExists) {
1372
+ if (runs('release') && !releaseExists) {
812
1373
  step(`GitHub release ${tag}`)
813
1374
  const args = [
814
1375
  'release',