@entro314labs/release-kit 2.5.0 → 2.7.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 (5) hide show
  1. package/README.md +55 -7
  2. package/TRAIN.md +332 -0
  3. package/package.json +5 -2
  4. package/release.mjs +255 -91
  5. package/train.mjs +1241 -0
package/release.mjs CHANGED
@@ -69,6 +69,12 @@ import { createInterface } from 'node:readline/promises'
69
69
  * assets string[] files attached to the GitHub release
70
70
  * notesFile string write the resolved release notes here, for a build tool that
71
71
  * takes them as a file (goreleaser --release-notes, and similar)
72
+ * notes string where release notes come from: "auto" tries the changelog,
73
+ * then an assistant, then the commits; or force one of
74
+ * changelog / assistant / commits / github
75
+ * hiddenTypes string[] commit types to leave out of the notes; empty means report all
76
+ * ignoreCommits string[] regexes for commits that are bookkeeping rather than change:
77
+ * release commits, merges, work-in-progress, autosquash markers
72
78
  * versioning string how `auto` derives a bump: "conventional", or
73
79
  * always-patch / always-minor / always-major to never infer
74
80
  * assistant string|object drafting CLI for commit messages and notes. A key of
@@ -94,8 +100,12 @@ import { createInterface } from 'node:readline/promises'
94
100
  */
95
101
  const STEPS = ['commit', 'version', 'changelog', 'tag', 'push', 'publish', 'release']
96
102
 
97
- /** Everything but `commit`, which is opt-in because it commits work you did not stage. */
98
- const DEFAULT_STEPS = STEPS.filter((name) => name !== 'commit')
103
+ /**
104
+ * All seven. `commit` is a conditional default: it no-ops on a clean tree, and on a dirty
105
+ * tree it proceeds only when a drafting assistant is configured — otherwise preflight
106
+ * still refuses the unclean tree. Opt out with `--skip commit` or a `steps` config.
107
+ */
108
+ const DEFAULT_STEPS = [...STEPS]
99
109
 
100
110
  const DEFAULTS = {
101
111
  steps: DEFAULT_STEPS,
@@ -112,6 +122,14 @@ const DEFAULTS = {
112
122
  assistant: null,
113
123
  notesFile: null,
114
124
  versioning: 'conventional',
125
+ notes: 'auto',
126
+ hiddenTypes: [],
127
+ ignoreCommits: [
128
+ '^chore\\(release\\)',
129
+ '^Merge (branch|pull request|remote)',
130
+ '^wip\\b',
131
+ '^(fixup|squash)!',
132
+ ],
115
133
  }
116
134
 
117
135
  /**
@@ -146,13 +164,15 @@ Steps, in the fixed order they run. All but "commit" run by default:
146
164
  Flags:
147
165
  --only <steps> run only these steps, comma-separated
148
166
  --skip <steps> run every step except these
149
- --commit add the opt-in commit step: commit a dirty working tree with a
167
+ --commit force the commit step on when a steps config removed it:
168
+ commit a dirty working tree with a
150
169
  drafted Conventional Commits message instead of refusing to release
151
170
  --preid <id> prerelease identifier (alpha, beta, rc, next, nightly, canary)
152
171
  --dist-tag <name> override the npm dist-tag (default: derived from the version)
153
172
  --dry-run print every step and execute nothing
154
173
  --yes, -y skip the confirmation prompt
155
174
  --notes-file <path> write the resolved release notes to a file for the next tool
175
+ --notes <source> where notes come from: auto, changelog, assistant, commits, github
156
176
  --assistant <name> drafting CLI to use: auto, none, claude, codex
157
177
  --assistant-model <name>
158
178
  model the assistant runs with (e.g. sonnet, opus)
@@ -382,6 +402,7 @@ function runAssistant(prompt) {
382
402
 
383
403
  /** Commit subjects since the last tag, with release and merge commits filtered out. */
384
404
  function commitsSinceLastTag() {
405
+ const ignored = (config.ignoreCommits ?? []).map((pattern) => new RegExp(pattern, 'i'))
385
406
  const lastTag = tryRead('git', ['describe', '--tags', '--abbrev=0'])
386
407
  const range = lastTag ? `${lastTag}..HEAD` : 'HEAD'
387
408
  // %B is the whole message: bodies carry BREAKING CHANGE and Release-As footers. A record
@@ -397,22 +418,20 @@ function commitsSinceLastTag() {
397
418
  const [subject, ...rest] = message.split('\n')
398
419
  return { hash: hash.trim(), subject: subject.trim(), body: rest.join('\n').trim() }
399
420
  })
400
- .filter(
401
- ({ subject }) =>
402
- subject &&
403
- !/^chore\(release\)/i.test(subject) &&
404
- !/^Merge (branch|pull request|remote)/i.test(subject) &&
405
- !/^wip\b/i.test(subject) &&
406
- !/^(fixup|squash)!/.test(subject),
407
- )
421
+ // Bookkeeping rather than change: the previous release's own commit, merges that
422
+ // duplicate the branch they bring in, and markers meant to be autosquashed away.
423
+ .filter(({ subject }) => subject && !ignored.some((re) => re.test(subject)))
408
424
  const kept = withoutRevertedCommits(commits)
409
425
  return { lastTag, commits: kept, subjects: kept.map((c) => c.subject) }
410
426
  }
