@entro314labs/release-kit 2.2.2 → 2.3.1

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 +46 -24
  2. package/package.json +1 -1
  3. package/release.mjs +211 -17
package/README.md CHANGED
@@ -94,7 +94,7 @@ release-kit minor
94
94
  No global state to drift, no install step, and the version is explicit in the command.
95
95
 
96
96
  ```sh
97
- npx @entro314labs/release-kit@2.1.0 minor --yes
97
+ npx @entro314labs/release-kit@2.3.0 minor --yes
98
98
  ```
99
99
 
100
100
  ### Vendored — no registry at release time
@@ -116,7 +116,7 @@ npx @entro314labs/release-kit --sync . # writes scripts/release.mjs
116
116
  machine you do not want to install anything on.
117
117
 
118
118
  ```sh
119
- curl -fsSL https://raw.githubusercontent.com/entro314-labs/release-kit/v2.1.0/release.mjs \
119
+ curl -fsSL https://raw.githubusercontent.com/entro314-labs/release-kit/v2.3.0/release.mjs \
120
120
  | node - minor --yes
121
121
  ```
122
122
 
@@ -144,6 +144,15 @@ pnpm release -- --help
144
144
  The target is optional. With no target it releases whatever version `package.json`
145
145
  already says — which is the mode to use when a version bump landed in an earlier commit.
146
146
 
147
+ | `auto` | derived from the Conventional Commits since the last tag |
148
+
149
+ `auto` follows release-please's rules: a breaking change (`!` or a `BREAKING CHANGE:`
150
+ footer) is a major, a `feat:` is a minor, anything else is a patch. Below `1.0.0` that is
151
+ softened — a breaking change bumps the minor rather than jumping to `1.0.0`. A commit body
152
+ containing `Release-As: 2.0.0` pins the version outright. It always prints what it inferred
153
+ and why before doing anything. Set `"versioning"` to `always-patch`, `always-minor` or
154
+ `always-major` to never infer.
155
+
147
156
  | Target | From `1.2.3` | From `2.0.0-beta.1` |
148
157
  | ------------------------------------ | ------------------------------------------------ | ------------------- |
149
158
  | _(none)_ | `1.2.3` | `2.0.0-beta.1` |
@@ -163,16 +172,20 @@ Prerelease bumps need `--preid` unless the current version already carries one t
163
172
 
164
173
  ### Flags
165
174
 
166
- | Flag | Effect |
167
- | ------------------- | -------------------------------------------------------------------------- |
168
- | `--dry-run` | Print every step, execute nothing. Preflight still runs and still reports. |
169
- | `--yes`, `-y` | Skip the confirmation prompt. |
170
- | `--preid <id>` | Prerelease identifier: `alpha`, `beta`, `rc`, `next`, `nightly`, `canary`. |
171
- | `--dist-tag <name>` | Override the npm dist-tag. Always wins over the derived one. |
172
- | `--only <steps>` | Run only these steps, comma-separated. |
173
- | `--skip <steps>` | Run every step except these. |
174
- | `--sync <dir>...` | Copy this script into other projects and exit. Touches no git state. |
175
- | `--help`, `-h` | Full flag list. |
175
+ | Flag | Effect |
176
+ | ---------------------------- | -------------------------------------------------------------------------- |
177
+ | `--only <steps>` | Run only these steps, comma-separated. |
178
+ | `--skip <steps>` | Run every step except these. |
179
+ | `--commit` | Add the opt-in `commit` step: commit a dirty tree with a drafted message. |
180
+ | `--dry-run` | Print every step, execute nothing. Preflight still runs and still reports. |
181
+ | `--yes`, `-y` | Skip the confirmation prompt. |
182
+ | `--preid <id>` | Prerelease identifier: `alpha`, `beta`, `rc`, `next`, `nightly`, `canary`. |
183
+ | `--dist-tag <name>` | Override the npm dist-tag. Always wins over the derived one. |
184
+ | `--assistant <name>` | Drafting CLI: `auto`, `none`, `claude`, `codex`. |
185
+ | `--assistant-model <name>` | Model the assistant runs with. |
186
+ | `--assistant-effort <level>` | Reasoning effort the assistant runs with. |
187
+ | `--sync <dir>...` | Copy this script into other projects and exit. Touches no git state. |
188
+ | `--help`, `-h` | Full flag list. |
176
189
 
