@entro314labs/release-kit 1.0.3 → 2.0.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 +97 -31
  2. package/package.json +1 -1
  3. package/release.mjs +459 -29
package/README.md CHANGED
@@ -54,7 +54,8 @@ 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 |
58
59
  | [✅ Preflight](#-preflight) | what is checked before anything mutates |
59
60
  | [♻️ Recovering from a failed run](#️-recovering-from-a-failed-run) | why re-running is safe |
60
61
  | [⚙️ Configuration](#️-configuration) | `release.config.json`, publishing, auth |
@@ -136,35 +137,50 @@ Prerelease bumps need `--preid` unless the current version already carries one t
136
137
 
137
138
  ### Flags
138
139
 
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`.
140
+ | Flag | Effect |
141
+ | ------------------- | -------------------------------------------------------------------------- |
142
+ | `--dry-run` | Print every step, execute nothing. Preflight still runs and still reports. |
143
+ | `--yes`, `-y` | Skip the confirmation prompt. |
144
+ | `--preid <id>` | Prerelease identifier: `alpha`, `beta`, `rc`, `next`, `nightly`, `canary`. |
145
+ | `--dist-tag <name>` | Override the npm dist-tag. Always wins over the derived one. |
146
+ | `--only <steps>` | Run only these steps, comma-separated. |
147
+ | `--skip <steps>` | Run every step except these. |
148
+ | `--sync <dir>...` | Copy this script into other projects and exit. Touches no git state. |
149
+ | `--help`, `-h` | Full flag list. |
150
+
151
+ ## 🧩 Steps
152
+
153
+ A release is seven named steps. They always run in this order — `steps` selects which of
154
+ them execute, it never reorders them.
155
+
156
+ | Step | Default | What it does |
157
+ | ----------- | ------- | ----------------------------------------------------------------------------------------------- |
158
+ | `commit` | off | Commit a dirty working tree with a drafted message ([assistant](#-assistant-optional) required) |
159
+ | `version` | on | Write the version into `package.json` and `versionFiles` |
160
+ | `changelog` | on | Roll `[Unreleased]` into the version, or add drafted notes |
161
+ | `tag` | on | Annotated git tag carrying the release notes |
162
+ | `push` | on | Push the branch and tag together (`--follow-tags`) |
163
+ | `publish` | on | Run the configured `publish` command |
164
+ | `release` | on | Create the GitHub release |
165
+
166
+ `version` and `changelog` write files; those writes are persisted by a release commit made
167
+ automatically when either step runs.
168
+
169
+ ```sh
170
+ release-kit minor --skip publish # everything but publish
171
+ release-kit --only tag,push,release # a version already committed elsewhere
172
+ release-kit minor --commit # add the opt-in commit step
173
+ ```
174
+
175
+ Or fix it per project, and just run `release-kit minor`:
176
+
177
+ ```json
178
+ { "steps": ["commit", "version", "changelog", "tag", "push", "release"] }
179
+ ```
180
+
181
+ `steps` decides **what** runs. Every other key describes **how** a step behaves — `publish`
182
+ is the command, `changelog` is the file. A step whose configuration is `null` runs as a
183
+ no-op and says so, rather than silently meaning "skip".
168
184
 
169
185
  ### Release notes
170
186
 
@@ -194,7 +210,7 @@ version, never guessed:
194
210
 
195
211
  The refusal is deliberate: an unrecognised prerelease identifier has no safe channel, and
196
212
  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.
213
+ `npm install` picks it up. Pass `--dist-tag <name>` to choose a channel explicitly.
198
214
 
199
215
  ## ✅ Preflight
200
216
 
@@ -313,6 +329,56 @@ A project releasing off a non-default branch with a different tag scheme:
313
329
  }
314
330
  ```
315
331
 
332
+ ## 🤖 Assistant (optional)
333
+
334
+ An assistant is an AI CLI already installed on your machine. When one is configured,
335
+ release-kit can write the Conventional Commits message for a dirty working tree and draft
336
+ release notes from the commit log. It is **off by default**, and every failure — not
337
+ installed, not authenticated, timed out, unusable answer — falls back to the behaviour you
338
+ already have. A release is never blocked because a text generator was unavailable.
339
+
340
+ ```sh
341
+ pnpm release minor --commit --assistant claude --assistant-model sonnet --assistant-effort low
342
+ ```
343
+
344
+ | Tool | Invocation | Model | Effort |
345
+ | -------- | ------------ | ------------------- | -------------------------------- |
346
+ | `claude` | `claude -p` | `--assistant-model` | `--assistant-effort` (low … max) |
347
+ | `codex` | `codex exec` | `-m` | `-c model_reasoning_effort=` |
348
+
349
+ Configure it once instead:
350
+
351
+ ```json
352
+ {
353
+ "assistant": { "tool": "claude", "model": "sonnet", "effort": "low" }
354
+ }
355
+ ```
356
+
357
+ `"assistant": "auto"` picks the first tool found on PATH; `"claude"` is shorthand for
358
+ `{ "tool": "claude" }`. Naming a tool that is not installed is an error rather than a silent
359
+ downgrade, so a configured pipeline fails loudly; `"auto"` degrades quietly by design.
360
+
361
+ ### What it does
362
+
363
+ - **`--commit`** stages the working tree, drafts a Conventional Commits message for the
364
+ staged diff, and commits — instead of refusing to release. The subject is validated
365
+ against the Conventional Commits grammar; an answer that does not parse is rejected rather
366
+ than committed. Attribution lines (`Co-Authored-By`, `Generated with`) are stripped, so
367
+ the tool never signs your commits.
368
+ - **Release notes** are drafted from the commits since the last tag when `CHANGELOG.md` has
369
+ no section for the version. They are written into the changelog, used as the tag
370
+ annotation, and posted as the GitHub release body — the same "written once, lands in three
371
+ places" path a hand-written section takes.
372
+
373
+ With `--commit`, notes are drafted _after_ that commit lands, so they describe the change it
374
+ just made. Merge, release, `WIP` and `fixup!`/`squash!` commits are excluded from the prompt.
375
+
376
+ ### Adding another tool
377
+
378
+ One row in `ASSISTANTS` in `release.mjs`: the command, the args that make it read a prompt on
379
+ stdin, and how it spells model and effort. Tools whose stdout carries session scaffolding
380
+ declare `outputFile` and the answer is read from there instead.
381
+
316
382
  ## 🔄 Keeping vendored copies in sync
317
383
 
318
384
  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.0.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",
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
 
@@ -55,8 +56,34 @@ import { createInterface } from 'node:readline/promises'
55
56
  * commitMessage string release commit subject
56
57
  * releaseTitle string GitHub release title
57
58
  * assets string[] files attached to the GitHub release
59
+ * assistant string|object drafting CLI for commit messages and notes. A key of
60
+ * ASSISTANTS, "auto" for the first available, or null. The
61
+ * object form { tool, model, effort } also pins which model and
62
+ * reasoning effort that CLI is invoked with.
58
63
  */
64
+ /**
65
+ * The release pipeline, in the only order that is safe to run it. `steps` selects which of
66
+ * these execute; it never reorders them — publishing before tagging, or pushing before
67
+ * committing, is a mistake the tool should not let you express.
68
+ *
69
+ * commit commit a dirty working tree (opt-in; touches work that predates the release)
70
+ * version write the version into package.json and versionFiles
71
+ * changelog roll [Unreleased] into the version, or add drafted notes
72
+ * tag annotated git tag carrying the release notes
73
+ * push push the branch and the tag together
74
+ * publish run the configured publish command
75
+ * release create the GitHub release
76
+ *
77
+ * `version` and `changelog` write files; those writes are persisted by a release commit
78
+ * made automatically when either step runs.
79
+ */
80
+ const STEPS = ['commit', 'version', 'changelog', 'tag', 'push', 'publish', 'release']
81
+
82
+ /** Everything but `commit`, which is opt-in because it commits work you did not stage. */
83
+ const DEFAULT_STEPS = STEPS.filter((name) => name !== 'commit')
84
+
59
85
  const DEFAULTS = {
86
+ steps: DEFAULT_STEPS,
60
87
  tagPrefix: 'v',
61
88
  branch: 'main',
62
89
  remote: 'origin',
@@ -66,6 +93,7 @@ const DEFAULTS = {
66
93
  commitMessage: 'chore(release): %t',
67
94
  releaseTitle: '%t',
68
95
  assets: [],
96
+ assistant: null,
69
97
  }
70
98
 
71
99
  /**
@@ -94,13 +122,23 @@ Target (optional; defaults to the version already in package.json):
94
122
  prepatch preminor premajor prerelease
95
123
  prerelease bump; needs --preid unless it can be inferred
96
124
 
125
+ Steps, in the fixed order they run. All but "commit" run by default:
126
+ ${STEPS.join(' ')}
127
+
97
128
  Flags:
129
+ --only <steps> run only these steps, comma-separated
130
+ --skip <steps> run every step except these
131
+ --commit add the opt-in commit step: commit a dirty working tree with a
132
+ drafted Conventional Commits message instead of refusing to release
98
133
  --preid <id> prerelease identifier (alpha, beta, rc, next, nightly, canary)
99
- --tag <dist-tag> override the npm dist-tag (default: derived from the version)
134
+ --dist-tag <name> override the npm dist-tag (default: derived from the version)
100
135
  --dry-run print every step and execute nothing
101
136
  --yes, -y skip the confirmation prompt
102
- --skip-publish do not publish to the registry
103
- --skip-release do not create the GitHub release
137
+ --assistant <name> drafting CLI to use: auto, none, claude, codex
138
+ --assistant-model <name>
139
+ model the assistant runs with (e.g. sonnet, opus)
140
+ --assistant-effort <level>
141
+ reasoning effort the assistant runs with (low … max)
104
142
  --sync <dir>... copy this script into other projects' scripts/ and exit
105
143
  --help, -h show this
106
144
 
@@ -192,6 +230,203 @@ function tryRead(command, args) {
192
230
  */
193
231
  const succeeds = (command, args) => tryRead(command, args) !== null
194
232
 
233
+ // ─────────────────────────────────────────────────────────────────────────────
234
+ // ASSISTANTS — optional CLIs that draft prose (commit messages, release notes)
235
+ // ─────────────────────────────────────────────────────────────────────────────
236
+
237
+ /**
238
+ * Drafting tools, tried in this order when `assistant` is "auto". Each is a CLI that
239
+ * reads a prompt on stdin and writes plain text to stdout. Supporting another harness is
240
+ * one row here; nothing else in the file names a specific tool.
241
+ */
242
+ const ASSISTANTS = {
243
+ claude: {
244
+ command: 'claude',
245
+ args: ['-p'],
246
+ probe: ['--version'],
247
+ model: (m) => ['--model', m],
248
+ effort: (e) => ['--effort', e],
249
+ },
250
+ codex: {
251
+ command: 'codex',
252
+ args: ['exec', '--skip-git-repo-check', '--sandbox', 'read-only'],
253
+ probe: ['--version'],
254
+ model: (m) => ['-m', m],
255
+ effort: (e) => ['-c', `model_reasoning_effort="${e}"`],
256
+ // `codex exec` streams session scaffolding (MCP notices, hook logs) to stdout, so the
257
+ // answer is only clean when written to a file with --output-last-message.
258
+ outputFile: (path) => ['--output-last-message', path],
259
+ },
260
+ }
261
+
262
+ /** How long a draft may take before the release gives up and falls back. */
263
+ const DRAFT_TIMEOUT_MS = 180_000
264
+
265
+ /**
266
+ * Lines a drafting tool may append that must never reach a commit message or release
267
+ * notes: attribution for the tool itself, and the markdown fences models wrap output in.
268
+ */
269
+ function cleanDraft(text) {
270
+ return text
271
+ .replace(/^\s*```[a-z]*\s*\n?/i, '')
272
+ .replace(/\n?```\s*$/, '')
273
+ .split('\n')
274
+ .filter(
275
+ (line) =>
276
+ !/^\s*co-authored-by:/i.test(line) &&
277
+ !/^\s*(🤖\s*)?generated with/i.test(line) &&
278
+ !/^\s*signed-off-by:\s*claude/i.test(line),
279
+ )
280
+ .join('\n')
281
+ .trim()
282
+ }
283
+
284
+ /**
285
+ * Release notes carry two artefacts a commit message does not: stray code fences around
286
+ * part of the output, and a trailing paragraph explaining the model's reasoning. Keep the
287
+ * document only up to its last heading or list item; prose after that is commentary.
288
+ */
289
+ function cleanNotes(text) {
290
+ const lines = cleanDraft(text)
291
+ .split('\n')
292
+ .filter((line) => !/^\s*```/.test(line))
293
+ let end = lines.length
294
+ while (end > 0) {
295
+ const line = lines[end - 1]
296
+ if (!line.trim()) {
297
+ end -= 1
298
+ continue
299
+ }
300
+ // A heading, a bullet, or an indented continuation of a bullet: the document proper.
301
+ if (/^\s*[-*+] /.test(line) || /^#{1,6} /.test(line) || /^\s+\S/.test(line)) break
302
+ end -= 1
303
+ }
304
+ return lines.slice(0, end).join('\n').trim() || null
305
+ }
306
+
307
+ /**
308
+ * Run the selected assistant against a prompt.
309
+ *
310
+ * Every failure mode — not installed, not authenticated, timed out, empty answer — returns
311
+ * null rather than throwing. Drafting is a convenience; a release must never be blocked
312
+ * because a text generator was unavailable.
313
+ *
314
+ * @returns {string | null} the cleaned draft, or null when unavailable
315
+ */
316
+ function runAssistant(prompt) {
317
+ if (!assistant) return null
318
+
319
+ const args = [...assistant.args]
320
+ if (assistantModel && assistant.model) args.push(...assistant.model(assistantModel))
321
+ if (assistantEffort && assistant.effort) args.push(...assistant.effort(assistantEffort))
322
+
323
+ // Tools whose stdout carries session scaffolding hand back the answer through a file.
324
+ const answerFile = assistant.outputFile
325
+ ? join(mkdtempSync(join(tmpdir(), 'release-kit-')), 'answer.md')
326
+ : null
327
+ if (answerFile) args.push(...assistant.outputFile(answerFile))
328
+
329
+ try {
330
+ const stdout = execFileSync(assistant.command, args, {
331
+ input: prompt,
332
+ encoding: 'utf8',
333
+ timeout: DRAFT_TIMEOUT_MS,
334
+ stdio: ['pipe', 'pipe', 'ignore'],
335
+ maxBuffer: 10 * 1024 * 1024,
336
+ })
337
+ const answer = answerFile
338
+ ? existsSync(answerFile)
339
+ ? readFileSync(answerFile, 'utf8')
340
+ : ''
341
+ : stdout
342
+ return cleanDraft(answer) || null
343
+ } catch {
344
+ return null
345
+ }
346
+ }
347
+
348
+ /** Commit subjects since the last tag, with release and merge commits filtered out. */
349
+ function commitsSinceLastTag() {
350
+ const lastTag = tryRead('git', ['describe', '--tags', '--abbrev=0'])
351
+ const range = lastTag ? `${lastTag}..HEAD` : 'HEAD'
352
+ const log = tryRead('git', ['log', '--format=%s', range]) ?? ''
353
+ return {
354
+ lastTag,
355
+ subjects: log
356
+ .split('\n')
357
+ .filter(
358
+ (s) =>
359
+ s && !/^chore\(release\)/i.test(s) && !/^Merge (branch|pull request|remote)/i.test(s),
360
+ ),
361
+ }
362
+ }
363
+
364
+ const CONVENTIONAL_TYPES = 'build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test'
365
+ const CONVENTIONAL_RE = new RegExp(`^(${CONVENTIONAL_TYPES})(\\([^)]+\\))?!?: .+`)
366
+
367
+ /**
368
+ * Draft a Conventional Commits message for the staged changes.
369
+ *
370
+ * @returns {string | null} a message whose subject is valid Conventional Commits, or null
371
+ */
372
+ function draftCommitMessage() {
373
+ const stat = tryRead('git', ['diff', '--cached', '--stat'])
374
+ // Cap the diff: a large one wastes the context window and rarely improves the subject.
375
+ const diff = tryRead('git', ['diff', '--cached', '--unified=1'])?.slice(0, 12_000)
376
+ if (!stat) return null
377
+
378
+ const prompt = [
379
+ 'Write a Conventional Commits message for these staged changes.',
380
+ '',
381
+ 'Rules:',
382
+ `- Subject: "<type>(<optional scope>): <description>" where type is one of ${CONVENTIONAL_TYPES.split('|').join(', ')}.`,
383
+ '- Subject in the imperative mood, no trailing period, under 72 characters.',
384
+ '- Add a body only if the change needs explanation; separate it with a blank line.',
385
+ '- Output the raw commit message and nothing else: no markdown fences, no preamble.',
386
+ '- Do NOT add Co-Authored-By, Signed-off-by, or any attribution or tool credit.',
387
+ '',
388
+ 'Files changed:',
389
+ stat,
390
+ '',
391
+ 'Diff:',
392
+ diff ?? '(unavailable)',
393
+ ].join('\n')
394
+
395
+ const message = runAssistant(prompt)
396
+ if (!message) return null
397
+ const [subject] = message.split('\n')
398
+ return CONVENTIONAL_RE.test(subject) ? message : null
399
+ }
400
+
401
+ /**
402
+ * Draft release notes from the commits since the last tag, in the changelog's own style.
403
+ *
404
+ * @returns {string | null} markdown body (no version heading), or null
405
+ */
406
+ function draftReleaseNotes(version, subjects, lastTag) {
407
+ if (!subjects.length) return null
408
+
409
+ const prompt = [
410
+ `Write release notes for version ${version}.`,
411
+ '',
412
+ 'Rules:',
413
+ '- Group the changes under Keep a Changelog headings (`### Added`, `### Changed`,',
414
+ ' `### Fixed`, `### Removed`), including only the headings that apply.',
415
+ '- One bullet per user-visible change. Merge related commits into a single bullet.',
416
+ '- Omit internal chores: CI, linting, formatting, dependency bumps, version bumps.',
417
+ '- Write for someone upgrading: say what changed for them, not which files moved.',
418
+ '- Plain, factual language. No hype, no emoji, no concluding summary.',
419
+ '- Output only the markdown body: no version heading, no code fences, no attribution.',
420
+ '- Do NOT explain your reasoning or add any commentary before or after the notes.',
421
+ '',
422
+ `Commit subjects since ${lastTag ?? 'the start of the project'}:`,
423
+ ...subjects.map((s) => `- ${s}`),
424
+ ].join('\n')
425
+
426
+ const drafted = runAssistant(prompt)
427
+ return drafted ? cleanNotes(drafted) : null
428
+ }
429
+
195
430
  /** One-line rendering of an argv, so a multi-line arg (release notes) stays readable. */
196
431
  const formatCommand = (command, args) =>
197
432
  [
@@ -392,6 +627,17 @@ function rollUnreleased(text, version, date) {
392
627
  return text.slice(0, match.index) + released + text.slice(match.index + match[0].length)
393
628
  }
394
629
 
630
+ /**
631
+ * Insert a section for a version above the newest existing one, so drafted notes are kept
632
+ * in the changelog rather than only reaching the tag and the GitHub release.
633
+ */
634
+ function insertChangelogSection(text, version, date, body) {
635
+ const entry = `## [${version}] - ${date}\n\n${body}\n`
636
+ const firstSection = /^## /m.exec(text)
637
+ if (!firstSection) return `${text.trimEnd()}\n\n${entry}`
638
+ return `${text.slice(0, firstSection.index)}${entry}\n${text.slice(firstSection.index)}`
639
+ }
640
+
395
641
  // ─────────────────────────────────────────────────────────────────────────────
396
642
  // JSON FILES
397
643
  // ─────────────────────────────────────────────────────────────────────────────
@@ -429,10 +675,14 @@ const option = (name) => {
429
675
 
430
676
  const dryRun = flag('--dry-run')
431
677
  const assumeYes = flag('--yes') || flag('-y')
432
- const skipPublish = flag('--skip-publish')
433
- const skipRelease = flag('--skip-release')
434
- const explicitDistTag = option('--tag')
678
+ const onlySteps = option('--only')
679
+ const skippedSteps = option('--skip')
680
+ const explicitDistTag = option('--dist-tag')
435
681
  const requestedPreid = option('--preid')
682
+ const autoCommit = flag('--commit')
683
+ const requestedAssistant = option('--assistant')
684
+ const requestedModel = option('--assistant-model')
685
+ const requestedEffort = option('--assistant-effort')
436
686
 
437
687
  if (flag('--help') || flag('-h')) {
438
688
  console.log(USAGE)
@@ -472,7 +722,31 @@ if (flag('--sync')) {
472
722
  process.exit(0)
473
723
  }
474
724
 
475
- const target = argv.find((a) => !a.startsWith('-') && a !== explicitDistTag && a !== requestedPreid)
725
+ /**
726
+ * Options that consume the argument after them. Without this list a positional target is
727
+ * found by guessing, and `--only tag,push` gets read as the version to release.
728
+ */
729
+ const VALUE_OPTIONS = new Set([
730
+ '--only',
731
+ '--skip',
732
+ '--preid',
733
+ '--dist-tag',
734
+ '--assistant',
735
+ '--assistant-model',
736
+ '--assistant-effort',
737
+ ])
738
+
739
+ /** The version or bump target: the first argument that is neither a flag nor a flag's value. */
740
+ const target = (() => {
741
+ for (let i = 0; i < argv.length; i += 1) {
742
+ if (VALUE_OPTIONS.has(argv[i])) {
743
+ i += 1
744
+ continue
745
+ }
746
+ if (!argv[i].startsWith('-')) return argv[i]
747
+ }
748
+ return undefined
749
+ })()
476
750
 
477
751
  // ─────────────────────────────────────────────────────────────────────────────
478
752
  // SETUP
@@ -509,6 +783,68 @@ const config = {
509
783
  const unknownKeys = Object.keys(config).filter((key) => !(key in DEFAULTS))
510
784
  if (unknownKeys.length) abort(`release.config.json has unknown keys: ${unknownKeys.join(', ')}`)
511
785
 
786
+ /**
787
+ * Which steps run: config provides the baseline, --only replaces it, --skip subtracts, and
788
+ * --commit adds the opt-in step. Unknown names are an error rather than a silent no-op.
789
+ */
790
+ const parseStepList = (value) =>
791
+ value
792
+ .split(',')
793
+ .map((name) => name.trim())
794
+ .filter(Boolean)
795
+
796
+ // Validate every name that was asked for, not just the ones that survive: a typo in
797
+ // --skip would otherwise delete nothing and silently run the step you meant to drop.
798
+ const requestedStepNames = [
799
+ ...(onlySteps ? parseStepList(onlySteps) : []),
800
+ ...(skippedSteps ? parseStepList(skippedSteps) : []),
801
+ ...(Array.isArray(config.steps) ? config.steps : []),
802
+ ]
803
+ const unknownSteps = [...new Set(requestedStepNames)].filter((name) => !STEPS.includes(name))
804
+ if (unknownSteps.length) {
805
+ abort(`unknown step(s): ${unknownSteps.join(', ')}\n Known steps: ${STEPS.join(', ')}`)
806
+ }
807
+
808
+ const steps = new Set(onlySteps ? parseStepList(onlySteps) : (config.steps ?? DEFAULT_STEPS))
809
+ if (skippedSteps) for (const name of parseStepList(skippedSteps)) steps.delete(name)
810
+ if (autoCommit) steps.add('commit')
811
+ const runs = (name) => steps.has(name)
812
+
813
+ /**
814
+ * The drafting tool, resolved from --assistant then config. "auto" picks the first one
815
+ * present on PATH; a named tool must be known and installed, otherwise it is an error
816
+ * rather than a silent downgrade to no drafting.
817
+ */
818
+ const assistantConfig =
819
+ config.assistant && typeof config.assistant === 'object' ? config.assistant : {}
820
+ const assistantChoice =
821
+ requestedAssistant ??
822
+ (typeof config.assistant === 'string' ? config.assistant : assistantConfig.tool) ??
823
+ 'none'
824
+ const assistantModel = requestedModel ?? assistantConfig.model ?? null
825
+ const assistantEffort = requestedEffort ?? assistantConfig.effort ?? null
826
+ let assistantName = null
827
+ if (assistantChoice !== 'none' && assistantChoice !== null) {
828
+ const candidates =
829
+ assistantChoice === 'auto'
830
+ ? Object.keys(ASSISTANTS)
831
+ : [assistantChoice].filter((name) => {
832
+ if (name in ASSISTANTS) return true
833
+ abort(
834
+ `unknown assistant "${name}" (known: ${Object.keys(ASSISTANTS).join(', ')}, auto, none)`,
835
+ )
836
+ return false
837
+ })
838
+ assistantName =
839
+ candidates.find((name) => succeeds(ASSISTANTS[name].command, ASSISTANTS[name].probe)) ?? null
840
+ if (!assistantName && assistantChoice !== 'auto') {
841
+ abort(
842
+ `assistant "${assistantChoice}" is configured but \`${ASSISTANTS[assistantChoice].command}\` is not on PATH`,
843
+ )
844
+ }
845
+ }
846
+ const assistant = assistantName ? ASSISTANTS[assistantName] : null
847
+
512
848
  // ─────────────────────────────────────────────────────────────────────────────
513
849
  // RESOLVE THE TARGET VERSION
514
850
  // ─────────────────────────────────────────────────────────────────────────────
@@ -536,7 +872,7 @@ if (!target) {
536
872
 
537
873
  const tag = `${config.tagPrefix}${version}`
538
874
  const isPrerelease = parseVersion(version).pre.length > 0
539
- const bumping = version !== pkg.version
875
+ const bumping = version !== pkg.version && runs('version')
540
876
 
541
877
  let distTag
542
878
  try {
@@ -563,7 +899,7 @@ const expand = (template) => expandWith(template, (value) => value)
563
899
  const shellQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`
564
900
  const expandShell = (template) => expandWith(template, shellQuote)
565
901
 
566
- const publishCommand = config.publish && !skipPublish ? expandShell(config.publish) : null
902
+ const publishCommand = runs('publish') && config.publish ? expandShell(config.publish) : null
567
903
 
568
904
  /**
569
905
  * npm and pnpm answer `whoami` and `view` identically and share `~/.npmrc`, so whichever
@@ -588,6 +924,7 @@ const isTrustedPublishing =
588
924
  !!process.env.NPM_ID_TOKEN
589
925
 
590
926
  console.log(` ${dim(`${pkg.version} → ${version} tag ${tag} dist-tag ${distTag}`)}`)
927
+ console.log(` ${dim(`steps: ${STEPS.filter(runs).join(' → ')}`)}`)
591
928
 
592
929
  // ─────────────────────────────────────────────────────────────────────────────
593
930
  // PREFLIGHT — every check runs, then it aborts once with all of the failures
@@ -611,8 +948,31 @@ if (bumping && compareVersions(version, pkg.version) <= 0) {
611
948
 
612
949
  const dirty = tryRead('git', ['status', '--porcelain'])
613
950
  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')
951
+ else if (dirty && runs('commit')) {
952
+ ok(`working tree has ${dirty.split('\n').length} change(s) — will be committed first`)
953
+ console.log(dim(indent(formatStatus(dirty))))
954
+ } else if (dirty) {
955
+ fail(`working tree is not clean:\n${indent(formatStatus(dirty))}`)
956
+ } else ok('working tree clean')
957
+
958
+ if (runs('commit') && !assistant) {
959
+ fail(
960
+ 'the commit step needs a drafting assistant to write the message. Configure one with ' +
961
+ '`--assistant auto`, or set "assistant" in release.config.json.',
962
+ )
963
+ } else if (assistant) {
964
+ const detail = [
965
+ assistantModel && `model ${assistantModel}`,
966
+ assistantEffort && `effort ${assistantEffort}`,
967
+ ]
968
+ .filter(Boolean)
969
+ .join(', ')
970
+ ok(`assistant: ${assistantName}${detail ? ` (${detail})` : ''}`)
971
+ if (assistantModel && !assistant.model)
972
+ warn(`${assistantName} takes no model flag — --model ignored`)
973
+ if (assistantEffort && !assistant.effort)
974
+ warn(`${assistantName} takes no effort flag — --effort ignored`)
975
+ }
616
976
 
617
977
  const branch = tryRead('git', ['rev-parse', '--abbrev-ref', 'HEAD'])
618
978
  if (!branch) fail('could not read the current branch')
@@ -642,7 +1002,9 @@ if (!succeeds('git', ['remote', 'get-url', config.remote])) {
642
1002
 
643
1003
  const head = tryRead('git', ['rev-parse', 'HEAD'])
644
1004
  const taggedCommit = tryRead('git', ['rev-list', '-n', '1', tag])
645
- if (taggedCommit && bumping) {
1005
+ if (!runs('tag')) {
1006
+ note('tag step not selected')
1007
+ } else if (taggedCommit && bumping) {
646
1008
  fail(`tag ${tag} already exists — release a different version`)
647
1009
  } else if (taggedCommit && taggedCommit !== head) {
648
1010
  fail(`tag ${tag} already exists at ${taggedCommit.slice(0, 8)}, not at HEAD`)
@@ -653,8 +1015,8 @@ if (taggedCommit && bumping) {
653
1015
  }
654
1016
 
655
1017
  let releaseExists = false
656
- if (skipRelease) {
657
- note('GitHub release skipped (--skip-release)')
1018
+ if (!runs('release')) {
1019
+ note('release step not selected')
658
1020
  } else if (!succeeds('gh', ['--version'])) {
659
1021
  fail('the GitHub CLI (`gh`) is not installed — https://cli.github.com')
660
1022
  } else if (!succeeds('gh', ['auth', 'status'])) {
@@ -667,7 +1029,7 @@ if (skipRelease) {
667
1029
 
668
1030
  let alreadyPublished = false
669
1031
  if (!publishCommand) {
670
- note(skipPublish ? 'publish skipped (--skip-publish)' : 'publish disabled in config')
1032
+ note(runs('publish') ? 'no publish command configured' : 'publish step not selected')
671
1033
  } else if (pkg.private) {
672
1034
  fail('package.json is private but a publish command is configured')
673
1035
  } else if (!registryCli) {
@@ -692,9 +1054,25 @@ if (!publishCommand) {
692
1054
  }
693
1055
  }
694
1056
 
695
- // Notes: the changelog section for this version, else GitHub generates them from commits.
1057
+ // Notes: the changelog section for this version, else a draft, else GitHub generates them.
696
1058
  let notes = null
697
1059
  let rolledChangelog = null
1060
+ let draftedNotes = null
1061
+
1062
+ /**
1063
+ * True when --commit still has to create a commit. Notes drafted before that commit would
1064
+ * describe an incomplete release, so drafting waits until the working tree is committed.
1065
+ */
1066
+ const notesDeferred = !!(dirty && runs('commit') && assistant)
1067
+
1068
+ /** Draft notes from the commit log, reporting what it is doing since it takes a moment. */
1069
+ function draftNotesFor(v) {
1070
+ if (!assistant) return null
1071
+ const { lastTag, subjects } = commitsSinceLastTag()
1072
+ if (!subjects.length) return null
1073
+ note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
1074
+ return draftReleaseNotes(v, subjects, lastTag)
1075
+ }
698
1076
  if (config.changelog && existsSync(config.changelog)) {
699
1077
  const text = readFileSync(config.changelog, 'utf8')
700
1078
  notes = changelogSection(text, version)
@@ -706,13 +1084,26 @@ if (config.changelog && existsSync(config.changelog)) {
706
1084
  notes = changelogSection(rolledChangelog, version)
707
1085
  ok(`${config.changelog}: [Unreleased] will become [${version}]`)
708
1086
  } else {
709
- warn(
710
- `${config.changelog} has no ${version} or [Unreleased] section — GitHub will generate the notes`,
711
- )
1087
+ draftedNotes = notesDeferred ? null : draftNotesFor(version)
1088
+ if (notesDeferred) {
1089
+ ok(`${config.changelog}: a ${version} section will be drafted after the commit`)
1090
+ } else if (draftedNotes) {
1091
+ notes = draftedNotes
1092
+ ok(`${config.changelog}: a ${version} section will be drafted by ${assistantName}`)
1093
+ } else {
1094
+ warn(
1095
+ `${config.changelog} has no ${version} or [Unreleased] section — GitHub will generate the notes`,
1096
+ )
1097
+ }
712
1098
  }
713
1099
  }
714
- } else if (config.changelog) {
715
- note(`no ${config.changelog} — GitHub will generate the notes`)
1100
+ } else {
1101
+ // No changelog file at all: there is nothing to roll, but notes can still be drafted for
1102
+ // the tag annotation and the GitHub release.
1103
+ notes = notesDeferred ? null : draftNotesFor(version)
1104
+ if (notesDeferred) ok('release notes will be drafted after the commit')
1105
+ else if (notes) ok(`release notes drafted by ${assistantName}`)
1106
+ else if (config.changelog) note(`no ${config.changelog} — GitHub will generate the notes`)
716
1107
  }
717
1108
 
718
1109
  for (const asset of config.assets) {
@@ -753,6 +1144,28 @@ if (!assumeYes && !dryRun && process.stdin.isTTY) {
753
1144
 
754
1145
  const staged = []
755
1146
 
1147
+ if (dirty && runs('commit')) {
1148
+ step('Commit the working tree')
1149
+ mutate('git', ['add', '--all'])
1150
+ // Draft after staging: the message describes what is staged, not what happens to be
1151
+ // in the tree. Under --dry-run nothing was staged, so there is nothing to describe.
1152
+ const message = dryRun ? null : draftCommitMessage()
1153
+ if (!message && !dryRun) {
1154
+ abort(
1155
+ `${assistantName} could not draft a Conventional Commits message for the staged changes.\n\n` +
1156
+ ' Commit them yourself and re-run, or run without --commit.',
1157
+ )
1158
+ }
1159
+ console.log(indent(message ?? '<drafted at run time>'))
1160
+ mutate('git', ['commit', '-m', message ?? 'chore: working tree'])
1161
+
1162
+ // Now that the commit exists it is part of the release, so the notes can describe it.
1163
+ if (notesDeferred && !dryRun) {
1164
+ draftedNotes = draftNotesFor(version)
1165
+ if (draftedNotes) notes = draftedNotes
1166
+ }
1167
+ }
1168
+
756
1169
  if (bumping) {
757
1170
  step(`Write version ${version}`)
758
1171
  for (const file of ['package.json', ...config.versionFiles]) {
@@ -769,11 +1182,26 @@ if (bumping) {
769
1182
  }
770
1183
  }
771
1184
 
772
- if (rolledChangelog) {
1185
+ if (rolledChangelog && runs('changelog')) {
773
1186
  step(`Roll ${config.changelog} to ${version}`)
774
1187
  if (dryRun) console.log(` ${yellow('would write')} ${config.changelog}`)
775
1188
  else writeFileSync(config.changelog, rolledChangelog)
776
1189
  staged.push(config.changelog)
1190
+ } else if (runs('changelog') && draftedNotes && config.changelog && existsSync(config.changelog)) {
1191
+ step(`Add the drafted ${version} section to ${config.changelog}`)
1192
+ if (dryRun) console.log(` ${yellow('would write')} ${config.changelog}`)
1193
+ else {
1194
+ writeFileSync(
1195
+ config.changelog,
1196
+ insertChangelogSection(
1197
+ readFileSync(config.changelog, 'utf8'),
1198
+ version,
1199
+ new Date().toISOString().slice(0, 10),
1200
+ draftedNotes,
1201
+ ),
1202
+ )
1203
+ }
1204
+ staged.push(config.changelog)
777
1205
  }
778
1206
 
779
1207
  if (staged.length) {
@@ -782,7 +1210,7 @@ if (staged.length) {
782
1210
  mutate('git', ['commit', '-m', expand(config.commitMessage)])
783
1211
  }
784
1212
 
785
- if (!taggedCommit) {
1213
+ if (runs('tag') && !taggedCommit) {
786
1214
  step(`Annotated tag ${tag}`)
787
1215
  // The notes become the tag annotation too, so a CI release workflow can read them
788
1216
  // straight off the tag instead of re-deriving them. --cleanup=verbatim is required:
@@ -798,17 +1226,19 @@ if (!taggedCommit) {
798
1226
  ])
799
1227
  }
800
1228
 
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'])
1229
+ if (runs('push')) {
1230
+ step(`Push branch and tag to ${config.remote}`)
1231
+ // --follow-tags sends the commit and the tag in one call; pushing them separately is how
1232
+ // a tag ends up on the remote without its commit, or a release without its tag.
1233
+ mutate('git', ['push', '--follow-tags', config.remote, branch ?? 'HEAD'])
1234
+ }
805
1235
 
806
1236
  if (publishCommand && !alreadyPublished) {
807
1237
  step(`Publish to the registry (dist-tag ${distTag})`)
808
1238
  mutateShell(publishCommand)
809
1239
  }
810
1240
 
811
- if (!skipRelease && !releaseExists) {
1241
+ if (runs('release') && !releaseExists) {
812
1242
  step(`GitHub release ${tag}`)
813
1243
  const args = [
814
1244
  'release',