@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.
Files changed (5) hide show
  1. package/README.md +112 -38
  2. package/TRAIN.md +332 -0
  3. package/package.json +5 -2
  4. package/release.mjs +406 -87
  5. 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
- /** Everything but `commit`, which is opt-in because it commits work you did not stage. */
98
- const DEFAULT_STEPS = STEPS.filter((name) => name !== 'commit')
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 add the opt-in commit step: commit a dirty working tree with a
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('RELEASE ABORTED'))} — ${message}\n`)
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
- .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
- )
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, 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.
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', 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 },
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
- for (const { type, section, hidden } of CHANGELOG_SECTIONS) {
527
- if (hidden) continue
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((c) => !known.has(c.type) && c.type !== 'feature')
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
- const CONVENTIONAL_TYPES = 'build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test'
606
- const CONVENTIONAL_RE = new RegExp(`^(${CONVENTIONAL_TYPES})(\\([^)]+\\))?!?: .+`)
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 ${CONVENTIONAL_TYPES.split('|').join(', ')}.`,
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
- return CONVENTIONAL_RE.test(subject) ? message : null
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() + '\n', version, date, body)
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 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]
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
- return undefined
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 = existsSync('release.config.json') ? readJson('release.config.json') : {}
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 adds the opt-in step. Unknown names are an error rather than a silent no-op.
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 (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) {
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) hides the history that release notes
1596
- // and the last-tag lookup are derived from. It still releases correctly; the notes just
1597
- // silently describe a fraction of the work, so say so before that happens.
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
- // Notes: the changelog section for this version, else a draft, else GitHub generates them.
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') && assistant)
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
- if (!assistant) return changelogFromCommits(commits, remoteLinks(config.remote))
1765
- if (shallow) {
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
- 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))
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
- reportChangelogOrder(text)
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 = notesDeferred ? null : draftNotesFor(version)
1791
- if (notesDeferred) {
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(`${config.changelog}: a ${version} section will be drafted by ${assistantName}`)
1796
- } else {
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
- `${config.changelog} has no ${version} or [Unreleased] section — GitHub will generate the notes`,
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
- mutate('git', ['reset', '--quiet'])
1856
- abort(
1857
- `${assistantName} could not draft a Conventional Commits message for these changes.\n\n` +
1858
- ' Commit them yourself and re-run, or run without --commit.',
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
  }