177
190
  ## 🧩 Steps
178
191
 
@@ -217,7 +230,11 @@ Notes resolve in this order:
217
230
  section ends at the next `##` heading or `---` rule.
218
231
  2. The `## [Unreleased]` section, if the version has no section of its own — this is the
219
232
  same content that step 2 above is about to promote.
220
- 3. Otherwise GitHub generates them from the commits since the previous tag.
233
+ 3. The commits grouped by Conventional Commit type — Features, Bug Fixes, Performance
234
+ Improvements, Reverts, with breaking changes first and chores, CI and docs hidden. This
235
+ is deterministic and needs nothing installed, so decent notes are the default rather
236
+ than something that requires an assistant.
237
+ 4. Otherwise GitHub generates them from the commits since the previous tag.
221
238
 
222
239
  The same text becomes the tag annotation, the GitHub release body, and (when rolled) the
223
240
  changelog entry. It is written once and lands in three places.
@@ -243,17 +260,18 @@ falling through to `latest` would put a prerelease on the stable line where ever
243
260
  Every check runs and every failure is reported before it aborts once with the whole list,
244
261
  rather than stopping at the first problem.
245
262
 
246
- - The target version is greater than the current one
247
- - Working tree is clean
248
- - On the configured branch
263
+ - The target version is greater than the current one — and for `auto`, which bump the
264
+ commits imply and why
265
+ - Working tree is clean, or listed for commit when the `commit` step runs
266
+ - On the configured branch, and not on a detached HEAD
249
267
  - The remote exists, is reachable, and the branch is not behind it
250
268
  - The tag is free — or already exists at `HEAD`, in which case it is reused
251
269
  - `gh` is installed and authenticated
252
- - Commit and tag signing can actually sign, when `commit.gpgsign` or `tag.gpgsign` is on
253
- - The publishing CLI is authenticated, and the version is not already on the registry
270
+ - Commit and tag signing can actually sign, and the key is one GitHub will accept
271
+ - The publishing CLI is authenticated, and the version is not already published
254
272
  - Configured release assets exist
255
- - A changelog section for the version exists _(a warning, not a failure — it falls back
256
- to generated notes)_
273
+ - A shallow clone is reported, since it truncates the history notes come from _(warning)_
274
+ - A changelog section for the version exists _(warning — it falls back to generated notes)_
257
275
 
258
276
  Under `--dry-run` the failures are reported and then the remaining steps are shown anyway,
259
277
  so you can see the whole plan without fixing the blockers first.
@@ -335,12 +353,16 @@ rather than being silently ignored.
335
353
 
336
354
  | Key | Default | Meaning |
337
355
  | --------------- | ------------------------ | ------------------------------------------------------------ |
356
+ | `steps` | all but `commit` | Which steps run; the order is fixed |
338
357
  | `tagPrefix` | `"v"` | Prepended to the version to form the tag |
339
358
  | `branch` | `"main"` | The only branch a release may run from; `null` allows any |
340
359
  | `remote` | `"origin"` | Git remote to push to |
341
- | `changelog` | `"CHANGELOG.md"` | Changelog path; `null` disables changelog handling |
342
- | `versionFiles` | `[]` | Extra JSON files whose top-level `"version"` is kept in sync |
343
- | `publish` | `"npm publish --tag %d"` | Publish command; `null` skips publishing |
360
+ | `changelog` | `"CHANGELOG.md"` | Changelog path; `null` for a project without one |
361
+ | `versionFile` | detected | Where the version lives; `null` versions by tag alone |
362
+ | `versionFiles` | `[]` | Further files kept in sync; a path or `{ path, pattern }` |
363
+ | `publish` | `"npm publish --tag %d"` | Publish command; `null` means none is configured |
364
+ | `versioning` | `"conventional"` | How `auto` infers; or `always-patch` / `-minor` / `-major` |
365
+ | `assistant` | `null` | Drafting CLI: a name, `"auto"`, or `{ tool, model, effort }` |
344
366
  | `commitMessage` | `"chore(release): %t"` | Release commit subject |
345
367
  | `releaseTitle` | `"%t"` | GitHub release title |
