@entro314labs/release-kit 2.6.0 → 2.8.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 +112 -38
- package/TRAIN.md +332 -0
- package/package.json +5 -2
- package/release.mjs +406 -87
- package/train.mjs +1241 -0
package/release.mjs
CHANGED
|
@@ -69,8 +69,17 @@ 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
|
|
80
|
+
* verify string command run during preflight — a project's own gate (tests,
|
|
81
|
+
* build). Non-zero aborts before anything mutates, instead of a
|
|
82
|
+
* prepublishOnly hook failing after the commit, tag and push
|
|
74
83
|
* assistant string|object drafting CLI for commit messages and notes. A key of
|
|
75
84
|
* ASSISTANTS, "auto" for the first available, or null. The
|
|
76
85
|
* object form { tool, model, effort } also pins which model and
|
|
@@ -94,8 +103,12 @@ import { createInterface } from 'node:readline/promises'
|
|
|
94
103
|
*/
|
|
95
104
|
const STEPS = ['commit', 'version', 'changelog', 'tag', 'push', 'publish', 'release']
|
|
96
105
|
|
|
97
|
-
/**
|
|
98
|
-
|
|
106
|
+
/**
|
|
107
|
+
* All seven. `commit` is a conditional default: it no-ops on a clean tree, and on a dirty
|
|
108
|
+
* tree it proceeds only when a drafting assistant is configured — otherwise preflight
|
|
109
|
+
* still refuses the unclean tree. Opt out with `--skip commit` or a `steps` config.
|
|
110
|
+
*/
|
|
111
|
+
const DEFAULT_STEPS = [...STEPS]
|
|
99
112
|
|
|
100
113
|
const DEFAULTS = {
|
|
101
114
|
steps: DEFAULT_STEPS,
|
|
@@ -112,6 +125,15 @@ const DEFAULTS = {
|
|
|
112
125
|
assistant: null,
|
|
113
126
|
notesFile: null,
|
|
114
127
|
versioning: 'conventional',
|
|
128
|
+
notes: 'auto',
|
|
129
|
+
hiddenTypes: [],
|
|
130
|
+
ignoreCommits: [
|
|
131
|
+
'^chore\\(release\\)',
|
|
132
|
+
'^Merge (branch|pull request|remote)',
|
|
133
|
+
'^wip\\b',
|
|
134
|
+
'^(fixup|squash)!',
|
|
135
|
+
],
|
|
136
|
+
verify: null,
|
|
115
137
|
}
|
|
116
138
|
|
|
117
139
|
/**
|
|
@@ -143,16 +165,25 @@ Target (optional; defaults to the version already in package.json):
|
|
|
143
165
|
Steps, in the fixed order they run. All but "commit" run by default:
|
|
144
166
|
${STEPS.join(' ')}
|
|
145
167
|
|
|
168
|
+
Subcommands (they check or copy, and never start a release):
|
|
169
|
+
lint-commits [<range>]
|
|
170
|
+
check commit subjects against Conventional Commits
|
|
171
|
+
(default range: since the last tag)
|
|
172
|
+
lint-commits --subject <text>
|
|
173
|
+
check one subject — a pull request title before it is squashed
|
|
174
|
+
|
|
146
175
|
Flags:
|
|
147
176
|
--only <steps> run only these steps, comma-separated
|
|
148
177
|
--skip <steps> run every step except these
|
|
149
|
-
--commit
|
|
178
|
+
--commit force the commit step on when a steps config removed it:
|
|
179
|
+
commit a dirty working tree with a
|
|
150
180
|
drafted Conventional Commits message instead of refusing to release
|
|
151
181
|
--preid <id> prerelease identifier (alpha, beta, rc, next, nightly, canary)
|
|
152
182
|
--dist-tag <name> override the npm dist-tag (default: derived from the version)
|
|
153
183
|
--dry-run print every step and execute nothing
|
|
154
184
|
--yes, -y skip the confirmation prompt
|
|
155
185
|
--notes-file <path> write the resolved release notes to a file for the next tool
|
|
186
|
+
--notes <source> where notes come from: auto, changelog, assistant, commits, github
|
|
156
187
|
--assistant <name> drafting CLI to use: auto, none, claude, codex
|
|
157
188
|
--assistant-model <name>
|
|
158
189
|
model the assistant runs with (e.g. sonnet, opus)
|
|
@@ -203,8 +234,8 @@ const formatStatus = (porcelain) =>
|
|
|
203
234
|
})
|
|
204
235
|
.join('\n')
|
|
205
236
|
|
|
206
|
-
function abort(message) {
|
|
207
|
-
console.log(`\n${red(bold(
|
|
237
|
+
function abort(message, title = 'RELEASE ABORTED') {
|
|
238
|
+
console.log(`\n${red(bold(title))} — ${message}\n`)
|
|
208
239
|
process.exit(1)
|
|
209
240
|
}
|
|
210
241
|
|
|
@@ -382,6 +413,7 @@ function runAssistant(prompt) {
|
|
|
382
413
|
|
|
383
414
|
/** Commit subjects since the last tag, with release and merge commits filtered out. */
|
|
384
415
|
function commitsSinceLastTag() {
|
|
416
|
+
const ignored = (config.ignoreCommits ?? []).map((pattern) => new RegExp(pattern, 'i'))
|
|
385
417
|
const lastTag = tryRead('git', ['describe', '--tags', '--abbrev=0'])
|
|
386
418
|
const range = lastTag ? `${lastTag}..HEAD` : 'HEAD'
|
|
387
419
|
// %B is the whole message: bodies carry BREAKING CHANGE and Release-As footers. A record
|
|
@@ -397,22 +429,20 @@ function commitsSinceLastTag() {
|
|
|
397
429
|
const [subject, ...rest] = message.split('\n')
|
|
398
430
|
return { hash: hash.trim(), subject: subject.trim(), body: rest.join('\n').trim() }
|
|
399
431
|
})
|
|
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
|
-
)
|
|
432
|
+
// Bookkeeping rather than change: the previous release's own commit, merges that
|
|
433
|
+
// duplicate the branch they bring in, and markers meant to be autosquashed away.
|
|
434
|
+
.filter(({ subject }) => subject && !ignored.some((re) => re.test(subject)))
|
|
408
435
|
const kept = withoutRevertedCommits(commits)
|
|
409
436
|
return { lastTag, commits: kept, subjects: kept.map((c) => c.subject) }
|
|
410
437
|
}
|
|
411
438
|
|
|
412
439
|
/**
|
|
413
|
-
* Conventional Commit types and the changelog heading each lands under
|
|
414
|
-
*
|
|
415
|
-
*
|
|
440
|
+
* Conventional Commit types and the changelog heading each lands under.
|
|
441
|
+
*
|
|
442
|
+
* Every type is reported. release-please and goreleaser hide `chore`, `ci`, `docs` and the
|
|
443
|
+
* rest by default, on the view that a reader upgrading does not care — but a changelog is a
|
|
444
|
+
* record, and silently omitting work makes it a partial one. A project that wants the
|
|
445
|
+
* shorter version lists the types to drop in `hiddenTypes`.
|
|
416
446
|
*/
|
|
417
447
|
const CHANGELOG_SECTIONS = [
|
|
418
448
|
{ type: 'feat', section: 'Features' },
|
|
@@ -420,13 +450,13 @@ const CHANGELOG_SECTIONS = [
|
|
|
420
450
|
{ type: 'perf', section: 'Performance Improvements' },
|
|
421
451
|
{ type: 'revert', section: 'Reverts' },
|
|
422
452
|
{ 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'
|
|
453
|
+
{ type: 'docs', section: 'Documentation' },
|
|
454
|
+
{ type: 'refactor', section: 'Code Refactoring' },
|
|
455
|
+
{ type: 'build', section: 'Build System' },
|
|
456
|
+
{ type: 'ci', section: 'Continuous Integration' },
|
|
457
|
+
{ type: 'test', section: 'Tests' },
|
|
458
|
+
{ type: 'style', section: 'Styles' },
|
|
459
|
+
{ type: 'chore', section: 'Miscellaneous Chores' },
|
|
430
460
|
]
|
|
431
461
|
|
|
432
462
|
/**
|
|
@@ -498,7 +528,7 @@ function inferBump(commits, currentVersion, strategy = 'conventional') {
|
|
|
498
528
|
*
|
|
499
529
|
* @returns {string | null} markdown body, or null when nothing visible changed
|
|
500
530
|
*/
|
|
501
|
-
function changelogFromCommits(commits, links = null) {
|
|
531
|
+
function changelogFromCommits(commits, links = null, hidden = []) {
|
|
502
532
|
const parsed = commits.map((c) => parseCommit(c.subject, c.body, c.hash)).filter(Boolean)
|
|
503
533
|
const lines = []
|
|
504
534
|
|
|
@@ -523,8 +553,9 @@ function changelogFromCommits(commits, links = null) {
|
|
|
523
553
|
}
|
|
524
554
|
|
|
525
555
|
const known = new Set(CHANGELOG_SECTIONS.map((s) => s.type))
|
|
526
|
-
|
|
527
|
-
|
|
556
|
+
const hiddenTypes = new Set(hidden)
|
|
557
|
+
for (const { type, section } of CHANGELOG_SECTIONS) {
|
|
558
|
+
if (hiddenTypes.has(type)) continue
|
|
528
559
|
const inSection = parsed.filter(
|
|
529
560
|
(c) => c.type === type || (type === 'feat' && c.type === 'feature'),
|
|
530
561
|
)
|
|
@@ -537,7 +568,9 @@ function changelogFromCommits(commits, links = null) {
|
|
|
537
568
|
// A conventional type nobody anticipated — `security:`, `i18n:` — is still a change
|
|
538
569
|
// someone made deliberately. Dropping it silently is how a security fix goes unmentioned.
|
|
539
570
|
// The hidden types are excluded because hiding them is the point.
|
|
540
|
-
const other = parsed.filter(
|
|
571
|
+
const other = parsed.filter(
|
|
572
|
+
(c) => !known.has(c.type) && c.type !== 'feature' && !hiddenTypes.has(c.type),
|
|
573
|
+
)
|
|
541
574
|
if (other.length) {
|
|
542
575
|
lines.push('### Other Changes', '')
|
|
543
576
|
for (const c of other) lines.push(bullet(c, c.subject))
|
|
@@ -602,8 +635,104 @@ function withoutRevertedCommits(commits) {
|
|
|
602
635
|
)
|
|
603
636
|
}
|
|
604
637
|
|
|
605
|
-
|
|
606
|
-
|
|
638
|
+
/**
|
|
639
|
+
* Semver strings a drafted message names that the staged changes never touch. The prompt
|
|
640
|
+
* forbids narrating versions, but a model can still read an unchanged `"version"` context
|
|
641
|
+
* line and describe it as work — a draft once claimed "release v1.4.5" for a commit that
|
|
642
|
+
* changed no version at all. Only added and removed lines are the change.
|
|
643
|
+
*/
|
|
644
|
+
function inventedVersions(message, changedLines) {
|
|
645
|
+
const versions = message.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g) ?? []
|
|
646
|
+
return [...new Set(versions)].filter((version) => !changedLines.includes(version))
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* A repository URL reduced to what identifies the repository: `git+` and protocol
|
|
651
|
+
* prefixes, the `git@host:` shorthand, a trailing `.git` and letter case all vary between
|
|
652
|
+
* package.json and a git remote without meaning a different repo.
|
|
653
|
+
*/
|
|
654
|
+
function normalizeRepoUrl(url) {
|
|
655
|
+
if (!url) return null
|
|
656
|
+
return url
|
|
657
|
+
.trim()
|
|
658
|
+
.replace(/^git\+/, '')
|
|
659
|
+
.replace(/^git@([^:]+):/, 'https://$1/')
|
|
660
|
+
.replace(/^ssh:\/\/git@/, 'https://')
|
|
661
|
+
.replace(/^git:\/\//, 'https://')
|
|
662
|
+
.replace(/\.git$/, '')
|
|
663
|
+
.replace(/\/+$/, '')
|
|
664
|
+
.toLowerCase()
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* A deterministic Conventional Commits message built from the staged file list — the
|
|
669
|
+
* floor under the drafting assistant. A release is never blocked because a text
|
|
670
|
+
* generator was unavailable or produced an unusable answer: the honest fallback is a
|
|
671
|
+
* chore commit that names what it touches, with the full paths in the body.
|
|
672
|
+
*/
|
|
673
|
+
function fallbackCommitMessage(files) {
|
|
674
|
+
const named = `chore: update ${files.map((file) => basename(file)).join(' and ')}`
|
|
675
|
+
const subject =
|
|
676
|
+
files.length > 0 && files.length <= 2 && named.length <= 72
|
|
677
|
+
? named
|
|
678
|
+
: `chore: update ${files.length} files`
|
|
679
|
+
const body = files.length > 2 ? files.map((file) => `- ${file}`).join('\n') : ''
|
|
680
|
+
return body ? `${subject}\n\n${body}` : subject
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* The types worth writing: every one has a changelog section of its own.
|
|
685
|
+
*
|
|
686
|
+
* Derived from CHANGELOG_SECTIONS rather than written out again, because the hand-kept
|
|
687
|
+
* copy had already drifted — it omitted `deps`, so the drafter could never produce a
|
|
688
|
+
* subject for the Dependencies section the changelog has always had.
|
|
689
|
+
*/
|
|
690
|
+
const CHANGELOG_TYPES = CHANGELOG_SECTIONS.map((s) => s.type)
|
|
691
|
+
|
|
692
|
+
/** The drafter's types plus `feature`, the alias `changelogFromCommits` folds into feat. */
|
|
693
|
+
const KNOWN_TYPES = new Set([...CHANGELOG_TYPES, 'feature'])
|
|
694
|
+
const CONVENTIONAL_RE = new RegExp(`^(${[...KNOWN_TYPES].join('|')})(\\([^)]+\\))?!?: .+`)
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Check commit subjects against the grammar the rest of this file reads.
|
|
698
|
+
*
|
|
699
|
+
* Two severities, because the two failures do not cost the same:
|
|
700
|
+
*
|
|
701
|
+
* - A subject `parseCommit` cannot read is invisible. It contributes nothing to the
|
|
702
|
+
* inferred bump and never reaches the changelog, so the work simply disappears.
|
|
703
|
+
* - A type outside CHANGELOG_SECTIONS is merely unfiled: `changelogFromCommits` still
|
|
704
|
+
* prints it under "Other Changes". `security:` and `i18n:` warn rather than fail —
|
|
705
|
+
* refusing them would make this stricter than the tool it is meant to protect.
|
|
706
|
+
*
|
|
707
|
+
* Case is not one of the failures: `parseCommit` folds the type, so `Feat:` bumps and files
|
|
708
|
+
* exactly as `feat:` does. The drafter is stricter about its own output than this is about
|
|
709
|
+
* anybody's commits, and deliberately so.
|
|
710
|
+
*
|
|
711
|
+
* @param {{subject: string, hash?: string}[]} commits
|
|
712
|
+
* @returns {{subject: string, hash: string, level: 'error'|'warn', reason: string}[]}
|
|
713
|
+
*/
|
|
714
|
+
function lintSubjects(commits) {
|
|
715
|
+
const findings = []
|
|
716
|
+
for (const { subject, hash = '' } of commits) {
|
|
717
|
+
const parsed = parseCommit(subject)
|
|
718
|
+
if (!parsed) {
|
|
719
|
+
findings.push({
|
|
720
|
+
subject,
|
|
721
|
+
hash,
|
|
722
|
+
level: 'error',
|
|
723
|
+
reason: `not Conventional Commits — expected "<type>(<scope>): <description>" with type one of ${CHANGELOG_TYPES.join(', ')}`,
|
|
724
|
+
})
|
|
725
|
+
} else if (!KNOWN_TYPES.has(parsed.type)) {
|
|
726
|
+
findings.push({
|
|
727
|
+
subject,
|
|
728
|
+
hash,
|
|
729
|
+
level: 'warn',
|
|
730
|
+
reason: `type "${parsed.type}" has no changelog section — it lands under "Other Changes"`,
|
|
731
|
+
})
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return findings
|
|
735
|
+
}
|
|
607
736
|
|
|
608
737
|
/**
|
|
609
738
|
* Draft a Conventional Commits message for the staged changes.
|
|
@@ -620,11 +749,15 @@ function draftCommitMessage() {
|
|
|
620
749
|
'Write a Conventional Commits message for these staged changes.',
|
|
621
750
|
'',
|
|
622
751
|
'Rules:',
|
|
623
|
-
`- Subject: "<type>(<optional scope>): <description>" where type is one of ${
|
|
752
|
+
`- Subject: "<type>(<optional scope>): <description>" where type is one of ${CHANGELOG_TYPES.join(', ')}.`,
|
|
624
753
|
'- Subject in the imperative mood, no trailing period, under 72 characters.',
|
|
625
754
|
'- Add a body only if the change needs explanation; separate it with a blank line.',
|
|
626
755
|
'- Output the raw commit message and nothing else: no markdown fences, no preamble.',
|
|
627
756
|
'- Do NOT add Co-Authored-By, Signed-off-by, or any attribution or tool credit.',
|
|
757
|
+
'- Describe only lines that are added or removed in the diff. Unchanged context lines',
|
|
758
|
+
' (including any version fields they show) are not part of this change.',
|
|
759
|
+
'- Never mention version numbers, releases, or version bumps: the release tooling',
|
|
760
|
+
' handles versioning separately, and this commit is not the release.',
|
|
628
761
|
'',
|
|
629
762
|
'Files changed:',
|
|
630
763
|
stat,
|
|
@@ -636,7 +769,19 @@ function draftCommitMessage() {
|
|
|
636
769
|
const message = runAssistant(prompt)
|
|
637
770
|
if (!message) return null
|
|
638
771
|
const [subject] = message.split('\n')
|
|
639
|
-
|
|
772
|
+
if (!CONVENTIONAL_RE.test(subject)) return null
|
|
773
|
+
// The prompt forbids narrating versions; this is the deterministic backstop — the same
|
|
774
|
+
// pattern as the citation check on drafted notes: validated, not trusted.
|
|
775
|
+
const changedLines = (tryRead('git', ['diff', '--cached', '--unified=0']) ?? '')
|
|
776
|
+
.split('\n')
|
|
777
|
+
.filter((line) => /^[+-](?![+-])/.test(line))
|
|
778
|
+
.join('\n')
|
|
779
|
+
const invented = inventedVersions(message, changedLines)
|
|
780
|
+
if (invented.length) {
|
|
781
|
+
note(`draft rejected: it names ${invented.join(', ')}, which the staged changes never touch`)
|
|
782
|
+
return null
|
|
783
|
+
}
|
|
784
|
+
return message
|
|
640
785
|
}
|
|
641
786
|
|
|
642
787
|
/**
|
|
@@ -952,7 +1097,7 @@ function rollUnreleased(text, version, date) {
|
|
|
952
1097
|
|
|
953
1098
|
// Remove the section wherever it sits, then place the release by version.
|
|
954
1099
|
const withoutUnreleased = text.slice(0, match.index) + (next ? after.slice(next.index) : '')
|
|
955
|
-
const placed = insertChangelogSection(withoutUnreleased.trimEnd()
|
|
1100
|
+
const placed = insertChangelogSection(`${withoutUnreleased.trimEnd()}\n`, version, date, body)
|
|
956
1101
|
|
|
957
1102
|
// A fresh [Unreleased] belongs above every release, whatever the file looked like before.
|
|
958
1103
|
const first = sectionOffsets(placed)[0] ?? placed.length
|
|
@@ -965,6 +1110,10 @@ function rollUnreleased(text, version, date) {
|
|
|
965
1110
|
|
|
966
1111
|
const readJson = (path) => JSON.parse(readFileSync(path, 'utf8'))
|
|
967
1112
|
|
|
1113
|
+
/** The project's release.config.json, or {} when it has none. */
|
|
1114
|
+
const readUserConfig = () =>
|
|
1115
|
+
existsSync('release.config.json') ? readJson('release.config.json') : {}
|
|
1116
|
+
|
|
968
1117
|
/**
|
|
969
1118
|
* Where a project keeps its version. The format is inferred from the file name, so the
|
|
970
1119
|
* common cases need nothing but a path:
|
|
@@ -1098,6 +1247,7 @@ const explicitDistTag = option('--dist-tag')
|
|
|
1098
1247
|
const requestedPreid = option('--preid')
|
|
1099
1248
|
const autoCommit = flag('--commit')
|
|
1100
1249
|
const requestedNotesFile = option('--notes-file')
|
|
1250
|
+
const requestedNotesSource = option('--notes')
|
|
1101
1251
|
const requestedAssistant = option('--assistant')
|
|
1102
1252
|
const requestedModel = option('--assistant-model')
|
|
1103
1253
|
const requestedEffort = option('--assistant-effort')
|
|
@@ -1157,6 +1307,69 @@ if (flag('--sync')) {
|
|
|
1157
1307
|
process.exit(0)
|
|
1158
1308
|
}
|
|
1159
1309
|
|
|
1310
|
+
// lint-commits checks subjects and exits; like --sync it starts no release. It is handled
|
|
1311
|
+
// before the positional parsing below because it takes a range, and a second positional is
|
|
1312
|
+
// otherwise a mistake.
|
|
1313
|
+
if (argv[0] === 'lint-commits') {
|
|
1314
|
+
const rest = argv.slice(1)
|
|
1315
|
+
const subjectAt = rest.indexOf('--subject')
|
|
1316
|
+
const ignored = { ...DEFAULTS, ...readUserConfig() }.ignoreCommits.map(
|
|
1317
|
+
(pattern) => new RegExp(pattern, 'i'),
|
|
1318
|
+
)
|
|
1319
|
+
|
|
1320
|
+
let subjects
|
|
1321
|
+
let scope
|
|
1322
|
+
if (subjectAt !== -1) {
|
|
1323
|
+
// A squash merge takes its subject from the pull request title, which is therefore the
|
|
1324
|
+
// commit this repository will parse — and the one no commit-msg hook ever sees.
|
|
1325
|
+
const text = rest[subjectAt + 1]
|
|
1326
|
+
if (text === undefined) abort('--subject needs the text to check', 'COMMIT LINT FAILED')
|
|
1327
|
+
subjects = [{ hash: '', subject: text.trim() }]
|
|
1328
|
+
scope = null
|
|
1329
|
+
} else {
|
|
1330
|
+
if (!tryRead('git', ['rev-parse', '--show-toplevel'])) abort('not inside a git repository')
|
|
1331
|
+
const lastTag = tryRead('git', ['describe', '--tags', '--abbrev=0'])
|
|
1332
|
+
const range =
|
|
1333
|
+
rest.find((arg) => !arg.startsWith('-')) ?? (lastTag ? `${lastTag}..HEAD` : 'HEAD')
|
|
1334
|
+
// Merges carry no prose of their own, and %s is enough: nothing here reads the body.
|
|
1335
|
+
const raw = tryRead('git', ['log', '--no-merges', '--format=%h%x1f%s', range])
|
|
1336
|
+
if (raw === null) {
|
|
1337
|
+
abort(
|
|
1338
|
+
`\`git log ${range}\` failed — is that a range in this repository?`,
|
|
1339
|
+
'COMMIT LINT FAILED',
|
|
1340
|
+
)
|
|
1341
|
+
}
|
|
1342
|
+
subjects = raw
|
|
1343
|
+
.split('\n')
|
|
1344
|
+
.filter(Boolean)
|
|
1345
|
+
.map((line) => {
|
|
1346
|
+
const [hash, subject = ''] = line.split('\u001F')
|
|
1347
|
+
return { hash: hash.trim(), subject: subject.trim() }
|
|
1348
|
+
})
|
|
1349
|
+
// The bookkeeping `commitsSinceLastTag` drops for the same reason: a release commit,
|
|
1350
|
+
// a merge or an autosquash marker is nobody's prose to fix.
|
|
1351
|
+
.filter(({ subject }) => subject && !ignored.some((re) => re.test(subject)))
|
|
1352
|
+
scope = range
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
const findings = lintSubjects(subjects)
|
|
1356
|
+
for (const { hash, subject, level, reason } of findings) {
|
|
1357
|
+
const label = level === 'error' ? red('error') : yellow('warn')
|
|
1358
|
+
console.log(` ${label} ${hash ? `${dim(hash)} ` : ''}${subject}\n ${dim(reason)}`)
|
|
1359
|
+
}
|
|
1360
|
+
const errors = findings.filter((f) => f.level === 'error').length
|
|
1361
|
+
if (errors) {
|
|
1362
|
+
abort(
|
|
1363
|
+
`${errors} subject${errors === 1 ? '' : 's'} the changelog and the version bump cannot read`,
|
|
1364
|
+
'COMMIT LINT FAILED',
|
|
1365
|
+
)
|
|
1366
|
+
}
|
|
1367
|
+
const counted = `${subjects.length} subject${subjects.length === 1 ? '' : 's'}`
|
|
1368
|
+
const unfiled = findings.length ? `, ${findings.length} unfiled` : ''
|
|
1369
|
+
ok(`${scope ? `${scope}: ` : ''}${counted} valid${unfiled}`)
|
|
1370
|
+
process.exit(0)
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1160
1373
|
/**
|
|
1161
1374
|
* Options that consume the argument after them. Without this list a positional target is
|
|
1162
1375
|
* found by guessing, and `--only tag,push` gets read as the version to release.
|
|
@@ -1166,23 +1379,33 @@ const VALUE_OPTIONS = new Set([
|
|
|
1166
1379
|
'--skip',
|
|
1167
1380
|
'--preid',
|
|
1168
1381
|
'--dist-tag',
|
|
1382
|
+
'--notes',
|
|
1169
1383
|
'--notes-file',
|
|
1170
1384
|
'--assistant',
|
|
1171
1385
|
'--assistant-model',
|
|
1172
1386
|
'--assistant-effort',
|
|
1173
1387
|
])
|
|
1174
1388
|
|
|
1175
|
-
/** The version or bump target: the
|
|
1176
|
-
const
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
}
|
|
1182
|
-
if (!argv[i].startsWith('-')) return argv[i]
|
|
1389
|
+
/** The version or bump target: the only argument that is neither a flag nor a flag's value. */
|
|
1390
|
+
const positionals = []
|
|
1391
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
1392
|
+
if (VALUE_OPTIONS.has(argv[i])) {
|
|
1393
|
+
i += 1
|
|
1394
|
+
continue
|
|
1183
1395
|
}
|
|
1184
|
-
|
|
1185
|
-
}
|
|
1396
|
+
if (!argv[i].startsWith('-')) positionals.push(argv[i])
|
|
1397
|
+
}
|
|
1398
|
+
const target = positionals[0]
|
|
1399
|
+
// A second positional is always a mistake, and silently ignoring it changes the release
|
|
1400
|
+
// that runs — `release-kit auto assistant auto` must not quietly mean `release-kit auto`.
|
|
1401
|
+
if (positionals.length > 1) {
|
|
1402
|
+
const extras = positionals.slice(1)
|
|
1403
|
+
const flagLike = extras.find((arg) => VALUE_OPTIONS.has(`--${arg}`))
|
|
1404
|
+
const hint = flagLike
|
|
1405
|
+
? `\n Flags are spelled with dashes: --${flagLike} ${extras[extras.indexOf(flagLike) + 1] ?? '<value>'}`
|
|
1406
|
+
: ''
|
|
1407
|
+
abort(`unexpected argument${extras.length > 1 ? 's' : ''}: ${extras.join(' ')}${hint}`)
|
|
1408
|
+
}
|
|
1186
1409
|
|
|
1187
1410
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1188
1411
|
// SETUP
|
|
@@ -1207,14 +1430,15 @@ if (existsSync(localManifest) && localManifest !== rootManifest) {
|
|
|
1207
1430
|
}
|
|
1208
1431
|
process.chdir(root)
|
|
1209
1432
|
|
|
1210
|
-
const userConfig =
|
|
1433
|
+
const userConfig = readUserConfig()
|
|
1211
1434
|
const config = { ...DEFAULTS, ...userConfig }
|
|
1212
1435
|
const unknownKeys = Object.keys(config).filter((key) => !(key in DEFAULTS))
|
|
1213
1436
|
if (unknownKeys.length) abort(`release.config.json has unknown keys: ${unknownKeys.join(', ')}`)
|
|
1214
1437
|
|
|
1215
1438
|
/**
|
|
1216
1439
|
* Which steps run: config provides the baseline, --only replaces it, --skip subtracts, and
|
|
1217
|
-
* --commit
|
|
1440
|
+
* --commit forces the step on when a steps config removed it. Unknown names are an
|
|
1441
|
+
* error rather than a silent no-op.
|
|
1218
1442
|
*/
|
|
1219
1443
|
const parseStepList = (value) =>
|
|
1220
1444
|
value
|
|
@@ -1543,6 +1767,12 @@ else if (dirty && runs('commit')) {
|
|
|
1543
1767
|
const entries = dirty.split('\n')
|
|
1544
1768
|
ok(`working tree has ${entries.length} change(s) — will be committed first`)
|
|
1545
1769
|
console.log(dim(indent(formatStatus(dirty))))
|
|
1770
|
+
if (!assistant) {
|
|
1771
|
+
note(
|
|
1772
|
+
'no assistant configured: the commit message will name the files — ' +
|
|
1773
|
+
'`--assistant auto` drafts a real one',
|
|
1774
|
+
)
|
|
1775
|
+
}
|
|
1546
1776
|
// One commit gets one subject. A change set spanning several top-level directories is
|
|
1547
1777
|
// usually several pieces of work, and no honest Conventional Commits subject covers it.
|
|
1548
1778
|
const areas = new Set(
|
|
@@ -1562,12 +1792,7 @@ else if (dirty && runs('commit')) {
|
|
|
1562
1792
|
fail(`working tree is not clean:\n${indent(formatStatus(dirty))}`)
|
|
1563
1793
|
} else ok('working tree clean')
|
|
1564
1794
|
|
|
1565
|
-
if (
|
|
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) {
|
|
1795
|
+
if (assistant) {
|
|
1571
1796
|
const detail = [
|
|
1572
1797
|
assistantModel && `model ${assistantModel}`,
|
|
1573
1798
|
assistantEffort && `effort ${assistantEffort}`,
|
|
@@ -1592,9 +1817,9 @@ else if (detached) {
|
|
|
1592
1817
|
fail(`on '${branch}', expected '${config.branch}'`)
|
|
1593
1818
|
} else ok(`on ${branch}`)
|
|
1594
1819
|
|
|
1595
|
-
// A shallow clone (CI checkouts default to depth 1)
|
|
1596
|
-
//
|
|
1597
|
-
//
|
|
1820
|
+
// A shallow clone (CI checkouts default to depth 1) is only a problem when it truncates
|
|
1821
|
+
// the history the release actually reads. Whether it does is checked after the fetch
|
|
1822
|
+
// below, where the answer is most accurate.
|
|
1598
1823
|
const shallow = tryRead('git', ['rev-parse', '--is-shallow-repository']) === 'true'
|
|
1599
1824
|
|
|
1600
1825
|
if (!succeeds('git', ['remote', 'get-url', config.remote])) {
|
|
@@ -1617,6 +1842,50 @@ if (!succeeds('git', ['remote', 'get-url', config.remote])) {
|
|
|
1617
1842
|
}
|
|
1618
1843
|
}
|
|
1619
1844
|
|
|
1845
|
+
// If the previous release tag is reachable from HEAD, a shallow clone hides nothing the
|
|
1846
|
+
// release reads — notes and `auto` see the whole span. No reachable tag means the history
|
|
1847
|
+
// is provably truncated: `auto` would infer the bump from a fraction of the commits, so
|
|
1848
|
+
// that is a failure; commit-derived notes merely come out partial, so that is a warning.
|
|
1849
|
+
let shallowHidesHistory = false
|
|
1850
|
+
if (shallow) {
|
|
1851
|
+
const reachableTag = tryRead('git', ['describe', '--tags', '--abbrev=0'])
|
|
1852
|
+
shallowHidesHistory = !reachableTag
|
|
1853
|
+
if (reachableTag) {
|
|
1854
|
+
ok(
|
|
1855
|
+
`shallow clone, but history back to ${reachableTag} is visible — notes and auto are complete`,
|
|
1856
|
+
)
|
|
1857
|
+
} else if (autoBump) {
|
|
1858
|
+
fail(
|
|
1859
|
+
'shallow clone hides the history `auto` infers the bump from — no previous tag is ' +
|
|
1860
|
+
'reachable.\n Fetch full history: fetch-depth: 0 in CI, or git fetch --unshallow.',
|
|
1861
|
+
)
|
|
1862
|
+
} else {
|
|
1863
|
+
warn(
|
|
1864
|
+
'shallow clone: no previous tag is reachable, so notes drafted from commits will ' +
|
|
1865
|
+
'describe only the visible history. Fetch full history (fetch-depth: 0, or ' +
|
|
1866
|
+
'git fetch --unshallow).',
|
|
1867
|
+
)
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
// The registry's "Repository" link comes from the manifest, not from git — a mismatch
|
|
1872
|
+
// ships a broken link with every publish, and npm only warns after the fact.
|
|
1873
|
+
if (existsSync('package.json')) {
|
|
1874
|
+
const repoField = readJson('package.json').repository
|
|
1875
|
+
const declared = typeof repoField === 'string' ? repoField : repoField?.url
|
|
1876
|
+
const remoteUrl = tryRead('git', ['remote', 'get-url', config.remote])
|
|
1877
|
+
if (declared && remoteUrl && /:\/\/|@/.test(declared)) {
|
|
1878
|
+
if (normalizeRepoUrl(declared) !== normalizeRepoUrl(remoteUrl)) {
|
|
1879
|
+
warn(
|
|
1880
|
+
`package.json repository is ${declared}, but ${config.remote} is ${remoteUrl} — ` +
|
|
1881
|
+
'the registry will link the wrong repository',
|
|
1882
|
+
)
|
|
1883
|
+
} else if (!/^git\+.*\.git$/.test(declared)) {
|
|
1884
|
+
note(`npm normalizes repository.url on publish — \`npm pkg fix\` writes that form`)
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1620
1889
|
// Signing is configured per repository and inherited, never managed here — git already
|
|
1621
1890
|
// owns that. But a signing setup that cannot produce a signature fails at the commit step,
|
|
1622
1891
|
// after the version has been written, so it is worth catching before anything mutates.
|
|
@@ -1731,7 +2000,23 @@ function reportChangelogOrder(text) {
|
|
|
1731
2000
|
}
|
|
1732
2001
|
}
|
|
1733
2002
|
|
|
1734
|
-
|
|
2003
|
+
/**
|
|
2004
|
+
* Where the notes come from. "auto" walks the list — a hand-written changelog section beats
|
|
2005
|
+
* anything generated — while an explicit source forces one, because asking for a thing and
|
|
2006
|
+
* being given something else is worse than being told the thing is unavailable.
|
|
2007
|
+
*/
|
|
2008
|
+
const NOTE_SOURCES = ['auto', 'changelog', 'assistant', 'commits', 'github']
|
|
2009
|
+
const notesSource = requestedNotesSource ?? config.notes ?? 'auto'
|
|
2010
|
+
if (!NOTE_SOURCES.includes(notesSource)) {
|
|
2011
|
+
abort(`unknown notes source "${notesSource}". Known: ${NOTE_SOURCES.join(', ')}`)
|
|
2012
|
+
}
|
|
2013
|
+
if (notesSource === 'assistant' && !assistant) {
|
|
2014
|
+
abort(
|
|
2015
|
+
'notes are set to come from an assistant, but none is available.\n' +
|
|
2016
|
+
' Add --assistant auto, or set "assistant" in release.config.json.',
|
|
2017
|
+
)
|
|
2018
|
+
}
|
|
2019
|
+
|
|
1735
2020
|
let notes = null
|
|
1736
2021
|
let rolledChangelog = null
|
|
1737
2022
|
let draftedNotes = null
|
|
@@ -1740,7 +2025,7 @@ let draftedNotes = null
|
|
|
1740
2025
|
* True when --commit still has to create a commit. Notes drafted before that commit would
|
|
1741
2026
|
* describe an incomplete release, so drafting waits until the working tree is committed.
|
|
1742
2027
|
*/
|
|
1743
|
-
const notesDeferred = !!(dirty && runs('commit')
|
|
2028
|
+
const notesDeferred = !!(dirty && runs('commit'))
|
|
1744
2029
|
|
|
1745
2030
|
/**
|
|
1746
2031
|
* Notes for a version, in descending order of how much they can be trusted:
|
|
@@ -1761,8 +2046,10 @@ function draftNotesFor(v) {
|
|
|
1761
2046
|
'will not appear in the notes.',
|
|
1762
2047
|
)
|
|
1763
2048
|
}
|
|
1764
|
-
|
|
1765
|
-
if (
|
|
2049
|
+
// An explicitly named source wins over the assistant being merely available.
|
|
2050
|
+
if (!assistant || notesSource === 'commits')
|
|
2051
|
+
return changelogFromCommits(commits, remoteLinks(config.remote), config.hiddenTypes)
|
|
2052
|
+
if (shallowHidesHistory) {
|
|
1766
2053
|
warn(
|
|
1767
2054
|
`shallow clone: only ${subjects.length} commit(s) are visible, so the notes will ` +
|
|
1768
2055
|
'describe part of the release. Check out with full history (fetch-depth: 0).',
|
|
@@ -1771,42 +2058,58 @@ function draftNotesFor(v) {
|
|
|
1771
2058
|
note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
|
|
1772
2059
|
return (
|
|
1773
2060
|
draftReleaseNotes(v, commits, lastTag, remoteLinks(config.remote)) ??
|
|
1774
|
-
changelogFromCommits(commits, remoteLinks(config.remote))
|
|
2061
|
+
changelogFromCommits(commits, remoteLinks(config.remote), config.hiddenTypes)
|
|
1775
2062
|
)
|
|
1776
2063
|
}
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
2064
|
+
const changelogText =
|
|
2065
|
+
config.changelog && existsSync(config.changelog) ? readFileSync(config.changelog, 'utf8') : null
|
|
2066
|
+
if (changelogText) reportChangelogOrder(changelogText)
|
|
2067
|
+
|
|
2068
|
+
// `github` means write nothing and let GitHub generate from the commit log.
|
|
2069
|
+
if (notesSource === 'github') {
|
|
2070
|
+
note('notes will be generated by GitHub')
|
|
2071
|
+
} else if (notesSource === 'changelog' && !changelogText) {
|
|
2072
|
+
fail(`notes are set to come from ${config.changelog}, which does not exist`)
|
|
2073
|
+
} else {
|
|
2074
|
+
const wantsChangelog = notesSource === 'auto' || notesSource === 'changelog'
|
|
2075
|
+
|
|
2076
|
+
// A section already written for this version is the most authoritative thing there is.
|
|
2077
|
+
if (wantsChangelog && changelogText) {
|
|
2078
|
+
notes = changelogSection(changelogText, version)
|
|
2079
|
+
if (notes) ok(`${config.changelog} has a ${version} section`)
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
// Otherwise promote [Unreleased], which is equally hand-written.
|
|
2083
|
+
if (!notes && wantsChangelog && changelogText) {
|
|
2084
|
+
rolledChangelog = rollUnreleased(changelogText, version, new Date().toISOString().slice(0, 10))
|
|
1785
2085
|
if (rolledChangelog) {
|
|
1786
2086
|
notes = changelogSection(rolledChangelog, version)
|
|
1787
2087
|
ok(`${config.changelog}: [Unreleased] will become [${version}]`)
|
|
1788
|
-
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
// Generate, either because nothing was written or because a source was named.
|
|
2092
|
+
if (!notes) {
|
|
2093
|
+
if (notesDeferred) {
|
|
2094
|
+
ok(`release notes will be ${assistant ? 'drafted' : 'generated'} after the commit`)
|
|
1789
2095
|
} else {
|
|
1790
|
-
draftedNotes =
|
|
1791
|
-
if (
|
|
1792
|
-
ok(`${config.changelog}: a ${version} section will be drafted after the commit`)
|
|
1793
|
-
} else if (draftedNotes) {
|
|
2096
|
+
draftedNotes = draftNotesFor(version)
|
|
2097
|
+
if (draftedNotes) {
|
|
1794
2098
|
notes = draftedNotes
|
|
1795
|
-
ok(
|
|
1796
|
-
|
|
2099
|
+
ok(
|
|
2100
|
+
notesSource === 'commits' || !assistant
|
|
2101
|
+
? 'release notes built from the commit log'
|
|
2102
|
+
: `release notes drafted by ${assistantName}`,
|
|
2103
|
+
)
|
|
2104
|
+
} else if (notesSource === 'auto') {
|
|
1797
2105
|
warn(
|
|
1798
|
-
|
|
2106
|
+
`no ${config.changelog ?? 'changelog'} section and nothing to draft from — GitHub will generate the notes`,
|
|
1799
2107
|
)
|
|
2108
|
+
} else {
|
|
2109
|
+
fail(`notes are set to come from "${notesSource}", which produced nothing`)
|
|
1800
2110
|
}
|
|
1801
2111
|
}
|
|
1802
2112
|
}
|
|
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
2113
|
}
|
|
1811
2114
|
|
|
1812
2115
|
for (const asset of config.assets) {
|
|
@@ -1826,6 +2129,19 @@ if (taggedCommit && runs('tag') && (bumping || rolledChangelog)) {
|
|
|
1826
2129
|
)
|
|
1827
2130
|
}
|
|
1828
2131
|
|
|
2132
|
+
// The project's own gate, run while nothing has mutated. Without this, a prepublishOnly
|
|
2133
|
+
// hook is the gate — and it fails at the publish step, after the commit, tag and push.
|
|
2134
|
+
if (config.verify) {
|
|
2135
|
+
note(`running verify: ${config.verify}`)
|
|
2136
|
+
try {
|
|
2137
|
+
execSync(config.verify, { stdio: 'pipe', encoding: 'utf8' })
|
|
2138
|
+
ok(`verify passed: ${config.verify}`)
|
|
2139
|
+
} catch (err) {
|
|
2140
|
+
const tail = `${err.stdout ?? ''}${err.stderr ?? ''}`.trim().split('\n').slice(-12).join('\n')
|
|
2141
|
+
fail(`verify failed: ${config.verify}\n${indent(tail)}`)
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
|
|
1829
2145
|
if (problems.length) {
|
|
1830
2146
|
const summary = `${problems.length} preflight check(s) failed:\n - ${problems.join('\n - ')}`
|
|
1831
2147
|
if (!dryRun) abort(summary)
|
|
@@ -1850,13 +2166,16 @@ if (dirty && runs('commit') && !dryRun) {
|
|
|
1850
2166
|
step('Stage the working tree')
|
|
1851
2167
|
mutate('git', ['add', '--all'])
|
|
1852
2168
|
didStage = true
|
|
1853
|
-
commitMessage = draftCommitMessage()
|
|
2169
|
+
commitMessage = assistant ? draftCommitMessage() : null
|
|
1854
2170
|
if (!commitMessage) {
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
2171
|
+
// No assistant, or its draft was unusable: never block on a text generator. The
|
|
2172
|
+
// deterministic floor is a chore commit that names what it touches.
|
|
2173
|
+
if (assistant) {
|
|
2174
|
+
note(`${assistantName} produced no usable message — falling back to a generated one`)
|
|
2175
|
+
}
|
|
2176
|
+
const files =
|
|
2177
|
+
tryRead('git', ['diff', '--cached', '--name-only'])?.split('\n').filter(Boolean) ?? []
|
|
2178
|
+
commitMessage = fallbackCommitMessage(files)
|
|
1860
2179
|
}
|
|
1861
2180
|
console.log(indent(commitMessage))
|
|
1862
2181
|
}
|