@entro314labs/release-kit 2.6.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 +39 -6
  2. package/TRAIN.md +332 -0
  3. package/package.json +5 -2
  4. package/release.mjs +140 -71
  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))
@@ -952,7 +974,7 @@ function rollUnreleased(text, version, date) {
952
974
 
953
975
  // Remove the section wherever it sits, then place the release by version.
954
976
  const withoutUnreleased = text.slice(0, match.index) + (next ? after.slice(next.index) : '')
955
- const placed = insertChangelogSection(withoutUnreleased.trimEnd() + '\n', version, date, body)
977
+ const placed = insertChangelogSection(`${withoutUnreleased.trimEnd()}\n`, version, date, body)
956
978
 
957
979
  // A fresh [Unreleased] belongs above every release, whatever the file looked like before.
958
980
  const first = sectionOffsets(placed)[0] ?? placed.length
@@ -1098,6 +1120,7 @@ const explicitDistTag = option('--dist-tag')
1098
1120
  const requestedPreid = option('--preid')
1099
1121
  const autoCommit = flag('--commit')
1100
1122
  const requestedNotesFile = option('--notes-file')
1123
+ const requestedNotesSource = option('--notes')
1101
1124
  const requestedAssistant = option('--assistant')
1102
1125
  const requestedModel = option('--assistant-model')
1103
1126
  const requestedEffort = option('--assistant-effort')
@@ -1166,23 +1189,33 @@ const VALUE_OPTIONS = new Set([
1166
1189
  '--skip',
1167
1190
  '--preid',
1168
1191
  '--dist-tag',
1192
+ '--notes',
1169
1193
  '--notes-file',
1170
1194
  '--assistant',
1171
1195
  '--assistant-model',
1172
1196
  '--assistant-effort',
1173
1197
  ])
1174
1198
 
1175
- /** The version or bump target: the first argument that is neither a flag nor a flag's value. */
1176
- const target = (() => {
1177
- for (let i = 0; i < argv.length; i += 1) {
1178
- if (VALUE_OPTIONS.has(argv[i])) {
1179
- i += 1
1180
- continue
1181
- }
1182
- 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
1183
1205
  }
1184
- return undefined
1185
- })()
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
+ }
1186
1219
 
1187
1220
  // ─────────────────────────────────────────────────────────────────────────────
1188
1221
  // SETUP