346
368
  | `assets` | `[]` | Files attached to the GitHub release |
@@ -360,7 +382,7 @@ Non-interactive by default: the confirmation prompt is skipped when stdin is not
360
382
  with:
361
383
  fetch-depth: 0 # release notes and the last-tag lookup need real history
362
384
  - id: release
363
- run: npx @entro314labs/release-kit@2.1.0 minor --yes
385
+ run: npx @entro314labs/release-kit@2.3.0 minor --yes
364
386
  env:
365
387
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
366
388
  - run: echo "shipped ${{ steps.release.outputs.tag }} ${{ steps.release.outputs.release-url }}"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entro314labs/release-kit",
3
- "version": "2.2.2",
3
+ "version": "2.3.1",
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
@@ -66,6 +66,8 @@ import { createInterface } from 'node:readline/promises'
66
66
  * commitMessage string release commit subject
67
67
  * releaseTitle string GitHub release title
68
68
  * assets string[] files attached to the GitHub release
69
+ * versioning string how `auto` derives a bump: "conventional", or
70
+ * always-patch / always-minor / always-major to never infer
69
71
  * assistant string|object drafting CLI for commit messages and notes. A key of
70
72
  * ASSISTANTS, "auto" for the first available, or null. The
71
73
  * object form { tool, model, effort } also pins which model and
@@ -105,6 +107,7 @@ const DEFAULTS = {
105
107
  releaseTitle: '%t',
106
108
  assets: [],
107
109
  assistant: null,
110
+ versioning: 'conventional',
108
111
  }
109
112
 
110
113
  /**
@@ -226,6 +229,22 @@ function read(command, args) {
226
229
  }).trim()
227
230
  }
228
231
 
232
+ /**
233
+ * Whether GitHub knows the key git signs with.
234
+ *
235
+ * Compares key material rather than fingerprints, so no extra process is spawned per key.
236
+ *
237
+ * @returns {boolean | null} null when it cannot be determined — gh missing, token without
238
+ * the scope to list signing keys, or no network. An unanswerable check is not a failure.
239
+ */
240
+ function signingKeyRegistered(keyPath) {
241
+ const local = readFileSync(keyPath, 'utf8').trim().split(/\s+/)[1]
242
+ if (!local) return null
243
+ const listed = tryRead('gh', ['api', 'user/ssh_signing_keys', '--jq', '.[].key'])
244
+ if (listed === null) return null
245
+ return listed.split('\n').some((key) => key.trim().split(/\s+/)[1] === local)
246
+ }
247
+
229
248
  /** Read-only command → trimmed stdout, or null when it exits non-zero. */