411
427
 
412
428
  /**
413
- * Conventional Commit types and the changelog heading each lands under, following
414
- * release-please's defaults. Types marked hidden are real changes but not release notes:
415
- * a reader upgrading does not need to know the CI config moved.
429
+ * Conventional Commit types and the changelog heading each lands under.
430
+ *
431
+ * Every type is reported. release-please and goreleaser hide `chore`, `ci`, `docs` and the
432
+ * rest by default, on the view that a reader upgrading does not care — but a changelog is a
433
+ * record, and silently omitting work makes it a partial one. A project that wants the
434
+ * shorter version lists the types to drop in `hiddenTypes`.
416
435
  */
417
436
  const CHANGELOG_SECTIONS = [
418
437
  { type: 'feat', section: 'Features' },
@@ -420,13 +439,13 @@ const CHANGELOG_SECTIONS = [
420
439
  { type: 'perf', section: 'Performance Improvements' },
421
440
  { type: 'revert', section: 'Reverts' },
422
441
  { type: 'deps', section: 'Dependencies' },
423
- { type: 'docs', section: 'Documentation', hidden: true },
424
- { type: 'style', section: 'Styles', hidden: true },
425
- { type: 'refactor', section: 'Code Refactoring', hidden: true },
426
- { type: 'test', section: 'Tests', hidden: true },
427
- { type: 'build', section: 'Build System', hidden: true },
428
- { type: 'ci', section: 'Continuous Integration', hidden: true },
429
- { type: 'chore', section: 'Miscellaneous Chores', hidden: true },
442
+ { type: 'docs', section: 'Documentation' },
443
+ { type: 'refactor', section: 'Code Refactoring' },
444
+ { type: 'build', section: 'Build System' },
445
+ { type: 'ci', section: 'Continuous Integration' },
446
+ { type: 'test', section: 'Tests' },
447
+ { type: 'style', section: 'Styles' },
448
+ { type: 'chore', section: 'Miscellaneous Chores' },
430
449
  ]
431
450
 
432
451
  /**
@@ -498,7 +517,7 @@ function inferBump(commits, currentVersion, strategy = 'conventional') {
498
517
  *
499
518
  * @returns {string | null} markdown body, or null when nothing visible changed
500
519
  */
501
- function changelogFromCommits(commits, links = null) {
520
+ function changelogFromCommits(commits, links = null, hidden = []) {
502
521
  const parsed = commits.map((c) => parseCommit(c.subject, c.body, c.hash)).filter(Boolean)
503
522
  const lines = []
504
523
 
@@ -523,8 +542,9 @@ function changelogFromCommits(commits, links = null) {
523
542
  }
524
543
 
525
544
  const known = new Set(CHANGELOG_SECTIONS.map((s) => s.type))
526
- for (const { type, section, hidden } of CHANGELOG_SECTIONS) {
527
- if (hidden) continue
545
+ const hiddenTypes = new Set(hidden)
546
+ for (const { type, section } of CHANGELOG_SECTIONS) {
547
+ if (hiddenTypes.has(type)) continue
528
548
  const inSection = parsed.filter(
529
549
  (c) => c.type === type || (type === 'feat' && c.type === 'feature'),
530
550
  )
@@ -537,7 +557,9 @@ function changelogFromCommits(commits, links = null) {
537
557
  // A conventional type nobody anticipated — `security:`, `i18n:` — is still a change
538
558
  // someone made deliberately. Dropping it silently is how a security fix goes unmentioned.
539
559
  // The hidden types are excluded because hiding them is the point.
540
- const other = parsed.filter((c) => !known.has(c.type) && c.type !== 'feature')
560
+ const other = parsed.filter(
561
+ (c) => !known.has(c.type) && c.type !== 'feature' && !hiddenTypes.has(c.type),
562
+ )
541
563
  if (other.length) {
542
564
  lines.push('### Other Changes', '')
543
565
  for (const c of other) lines.push(bullet(c, c.subject))
@@ -644,8 +666,36 @@ function draftCommitMessage() {
644
666
  *
645
667
  * @returns {string | null} markdown body (no version heading), or null
646
668
  */
647
- function draftReleaseNotes(version, subjects, lastTag) {
648
- if (!subjects.length) return null
669
+ /**
670
+ * Attach commit links to a drafted bullet's citation.
671
+ *
672
+ * The model is asked to end each bullet with the short hashes it covers. Models invent
673
+ * plausible-looking hashes, so every citation is checked against the commits that actually
674
+ * exist: real ones become links, invented ones are removed rather than published.
675
+ *
676
+ * @returns {string} the notes with citations resolved
677
+ */
678
+ function linkCitedCommits(notes, commits, links) {
679
+ const known = new Set(commits.map((c) => (c.hash ?? '').slice(0, 7)).filter(Boolean))
680
+ return notes.replace(/\s*\(([0-9a-f]{7,40}(?:\s*,\s*[0-9a-f]{7,40})*)\)\s*$/gim, (_, cited) => {
681
+ const real = [
682
+ ...new Set(cited.split(/\s*,\s*/).map((h) => h.toLowerCase().slice(0, 7))),
683
+ ].filter((h) => known.has(h))
684
+ if (!real.length) return ''
685
+ const rendered = links
686
+ ? real.map((h) => `[${h}](${links.commit}/${h})`)
687
+ : real.map((h) => `\`${h}\``)
688
+ return ` (${rendered.join(', ')})`
689
+ })
690
+ }
691
+
692
+ /**
693
+ * Draft release notes from the commit log.
694
+ *
695
+ * @returns {string | null} markdown body (no version heading), or null
696
+ */
697
+ function draftReleaseNotes(version, commits, lastTag, links) {
698
+ if (!commits.length) return null
649
699
 
650
700
  const prompt = [
651
701
  `Write release notes for version ${version}.`,
@@ -654,18 +704,23 @@ function draftReleaseNotes(version, subjects, lastTag) {
654
704
  '- Group the changes under Keep a Changelog headings (`### Added`, `### Changed`,',
655
705
  ' `### Fixed`, `### Removed`), including only the headings that apply.',
656
706
  '- One bullet per user-visible change. Merge related commits into a single bullet.',
707
+ '- End every bullet with the short hashes it covers, in parentheses: `(abc1234)` or',
708
+ ' `(abc1234, def5678)` when merged. Copy them exactly from the list below and invent',
709
+ ' nothing — a hash that is not in the list will be removed.',
657
710
  '- Omit internal chores: CI, linting, formatting, dependency bumps, version bumps.',
658
711
  '- Write for someone upgrading: say what changed for them, not which files moved.',
659
712
  '- Plain, factual language. No hype, no emoji, no concluding summary.',
660
713
  '- Output only the markdown body: no version heading, no code fences, no attribution.',
661
714
  '- Do NOT explain your reasoning or add any commentary before or after the notes.',
662
715
  '',
663
- `Commit subjects since ${lastTag ?? 'the start of the project'}:`,
664
- ...subjects.map((s) => `- ${s}`),
716
+ `Commits since ${lastTag ?? 'the start of the project'}:`,
717
+ ...commits.map((c) => `- ${(c.hash ?? '').slice(0, 7)} ${c.subject}`),
665
718
  ].join('\n')
666
719
 
667
720
  const drafted = runAssistant(prompt)
668
- return drafted ? cleanNotes(drafted) : null
721
+ if (!drafted) return null
722
+ const cleaned = cleanNotes(drafted)
723
+ return cleaned ? linkCitedCommits(cleaned, commits, links) : null
669
724
  }
670
725
 
671
726
  /** One-line rendering of an argv, so a multi-line arg (release notes) stays readable. */
@@ -855,8 +910,50 @@ function changelogSection(text, version) {
855
910
  }
856
911
 
857
912
  /**
858
- * Rewrite a `## [Unreleased]` heading as the released version, and open a fresh
859
- * `## [Unreleased]` above it for the next cycle.
913
+ * Version sections that sit above a newer one.
914
+ *
915
+ * Placement only keeps a changelog tidy going forward; a file already out of order stays
916
+ * that way, and its disorder is invisible until a release lands somewhere surprising.
917
+ *
918
+ * @returns {string[]} the versions found out of order, newest-first order being expected
919
+ */
920
+ function changelogOutOfOrder(text) {
921
+ const versions = [...text.matchAll(/^## \[?v?(\d+\.\d+\.\d+(?:-[\w.]+)?)\]?/gm)].map((m) => m[1])
922
+ return versions.filter((v, i) => i > 0 && compareVersions(versions[i - 1], v) < 0)
923
+ }
924
+
925
+ /** The `## ` heading offsets in a changelog, in file order. */
926
+ const sectionOffsets = (text) => [...text.matchAll(/^## .*$/gm)].map((m) => m.index)
927
+
928
+ /**
929
+ * Place a version's section where it belongs: above the first section whose version is
930
+ * lower, rather than wherever the file happens to start.
931
+ *
932
+ * Blindly inserting at the top is correct only while the file is already newest-first. A
933
+ * changelog that drifts out of order once then stays that way, and every release makes it
934
+ * worse — which is how a released version ends up sitting between two older ones.
935
+ */
936
+ function insertChangelogSection(text, version, date, body) {
937
+ const entry = `## [${version}] - ${date}\n\n${body}\n`
938
+ for (const offset of sectionOffsets(text)) {
939
+ const heading = /^## \[?v?([\d.]+(?:-[\w.]+)?)\]?/m.exec(
940
+ text.slice(offset, text.indexOf('\n', offset)),
941
+ )
942
+ // An [Unreleased] heading has no version and always stays above the releases.
943
+ if (!heading) continue
944
+ if (compareVersions(version, heading[1]) > 0) {
945
+ return `${text.slice(0, offset)}${entry}\n${text.slice(offset)}`
946
+ }
947
+ }
948
+ const trimmed = text.trimEnd()
949
+ return `${trimmed}\n\n${entry}`
950
+ }
951
+
952
+ /**
953
+ * Promote `## [Unreleased]` to a released version and reopen an empty one above it.
954
+ *
955
+ * The section is lifted out and re-placed in version order, so a misplaced `[Unreleased]`
956
+ * does not drag the new release into the middle of the file with it.
860
957
  *
861
958
  * @returns {string | null} the updated document, or null when there is nothing to roll
862
959
  */
@@ -870,23 +967,18 @@ function rollUnreleased(text, version, date) {
870
967
  if (!match) return null
871
968
 
872
969
  // An empty [Unreleased] has nothing to promote; rolling it produces an empty section.
873
- const rest = text.slice(match.index + match[0].length)
874
- const next = /^## /m.exec(rest)
875
- const body = (next ? rest.slice(0, next.index) : rest).trim()
970
+ const after = text.slice(match.index + match[0].length)
971
+ const next = /^## /m.exec(after)
972
+ const body = (next ? after.slice(0, next.index) : after).trim()
876
973
  if (!body) return null
877
- const released = `## [Unreleased]\n\n## [${version}] - ${date}`
878
- return text.slice(0, match.index) + released + text.slice(match.index + match[0].length)
879
- }
880
974
 
881
- /**
882
- * Insert a section for a version above the newest existing one, so drafted notes are kept
883
- * in the changelog rather than only reaching the tag and the GitHub release.
884
- */
885
- function insertChangelogSection(text, version, date, body) {
886
- const entry = `## [${version}] - ${date}\n\n${body}\n`
887
- const firstSection = /^## /m.exec(text)
888
- if (!firstSection) return `${text.trimEnd()}\n\n${entry}`
889
- return `${text.slice(0, firstSection.index)}${entry}\n${text.slice(firstSection.index)}`
975
+ // Remove the section wherever it sits, then place the release by version.
976
+ const withoutUnreleased = text.slice(0, match.index) + (next ? after.slice(next.index) : '')
977
+ const placed = insertChangelogSection(`${withoutUnreleased.trimEnd()}\n`, version, date, body)
978
+
979
+ // A fresh [Unreleased] belongs above every release, whatever the file looked like before.
980
+ const first = sectionOffsets(placed)[0] ?? placed.length
981
+ return `${placed.slice(0, first)}## [Unreleased]\n\n${placed.slice(first)}`
890
982
  }
891
983
 
892
984
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1028,6 +1120,7 @@ const explicitDistTag = option('--dist-tag')
1028
1120
  const requestedPreid = option('--preid')
1029
1121
  const autoCommit = flag('--commit')
1030
1122
  const requestedNotesFile = option('--notes-file')
1123
+ const requestedNotesSource = option('--notes')
1031
1124
  const requestedAssistant = option('--assistant')
1032
1125
  const requestedModel = option('--assistant-model')
1033
1126
  const requestedEffort = option('--assistant-effort')
@@ -1096,23 +1189,33 @@ const VALUE_OPTIONS = new Set([
1096
1189
  '--skip',
1097
1190
  '--preid',
1098
1191
  '--dist-tag',
1192
+ '--notes',
1099
1193
  '--notes-file',
1100
1194
  '--assistant',
1101
1195
  '--assistant-model',
1102
1196
  '--assistant-effort',
1103
1197
  ])
1104
1198
 
1105
- /** The version or bump target: the first argument that is neither a flag nor a flag's value. */
1106
- const target = (() => {
1107
- for (let i = 0; i < argv.length; i += 1) {
1108
- if (VALUE_OPTIONS.has(argv[i])) {
1109
- i += 1
1110
- continue
1111
- }
1112
- if (!argv[i].startsWith('-')) return argv[i]
1199
+ /** The version or bump target: the only argument that is neither a flag nor a flag's value. */
1200
+ const positionals = []
1201
+ for (let i = 0; i < argv.length; i += 1) {
1202
+ if (VALUE_OPTIONS.has(argv[i])) {
1203
+ i += 1
1204
+ continue
1113
1205
  }
1114
- return undefined
1115
- })()
1206
+ if (!argv[i].startsWith('-')) positionals.push(argv[i])
1207
+ }
1208
+ const target = positionals[0]
1209
+ // A second positional is always a mistake, and silently ignoring it changes the release
1210
+ // that runs — `release-kit auto assistant auto` must not quietly mean `release-kit auto`.
1211
+ if (positionals.length > 1) {
1212
+ const extras = positionals.slice(1)
1213
+ const flagLike = extras.find((arg) => VALUE_OPTIONS.has(`--${arg}`))
1214
+ const hint = flagLike
1215
+ ? `\n Flags are spelled with dashes: --${flagLike} ${extras[extras.indexOf(flagLike) + 1] ?? '<value>'}`
1216
+ : ''
1217
+ abort(`unexpected argument${extras.length > 1 ? 's' : ''}: ${extras.join(' ')}${hint}`)
1218
+ }
1116
1219
 
1117
1220
  // ─────────────────────────────────────────────────────────────────────────────
1118
1221
  // SETUP
@@ -1144,7 +1247,8 @@ if (unknownKeys.length) abort(`release.config.json has unknown keys: ${unknownKe
1144
1247
 
1145
1248
  /**
1146
1249
  * Which steps run: config provides the baseline, --only replaces it, --skip subtracts, and
1147
- * --commit adds the opt-in step. Unknown names are an error rather than a silent no-op.
1250
+ * --commit forces the step on when a steps config removed it. Unknown names are an
1251
+ * error rather than a silent no-op.
1148
1252
  */
1149
1253
  const parseStepList = (value) =>
1150
1254
  value
@@ -1469,7 +1573,7 @@ if (bumping && compareVersions(version, currentVersion) <= 0) {
1469
1573
 
1470
1574
  const dirty = tryRead('git', ['status', '--porcelain'])
1471
1575
  if (dirty === null) fail('could not read git status')
1472
- else if (dirty && runs('commit')) {
1576
+ else if (dirty && runs('commit') && assistant) {
1473
1577
  const entries = dirty.split('\n')
1474
1578
  ok(`working tree has ${entries.length} change(s) — will be committed first`)
1475
1579
  console.log(dim(indent(formatStatus(dirty))))
@@ -1488,16 +1592,17 @@ else if (dirty && runs('commit')) {
1488
1592
  'releasing with --skip commit.',
1489
1593
  )
1490
1594
  }
1595
+ } else if (dirty && runs('commit')) {
1596
+ fail(
1597
+ `working tree is not clean:\n${indent(formatStatus(dirty))}\n` +
1598
+ ' Configure a drafting assistant (`--assistant auto`, or "assistant" in ' +
1599
+ 'release.config.json) to have these committed automatically, or commit them yourself.',
1600
+ )
1491
1601
  } else if (dirty) {
1492
1602
  fail(`working tree is not clean:\n${indent(formatStatus(dirty))}`)
1493
1603
  } else ok('working tree clean')
1494
1604
 
1495
- if (runs('commit') && !assistant) {
1496
- fail(
1497
- 'the commit step needs a drafting assistant to write the message. Configure one with ' +
1498
- '`--assistant auto`, or set "assistant" in release.config.json.',
1499
- )
1500
- } else if (assistant) {
1605
+ if (assistant) {
1501
1606
  const detail = [
1502
1607
  assistantModel && `model ${assistantModel}`,
1503
1608
  assistantEffort && `effort ${assistantEffort}`,
@@ -1649,7 +1754,35 @@ if (!publishCommand) {
1649
1754
  }
1650
1755
  }
1651
1756
 
1652
- // Notes: the changelog section for this version, else a draft, else GitHub generates them.
1757
+ /** Say so when the changelog is not newest-first, since placement cannot repair it. */
1758
+ function reportChangelogOrder(text) {
1759
+ const misplaced = changelogOutOfOrder(text)
1760
+ if (misplaced.length) {
1761
+ warn(
1762
+ `${config.changelog} is not in newest-first order (${misplaced.slice(0, 3).join(', ')}` +
1763
+ `${misplaced.length > 3 ? ', …' : ''} sit above a newer version).\n` +
1764
+ ' New sections are placed correctly, but the existing order is left alone.',
1765
+ )
1766
+ }
1767
+ }
1768
+
1769
+ /**
1770
+ * Where the notes come from. "auto" walks the list — a hand-written changelog section beats
1771
+ * anything generated — while an explicit source forces one, because asking for a thing and
1772
+ * being given something else is worse than being told the thing is unavailable.
1773
+ */
1774
+ const NOTE_SOURCES = ['auto', 'changelog', 'assistant', 'commits', 'github']
1775
+ const notesSource = requestedNotesSource ?? config.notes ?? 'auto'
1776
+ if (!NOTE_SOURCES.includes(notesSource)) {
1777
+ abort(`unknown notes source "${notesSource}". Known: ${NOTE_SOURCES.join(', ')}`)
1778
+ }
1779
+ if (notesSource === 'assistant' && !assistant) {
1780
+ abort(
1781
+ 'notes are set to come from an assistant, but none is available.\n' +
1782
+ ' Add --assistant auto, or set "assistant" in release.config.json.',
1783
+ )
1784
+ }
1785
+
1653
1786
  let notes = null
1654
1787
  let rolledChangelog = null
1655
1788
  let draftedNotes = null
@@ -1668,7 +1801,20 @@ const notesDeferred = !!(dirty && runs('commit') && assistant)
1668
1801
  function draftNotesFor(v) {
1669
1802
  const { lastTag, subjects, commits } = commitsSinceLastTag()
1670
1803
  if (!commits.length) return null
1671
- if (!assistant) return changelogFromCommits(commits, remoteLinks(config.remote))
1804
+
1805
+ // Notes are built from Conventional Commits, so anything not written that way is simply
1806
+ // absent from them. A squash-merge takes its subject from the pull request title, which
1807
+ // is where this usually goes wrong — and silently, since the release still succeeds.
1808
+ const unconventional = commits.filter((c) => !parseCommit(c.subject, c.body, c.hash)).length
1809
+ if (unconventional) {
1810
+ warn(
1811
+ `${unconventional} of ${commits.length} commit(s) are not Conventional Commits, so they ` +
1812
+ 'will not appear in the notes.',
1813
+ )
1814
+ }
1815
+ // An explicitly named source wins over the assistant being merely available.
1816
+ if (!assistant || notesSource === 'commits')
1817
+ return changelogFromCommits(commits, remoteLinks(config.remote), config.hiddenTypes)
1672
1818
  if (shallow) {
1673
1819
  warn(
1674
1820
  `shallow clone: only ${subjects.length} commit(s) are visible, so the notes will ` +
@@ -1677,41 +1823,59 @@ function draftNotesFor(v) {
1677
1823
  }
1678
1824
  note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
1679
1825
  return (
1680
- draftReleaseNotes(v, subjects, lastTag) ??
1681
- changelogFromCommits(commits, remoteLinks(config.remote))
1826
+ draftReleaseNotes(v, commits, lastTag, remoteLinks(config.remote)) ??
1827
+ changelogFromCommits(commits, remoteLinks(config.remote), config.hiddenTypes)
1682
1828
  )
1683
1829
  }
1684
- if (config.changelog && existsSync(config.changelog)) {
1685
- const text = readFileSync(config.changelog, 'utf8')
1686
- notes = changelogSection(text, version)
1687
- if (notes) {
1688
- ok(`${config.changelog} has a ${version} section`)
1689
- } else {
1690
- rolledChangelog = rollUnreleased(text, version, new Date().toISOString().slice(0, 10))
1830
+ const changelogText =
1831
+ config.changelog && existsSync(config.changelog) ? readFileSync(config.changelog, 'utf8') : null
1832
+ if (changelogText) reportChangelogOrder(changelogText)
1833
+
1834
+ // `github` means write nothing and let GitHub generate from the commit log.
1835
+ if (notesSource === 'github') {
1836
+ note('notes will be generated by GitHub')
1837
+ } else if (notesSource === 'changelog' && !changelogText) {
1838
+ fail(`notes are set to come from ${config.changelog}, which does not exist`)
1839
+ } else {
1840
+ const wantsChangelog = notesSource === 'auto' || notesSource === 'changelog'
1841
+
1842
+ // A section already written for this version is the most authoritative thing there is.
1843
+ if (wantsChangelog && changelogText) {
1844
+ notes = changelogSection(changelogText, version)
1845
+ if (notes) ok(`${config.changelog} has a ${version} section`)
1846
+ }
1847
+
1848
+ // Otherwise promote [Unreleased], which is equally hand-written.
1849
+ if (!notes && wantsChangelog && changelogText) {
1850
+ rolledChangelog = rollUnreleased(changelogText, version, new Date().toISOString().slice(0, 10))
1691
1851
  if (rolledChangelog) {
1692
1852
  notes = changelogSection(rolledChangelog, version)
1693
1853
  ok(`${config.changelog}: [Unreleased] will become [${version}]`)
1854
+ }
1855
+ }
1856
+
1857
+ // Generate, either because nothing was written or because a source was named.
1858
+ if (!notes) {
1859
+ if (notesDeferred) {
1860
+ ok('release notes will be drafted after the commit')
1694
1861
  } else {
1695
- draftedNotes = notesDeferred ? null : draftNotesFor(version)
1696
- if (notesDeferred) {
1697
- ok(`${config.changelog}: a ${version} section will be drafted after the commit`)
1698
- } else if (draftedNotes) {
1862
+ draftedNotes = draftNotesFor(version)
1863
+ if (draftedNotes) {
1699
1864
  notes = draftedNotes
1700
- ok(`${config.changelog}: a ${version} section will be drafted by ${assistantName}`)
1701
- } else {
1865
+ ok(
1866
+ notesSource === 'commits' || !assistant
1867
+ ? 'release notes built from the commit log'
1868
+ : `release notes drafted by ${assistantName}`,
1869
+ )
1870
+ } else if (notesSource === 'auto') {
1702
1871
  warn(
1703
- `${config.changelog} has no ${version} or [Unreleased] section — GitHub will generate the notes`,
1872
+ `no ${config.changelog ?? 'changelog'} section and nothing to draft from — GitHub will generate the notes`,
1704
1873
  )
1874
+ } else {
1875
+ fail(`notes are set to come from "${notesSource}", which produced nothing`)
1705
1876
  }
1706
1877
  }
1707
1878
  }
1708
- } else {
1709
- // No changelog file at all: there is nothing to roll, but notes can still be drafted for
1710
- // the tag annotation and the GitHub release.
1711
- notes = notesDeferred ? null : draftNotesFor(version)
1712
- if (notesDeferred) ok('release notes will be drafted after the commit')
1713
- else if (notes) ok(`release notes drafted by ${assistantName}`)
1714
- else if (config.changelog) note(`no ${config.changelog} — GitHub will generate the notes`)
1715
1879
  }
1716
1880
 
1717
1881
  for (const asset of config.assets) {
@@ -1760,7 +1924,7 @@ if (dirty && runs('commit') && !dryRun) {
1760
1924
  mutate('git', ['reset', '--quiet'])
1761
1925
  abort(
1762
1926
  `${assistantName} could not draft a Conventional Commits message for these changes.\n\n` +
1763
- ' Commit them yourself and re-run, or run without --commit.',
1927
+ ' Commit them yourself and re-run, or release with --skip commit.',
1764
1928
  )
1765
1929
  }
1766
1930
  console.log(indent(commitMessage))