@@ -1214,7 +1247,8 @@ if (unknownKeys.length) abort(`release.config.json has unknown keys: ${unknownKe
1214
1247
 
1215
1248
  /**
1216
1249
  * Which steps run: config provides the baseline, --only replaces it, --skip subtracts, and
1217
- * --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.
1218
1252
  */
1219
1253
  const parseStepList = (value) =>
1220
1254
  value
@@ -1539,7 +1573,7 @@ if (bumping && compareVersions(version, currentVersion) <= 0) {
1539
1573
 
1540
1574
  const dirty = tryRead('git', ['status', '--porcelain'])
1541
1575
  if (dirty === null) fail('could not read git status')
1542
- else if (dirty && runs('commit')) {
1576
+ else if (dirty && runs('commit') && assistant) {
1543
1577
  const entries = dirty.split('\n')
1544
1578
  ok(`working tree has ${entries.length} change(s) — will be committed first`)
1545
1579
  console.log(dim(indent(formatStatus(dirty))))
@@ -1558,16 +1592,17 @@ else if (dirty && runs('commit')) {
1558
1592
  'releasing with --skip commit.',
1559
1593
  )
1560
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
+ )
1561
1601
  } else if (dirty) {
1562
1602
  fail(`working tree is not clean:\n${indent(formatStatus(dirty))}`)
1563
1603
  } else ok('working tree clean')
1564
1604
 
1565
- if (runs('commit') && !assistant) {
1566
- fail(
1567
- 'the commit step needs a drafting assistant to write the message. Configure one with ' +
1568
- '`--assistant auto`, or set "assistant" in release.config.json.',
1569
- )
1570
- } else if (assistant) {
1605
+ if (assistant) {
1571
1606
  const detail = [
1572
1607
  assistantModel && `model ${assistantModel}`,
1573
1608
  assistantEffort && `effort ${assistantEffort}`,
@@ -1731,7 +1766,23 @@ function reportChangelogOrder(text) {
1731
1766
  }
1732
1767
  }
1733
1768
 
1734
- // Notes: the changelog section for this version, else a draft, else GitHub generates them.
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
+
1735
1786
  let notes = null
1736
1787
  let rolledChangelog = null
1737
1788
  let draftedNotes = null
@@ -1761,7 +1812,9 @@ function draftNotesFor(v) {
1761
1812
  'will not appear in the notes.',
1762
1813
  )
1763
1814
  }
1764
- if (!assistant) return changelogFromCommits(commits, remoteLinks(config.remote))
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)
1765
1818
  if (shallow) {
1766
1819
  warn(
1767
1820
  `shallow clone: only ${subjects.length} commit(s) are visible, so the notes will ` +
@@ -1771,42 +1824,58 @@ function draftNotesFor(v) {
1771
1824
  note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
1772
1825
  return (
1773
1826
  draftReleaseNotes(v, commits, lastTag, remoteLinks(config.remote)) ??
1774
- changelogFromCommits(commits, remoteLinks(config.remote))
1827
+ changelogFromCommits(commits, remoteLinks(config.remote), config.hiddenTypes)
1775
1828
  )
1776
1829
  }
1777
- if (config.changelog && existsSync(config.changelog)) {
1778
- const text = readFileSync(config.changelog, 'utf8')
1779
- notes = changelogSection(text, version)
1780
- if (notes) {
1781
- ok(`${config.changelog} has a ${version} section`)
1782
- reportChangelogOrder(text)
1783
- } else {
1784
- 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))
1785
1851
  if (rolledChangelog) {
1786
1852
  notes = changelogSection(rolledChangelog, version)
1787
1853
  ok(`${config.changelog}: [Unreleased] will become [${version}]`)
1788
- reportChangelogOrder(text)
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')
1789
1861
  } else {
1790
- draftedNotes = notesDeferred ? null : draftNotesFor(version)
1791
- if (notesDeferred) {
1792
- ok(`${config.changelog}: a ${version} section will be drafted after the commit`)
1793
- } else if (draftedNotes) {
1862
+ draftedNotes = draftNotesFor(version)
1863
+ if (draftedNotes) {
1794
1864
  notes = draftedNotes
1795
- ok(`${config.changelog}: a ${version} section will be drafted by ${assistantName}`)
1796
- } 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') {
1797
1871
  warn(
1798
- `${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`,
1799
1873
  )
1874
+ } else {
1875
+ fail(`notes are set to come from "${notesSource}", which produced nothing`)
1800
1876
  }
1801
1877
  }
1802
1878
  }
1803
- } else {
1804
- // No changelog file at all: there is nothing to roll, but notes can still be drafted for
1805
- // the tag annotation and the GitHub release.
1806
- notes = notesDeferred ? null : draftNotesFor(version)
1807
- if (notesDeferred) ok('release notes will be drafted after the commit')
1808
- else if (notes) ok(`release notes drafted by ${assistantName}`)
1809
- else if (config.changelog) note(`no ${config.changelog} — GitHub will generate the notes`)
1810
1879
  }
1811
1880
 
1812
1881
  for (const asset of config.assets) {
@@ -1855,7 +1924,7 @@ if (dirty && runs('commit') && !dryRun) {
1855
1924
  mutate('git', ['reset', '--quiet'])
1856
1925
  abort(
1857
1926
  `${assistantName} could not draft a Conventional Commits message for these changes.\n\n` +
1858
- ' Commit them yourself and re-run, or run without --commit.',
1927
+ ' Commit them yourself and re-run, or release with --skip commit.',
1859
1928
  )
1860
1929
  }
1861
1930
  console.log(indent(commitMessage))