@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.
- package/README.md +55 -7
- package/TRAIN.md +332 -0
- package/package.json +5 -2
- package/release.mjs +255 -91
- 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
|
-
/**
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
|
414
|
-
*
|
|
415
|
-
*
|
|
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'
|
|
424
|
-
{ type: '
|
|
425
|
-
{ type: '
|
|
426
|
-
{ type: '
|
|
427
|
-
{ type: '
|
|
428
|
-
{ type: '
|
|
429
|
-
{ type: 'chore', section: 'Miscellaneous Chores'
|
|
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
|
-
|
|
527
|
-
|
|
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(
|
|
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
|
-
|
|
648
|
-
|
|
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
|
-
`
|
|
664
|
-
...
|
|
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
|
-
|
|
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
|
-
*
|
|
859
|
-
*
|
|
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
|
|
874
|
-
const next = /^## /m.exec(
|
|
875
|
-
const body = (next ?
|
|
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
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
const
|
|
887
|
-
|
|
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
|
|
1106
|
-
const
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
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 =
|
|
1696
|
-
if (
|
|
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(
|
|
1701
|
-
|
|
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
|
-
|
|
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
|
|
1927
|
+
' Commit them yourself and re-run, or release with --skip commit.',
|
|
1764
1928
|
)
|
|
1765
1929
|
}
|
|
1766
1930
|
console.log(indent(commitMessage))
|