@entro314labs/release-kit 1.0.2 → 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 +474 -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.2",
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
@@ -480,6 +754,21 @@ const target = argv.find((a) => !a.startsWith('-') && a !== explicitDistTag && a
480
754
 
481
755
  const root = tryRead('git', ['rev-parse', '--show-toplevel'])
482
756
  if (!root) abort('not inside a git repository')
757
+
758
+ // A release is scoped to the repository: the version, the tag and the push all belong to
759
+ // one git history, so the package released is the one at the git root. Refuse when invoked
760
+ // from a nested package instead — silently releasing the parent is the worse outcome.
761
+ const localManifest = resolve('package.json')
762
+ const rootManifest = join(root, 'package.json')
763
+ if (existsSync(localManifest) && localManifest !== rootManifest) {
764
+ abort(
765
+ `${relative(root, localManifest)} is a nested package, but a release covers the whole ` +
766
+ `repository.\n\n Running here would release ${
767
+ existsSync(rootManifest) ? readJson(rootManifest).name : 'the repository root'
768
+ } instead.\n` +
769
+ ' release-kit handles one package per repository; it does not release workspace members.',
770
+ )
771
+ }
483
772
  process.chdir(root)
484
773
 
485
774
  if (!existsSync('package.json')) abort(`no package.json at ${root}`)
@@ -494,6 +783,68 @@ const config = {
494
783
  const unknownKeys = Object.keys(config).filter((key) => !(key in DEFAULTS))
495
784
  if (unknownKeys.length) abort(`release.config.json has unknown keys: ${unknownKeys.join(', ')}`)
496
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
+
497
848
  // ─────────────────────────────────────────────────────────────────────────────
498
849
  // RESOLVE THE TARGET VERSION
499
850
  // ─────────────────────────────────────────────────────────────────────────────
@@ -521,7 +872,7 @@ if (!target) {
521
872
 
522
873
  const tag = `${config.tagPrefix}${version}`
523
874
  const isPrerelease = parseVersion(version).pre.length > 0
524
- const bumping = version !== pkg.version
875
+ const bumping = version !== pkg.version && runs('version')
525
876
 
526
877
  let distTag
527
878
  try {
@@ -548,7 +899,7 @@ const expand = (template) => expandWith(template, (value) => value)
548
899
  const shellQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`
549
900
  const expandShell = (template) => expandWith(template, shellQuote)
550
901
 
551
- const publishCommand = config.publish && !skipPublish ? expandShell(config.publish) : null
902
+ const publishCommand = runs('publish') && config.publish ? expandShell(config.publish) : null
552
903
 
553
904
  /**
554
905
  * npm and pnpm answer `whoami` and `view` identically and share `~/.npmrc`, so whichever
@@ -573,6 +924,7 @@ const isTrustedPublishing =
573
924
  !!process.env.NPM_ID_TOKEN
574
925
 
575
926
  console.log(` ${dim(`${pkg.version} → ${version} tag ${tag} dist-tag ${distTag}`)}`)
927
+ console.log(` ${dim(`steps: ${STEPS.filter(runs).join(' → ')}`)}`)
576
928
 
577
929
  // ─────────────────────────────────────────────────────────────────────────────
578
930
  // PREFLIGHT — every check runs, then it aborts once with all of the failures
@@ -596,8 +948,31 @@ if (bumping && compareVersions(version, pkg.version) <= 0) {
596
948
 
597
949
  const dirty = tryRead('git', ['status', '--porcelain'])
598
950
  if (dirty === null) fail('could not read git status')
599
- else if (dirty) fail(`working tree is not clean:\n${indent(formatStatus(dirty))}`)
600
- 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
+ }
601
976
 
602
977
  const branch = tryRead('git', ['rev-parse', '--abbrev-ref', 'HEAD'])
603
978
  if (!branch) fail('could not read the current branch')
@@ -627,7 +1002,9 @@ if (!succeeds('git', ['remote', 'get-url', config.remote])) {
627
1002
 
628
1003
  const head = tryRead('git', ['rev-parse', 'HEAD'])
629
1004
  const taggedCommit = tryRead('git', ['rev-list', '-n', '1', tag])
630
- if (taggedCommit && bumping) {
1005
+ if (!runs('tag')) {
1006
+ note('tag step not selected')
1007
+ } else if (taggedCommit && bumping) {
631
1008
  fail(`tag ${tag} already exists — release a different version`)
632
1009
  } else if (taggedCommit && taggedCommit !== head) {
633
1010
  fail(`tag ${tag} already exists at ${taggedCommit.slice(0, 8)}, not at HEAD`)
@@ -638,8 +1015,8 @@ if (taggedCommit && bumping) {
638
1015
  }
639
1016
 
640
1017
  let releaseExists = false
641
- if (skipRelease) {
642
- note('GitHub release skipped (--skip-release)')
1018
+ if (!runs('release')) {
1019
+ note('release step not selected')
643
1020
  } else if (!succeeds('gh', ['--version'])) {
644
1021
  fail('the GitHub CLI (`gh`) is not installed — https://cli.github.com')
645
1022
  } else if (!succeeds('gh', ['auth', 'status'])) {
@@ -652,7 +1029,7 @@ if (skipRelease) {
652
1029
 
653
1030
  let alreadyPublished = false
654
1031
  if (!publishCommand) {
655
- note(skipPublish ? 'publish skipped (--skip-publish)' : 'publish disabled in config')
1032
+ note(runs('publish') ? 'no publish command configured' : 'publish step not selected')
656
1033
  } else if (pkg.private) {
657
1034
  fail('package.json is private but a publish command is configured')
658
1035
  } else if (!registryCli) {
@@ -677,9 +1054,25 @@ if (!publishCommand) {
677
1054
  }
678
1055
  }
679
1056
 
680
- // 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.
681
1058
  let notes = null
682
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
+ }
683
1076
  if (config.changelog && existsSync(config.changelog)) {
684
1077
  const text = readFileSync(config.changelog, 'utf8')
685
1078
  notes = changelogSection(text, version)
@@ -691,13 +1084,26 @@ if (config.changelog && existsSync(config.changelog)) {
691
1084
  notes = changelogSection(rolledChangelog, version)
692
1085
  ok(`${config.changelog}: [Unreleased] will become [${version}]`)
693
1086
  } else {
694
- warn(
695
- `${config.changelog} has no ${version} or [Unreleased] section — GitHub will generate the notes`,
696
- )
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
+ }
697
1098
  }
698
1099
  }
699
- } else if (config.changelog) {
700
- 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`)
701
1107
  }
702
1108
 
703
1109
  for (const asset of config.assets) {
@@ -738,6 +1144,28 @@ if (!assumeYes && !dryRun && process.stdin.isTTY) {
738
1144
 
739
1145
  const staged = []
740
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
+
741
1169
  if (bumping) {
742
1170
  step(`Write version ${version}`)
743
1171
  for (const file of ['package.json', ...config.versionFiles]) {
@@ -754,11 +1182,26 @@ if (bumping) {
754
1182
  }
755
1183
  }
756
1184
 
757
- if (rolledChangelog) {
1185
+ if (rolledChangelog && runs('changelog')) {
758
1186
  step(`Roll ${config.changelog} to ${version}`)
759
1187
  if (dryRun) console.log(` ${yellow('would write')} ${config.changelog}`)
760
1188
  else writeFileSync(config.changelog, rolledChangelog)
761
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)
762
1205
  }
763
1206
 
764
1207
  if (staged.length) {
@@ -767,7 +1210,7 @@ if (staged.length) {
767
1210
  mutate('git', ['commit', '-m', expand(config.commitMessage)])
768
1211
  }
769
1212
 
770
- if (!taggedCommit) {
1213
+ if (runs('tag') && !taggedCommit) {
771
1214
  step(`Annotated tag ${tag}`)
772
1215
  // The notes become the tag annotation too, so a CI release workflow can read them
773
1216
  // straight off the tag instead of re-deriving them. --cleanup=verbatim is required:
@@ -783,17 +1226,19 @@ if (!taggedCommit) {
783
1226
  ])
784
1227
  }
785
1228
 
786
- step(`Push branch and tag to ${config.remote}`)
787
- // --follow-tags sends the commit and the tag in one call; pushing them separately is how
788
- // a tag ends up on the remote without its commit, or a release without its tag.
789
- 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
+ }
790
1235
 
791
1236
  if (publishCommand && !alreadyPublished) {
792
1237
  step(`Publish to the registry (dist-tag ${distTag})`)
793
1238
  mutateShell(publishCommand)
794
1239
  }
795
1240
 
796
- if (!skipRelease && !releaseExists) {
1241
+ if (runs('release') && !releaseExists) {
797
1242
  step(`GitHub release ${tag}`)
798
1243
  const args = [
799
1244
  'release',