230
249
  function tryRead(command, args) {
231
250
  try {
@@ -360,16 +379,122 @@ function runAssistant(prompt) {
360
379
  function commitsSinceLastTag() {
361
380
  const lastTag = tryRead('git', ['describe', '--tags', '--abbrev=0'])
362
381
  const range = lastTag ? `${lastTag}..HEAD` : 'HEAD'
363
- const log = tryRead('git', ['log', '--format=%s', range]) ?? ''
364
- return {
365
- lastTag,
366
- subjects: log
367
- .split('\n')
368
- .filter(
369
- (s) =>
370
- s && !/^chore\(release\)/i.test(s) && !/^Merge (branch|pull request|remote)/i.test(s),
371
- ),
382
+ // %B is the whole message: bodies carry BREAKING CHANGE and Release-As footers. A record
383
+ // separator keeps multi-line messages parseable when splitting the log back apart.
384
+ const raw = tryRead('git', ['log', `--format=%B%x1e`, range]) ?? ''
385
+ const commits = raw
386
+ .split('\u001e')
387
+ .map((entry) => entry.trim())
388
+ .filter(Boolean)
389
+ .map((entry) => {
390
+ const [subject, ...rest] = entry.split('\n')
391
+ return { subject: subject.trim(), body: rest.join('\n').trim() }
392
+ })
393
+ .filter(
394
+ ({ subject }) =>
395
+ subject &&
396
+ !/^chore\(release\)/i.test(subject) &&
397
+ !/^Merge (branch|pull request|remote)/i.test(subject) &&
398
+ !/^wip\b/i.test(subject) &&
399
+ !/^(fixup|squash)!/.test(subject),
400
+ )
401
+ return { lastTag, commits, subjects: commits.map((c) => c.subject) }
402
+ }
403
+
404
+ /**
405
+ * Conventional Commit types and the changelog heading each lands under, following
406
+ * release-please's defaults. Types marked hidden are real changes but not release notes:
407
+ * a reader upgrading does not need to know the CI config moved.
408
+ */
409
+ const CHANGELOG_SECTIONS = [
410
+ { type: 'feat', section: 'Features' },
411
+ { type: 'fix', section: 'Bug Fixes' },
412
+ { type: 'perf', section: 'Performance Improvements' },
413
+ { type: 'revert', section: 'Reverts' },
414
+ { type: 'docs', section: 'Documentation', hidden: true },
415
+ { type: 'style', section: 'Styles', hidden: true },
416
+ { type: 'refactor', section: 'Code Refactoring', hidden: true },
417
+ { type: 'test', section: 'Tests', hidden: true },
418
+ { type: 'build', section: 'Build System', hidden: true },
419
+ { type: 'ci', section: 'Continuous Integration', hidden: true },
420
+ { type: 'chore', section: 'Miscellaneous Chores', hidden: true },
421
+ ]
422
+
423
+ /**
424
+ * Parse a commit into the parts a release cares about.
425
+ *
426
+ * @returns {{type: string, scope: string|null, breaking: boolean, subject: string,
427
+ * releaseAs: string|null} | null} null when the subject is not Conventional Commits
428
+ */
429
+ function parseCommit(subject, body = '') {
430
+ const match = /^([a-z]+)(?:\(([^)]+)\))?(!)?: (.+)$/i.exec(subject)
431
+ if (!match) return null
432
+ const [, type, scope, bang, text] = match
433
+ // A breaking change is marked either by `!` in the header or a BREAKING CHANGE footer.
434
+ const breaking = !!bang || /^BREAKING[ -]CHANGE:/m.test(body)
435
+ // `Release-As: 1.2.3` in a commit body pins the next version, so the decision can live
436
+ // in git history rather than on the command line.
437
+ const releaseAs = /^Release-As:\s*v?(\S+)/im.exec(body)?.[1] ?? null
438
+ return { type: type.toLowerCase(), scope: scope ?? null, breaking, subject: text, releaseAs }
439
+ }
440
+
441
+ /**
442
+ * The bump implied by a set of commits, following release-please's default strategy:
443
+ * a breaking change is major, a feature is minor, anything else is a patch.
444
+ *
445
+ * Below 1.0.0 that is usually wrong — a breaking change in a 0.x project conventionally
446
+ * bumps the minor, not to 1.0.0 — so `preMajor` softens both by one level.
447
+ *
448
+ * @returns {{bump: string, breaking: string[], features: string[], releaseAs: string|null}}
449
+ */
450
+ function inferBump(commits, currentVersion, strategy = 'conventional') {
451
+ const parsed = commits.map((c) => parseCommit(c.subject, c.body)).filter(Boolean)
452
+ const releaseAs = parsed.find((c) => c.releaseAs)?.releaseAs ?? null
453
+ const breaking = parsed.filter((c) => c.breaking).map((c) => c.subject)
454
+ const features = parsed
455
+ .filter((c) => !c.breaking && /^feat(ure)?$/.test(c.type))
456
+ .map((c) => c.subject)
457
+
458
+ if (strategy !== 'conventional') {
459
+ return { bump: strategy.replace(/^always-/, ''), breaking, features, releaseAs }
372
460
  }
461
+
462
+ const preMajor = currentVersion ? parseVersion(currentVersion)?.major === 0 : false
463
+ let bump = 'patch'
464
+ if (breaking.length) bump = preMajor ? 'minor' : 'major'
465
+ else if (features.length) bump = 'minor'
466
+ return { bump, breaking, features, releaseAs }
467
+ }
468
+
469
+ /**
470
+ * Group commits into changelog sections, hiding the types that are not release notes.
471
+ * Deterministic and offline: this is what a project gets without an assistant.
472
+ *
473
+ * @returns {string | null} markdown body, or null when nothing visible changed
474
+ */
475
+ function changelogFromCommits(commits) {
476
+ const parsed = commits.map((c) => parseCommit(c.subject, c.body)).filter(Boolean)
477
+ const lines = []
478
+
479
+ const breaking = parsed.filter((c) => c.breaking)
480
+ if (breaking.length) {
481
+ lines.push('### ⚠ BREAKING CHANGES', '')
482
+ for (const c of breaking) lines.push(`- ${c.scope ? `**${c.scope}:** ` : ''}${c.subject}`)
483
+ lines.push('')
484
+ }
485
+
486
+ for (const { type, section, hidden } of CHANGELOG_SECTIONS) {
487
+ if (hidden) continue
488
+ const inSection = parsed.filter(
489
+ (c) => c.type === type || (type === 'feat' && c.type === 'feature'),
490
+ )
491
+ if (!inSection.length) continue
492
+ lines.push(`### ${section}`, '')
493
+ for (const c of inSection) lines.push(`- ${c.scope ? `**${c.scope}:** ` : ''}${c.subject}`)
494
+ lines.push('')
495
+ }
496
+
497
+ return lines.length ? lines.join('\n').trim() : null
373
498
  }
374
499
 
375
500
  const CONVENTIONAL_TYPES = 'build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test'
@@ -742,7 +867,16 @@ function writeVersionInto(entry, version) {
742
867
  // ─────────────────────────────────────────────────────────────────────────────
743
868
 
744
869
  const argv = process.argv.slice(2)
745
- const BUMPS = new Set(['major', 'minor', 'patch', 'premajor', 'preminor', 'prepatch', 'prerelease'])
870
+ const BUMPS = new Set([
871
+ 'auto',
872
+ 'major',
873
+ 'minor',
874
+ 'patch',
875
+ 'premajor',
876
+ 'preminor',
877
+ 'prepatch',
878
+ 'prerelease',
879
+ ])
746
880
 
747
881
  const flag = (name) => argv.includes(name)
748
882
  const option = (name) => {
@@ -987,6 +1121,9 @@ console.log(
987
1121
  (dryRun ? ` ${yellow('(dry run — nothing will execute)')}` : ''),
988
1122
  )
989
1123
 
1124
+ /** What `auto` inferred, kept so preflight can show the reasoning. */
1125
+ let autoBump = null
1126
+
990
1127
  let version
991
1128
  if (!target) {
992
1129
  if (!currentVersion) {
@@ -996,6 +1133,29 @@ if (!target) {
996
1133
  )
997
1134
  }
998
1135
  version = currentVersion
1136
+ } else if (target === 'auto') {
1137
+ if (!currentVersion) {
1138
+ abort('auto needs a versionFile to bump from. Pass a version explicitly instead.')
1139
+ }
1140
+ const { commits, lastTag } = commitsSinceLastTag()
1141
+ if (!commits.length) {
1142
+ abort(
1143
+ `no releasable commits since ${lastTag ?? 'the start of the project'} — nothing to release`,
1144
+ )
1145
+ }
1146
+ autoBump = inferBump(commits, currentVersion, config.versioning)
1147
+ if (autoBump.releaseAs) {
1148
+ if (!parseVersion(autoBump.releaseAs)) {
1149
+ abort(`Release-As: ${autoBump.releaseAs} in a commit is not a semver version`)
1150
+ }
1151
+ version = autoBump.releaseAs
1152
+ } else {
1153
+ version = incrementVersion(
1154
+ currentVersion,
1155
+ autoBump.bump,
1156
+ requestedPreid ?? preidOf(currentVersion),
1157
+ )
1158
+ }
999
1159
  } else if (BUMPS.has(target)) {
1000
1160
  if (!currentVersion) {
1001
1161
  abort(`a ${target} bump needs a versionFile to bump from. Pass a version explicitly instead.`)
@@ -1098,6 +1258,20 @@ const fail = (message) => {
1098
1258
  problems.push(message)
1099
1259
  }
1100
1260
 
1261
+ if (autoBump) {
1262
+ const reason = autoBump.releaseAs
1263
+ ? `Release-As: ${autoBump.releaseAs} in a commit`
1264
+ : autoBump.breaking.length
1265
+ ? `${autoBump.breaking.length} breaking change(s)`
1266
+ : autoBump.features.length
1267
+ ? `${autoBump.features.length} feature(s), no breaking changes`
1268
+ : 'no features or breaking changes'
1269
+ ok(`auto: ${autoBump.bump} — ${reason}`)
1270
+ for (const subject of [...autoBump.breaking, ...autoBump.features].slice(0, 5)) {
1271
+ console.log(dim(` ${subject}`))
1272
+ }
1273
+ }
1274
+
1101
1275
  if (bumping && compareVersions(version, currentVersion) <= 0) {
1102
1276
  fail(`${version} is not greater than the current version ${currentVersion}`)
1103
1277
  } else if (bumping) {
@@ -1131,9 +1305,9 @@ if (runs('commit') && !assistant) {
1131
1305
  .join(', ')
1132
1306
  ok(`assistant: ${assistantName}${detail ? ` (${detail})` : ''}`)
1133
1307
  if (assistantModel && !assistant.model)
1134
- warn(`${assistantName} takes no model flag — --model ignored`)
1308
+ warn(`${assistantName} takes no model flag — --assistant-model ignored`)
1135
1309
  if (assistantEffort && !assistant.effort)
1136
- warn(`${assistantName} takes no effort flag — --effort ignored`)
1310
+ warn(`${assistantName} takes no effort flag — --assistant-effort ignored`)
1137
1311
  }
1138
1312
 
1139
1313
  const branch = tryRead('git', ['rev-parse', '--abbrev-ref', 'HEAD'])
@@ -1193,6 +1367,22 @@ if (signsSomething && signingKeys.length) {
1193
1367
  )
1194
1368
  } else {
1195
1369
  ok(`signing commits and tags (${format})`)
1370
+ // git signs happily with a key GitHub has never seen, which is how a repository fills
1371
+ // up with commits that are locally valid and permanently "Unverified". Only GitHub can
1372
+ // answer this, and only when the token carries the scope to list signing keys — so an
1373
+ // unanswerable check stays silent rather than guessing.
1374
+ if (format === 'ssh' && keyPath && existsSync(keyPath) && runs('release')) {
1375
+ const registered = signingKeyRegistered(keyPath)
1376
+ if (registered === false) {
1377
+ warn(
1378
+ 'this signing key is not registered with GitHub, so the commits will show as ' +
1379
+ 'Unverified.\n Add it: gh ssh-key add ' +
1380
+ `${signingKey} --type signing`,
1381
+ )
1382
+ } else if (registered === true) {
1383
+ ok('signing key is registered with GitHub')
1384
+ }
1385
+ }
1196
1386
  }
1197
1387
  }
1198
1388
 
@@ -1269,11 +1459,15 @@ let draftedNotes = null
1269
1459
  */
1270
1460
  const notesDeferred = !!(dirty && runs('commit') && assistant)
1271
1461
 
1272
- /** Draft notes from the commit log, reporting what it is doing since it takes a moment. */
1462
+ /**
1463
+ * Notes for a version, in descending order of how much they can be trusted:
1464
+ * an assistant's prose when one is configured, otherwise the commits grouped by
1465
+ * Conventional Commit type. Only when neither yields anything does GitHub generate them.
1466
+ */
1273
1467
  function draftNotesFor(v) {
1274
- if (!assistant) return null
1275
- const { lastTag, subjects } = commitsSinceLastTag()
1276
- if (!subjects.length) return null
1468
+ const { lastTag, subjects, commits } = commitsSinceLastTag()
1469
+ if (!commits.length) return null
1470
+ if (!assistant) return changelogFromCommits(commits)
1277
1471
  if (shallow) {
1278
1472
  warn(
1279
1473
  `shallow clone: only ${subjects.length} commit(s) are visible, so the notes will ` +
@@ -1281,7 +1475,7 @@ function draftNotesFor(v) {
1281
1475
  )
1282
1476
  }
1283
1477
  note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
1284
- return draftReleaseNotes(v, subjects, lastTag)
1478
+ return draftReleaseNotes(v, subjects, lastTag) ?? changelogFromCommits(commits)
1285
1479
  }
1286
1480
  if (config.changelog && existsSync(config.changelog)) {
1287
1481
  const text = readFileSync(config.changelog, 'utf8')