@entro314labs/release-kit 2.3.1 → 2.3.3

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 (3) hide show
  1. package/README.md +90 -2
  2. package/package.json +1 -1
  3. package/release.mjs +228 -35
package/README.md CHANGED
@@ -55,6 +55,7 @@ Released v2.5.0
55
55
  | [📦 Install](#-install) | package, `npx`, or vendored file |
56
56
  | [⚡ Usage](#-usage) | targets, bumps, flags |
57
57
  | [🧩 Steps](#-steps) | the seven steps and how to select them |
58
+ | [📚 Libraries versus apps](#-libraries-versus-apps) | which steps you want, and why |
58
59
  | [🤖 Assistant](#-assistant-optional) | optional AI drafting |
59
60
  | [🌍 Any language](#-any-language) | Rust, Python, tag-only, anything |
60
61
  | [✅ Preflight](#-preflight) | what is checked before anything mutates |
@@ -231,8 +232,11 @@ Notes resolve in this order:
231
232
  2. The `## [Unreleased]` section, if the version has no section of its own — this is the
232
233
  same content that step 2 above is about to promote.
233
234
  3. The commits grouped by Conventional Commit type — Features, Bug Fixes, Performance
234
- Improvements, Reverts, with breaking changes first and chores, CI and docs hidden. This
235
- is deterministic and needs nothing installed, so decent notes are the default rather
235
+ Improvements, Reverts, with breaking changes first and chores, CI and docs hidden. Each
236
+ bullet links to its commit, and `closes #12` / `fixes #34` in a message becomes a link to
237
+ the issue. A `BREAKING CHANGE:` footer is used in place of the subject, since it explains
238
+ the break. A commit reverted within the same release drops out along with its revert.
239
+ All deterministic and needing nothing installed, so decent notes are the default rather
236
240
  than something that requires an assistant.
237
241
  4. Otherwise GitHub generates them from the commits since the previous tag.
238
242
 
@@ -323,6 +327,11 @@ Anything else runs as written with no preflight. The project name comes from the
323
327
  `name` in `package.json`, `Cargo.toml` or `pyproject.toml`, `module` in `go.mod` — falling
324
328
  back to the repository directory.
325
329
 
330
+ `publish` is detected too, but only where one ecosystem obviously owns it: `package.json`
331
+ gets `npm publish`, `Cargo.toml` gets `cargo publish`. Python has several publishers (uv,
332
+ twine, poetry, flit) and Go has none, so those get nothing rather than a guess — publishing
333
+ to the wrong registry is a far worse failure than being asked to configure it.
334
+
326
335
  With no `versionFile` configured it is detected from the repository — `package.json`,
327
336
  `pyproject.toml`, `Cargo.toml`, then `VERSION` — so most projects need no config for it at
328
337
  all. Set it explicitly to override, or to `null` for a repository that versions by tag.
@@ -528,6 +537,80 @@ One row in `ASSISTANTS` in `release.mjs`: the command, the args that make it rea
528
537
  stdin, and how it spells model and effort. Tools whose stdout carries session scaffolding
529
538
  declare `outputFile` and the answer is read from there instead.
530
539
 
540
+ ## 📚 Libraries versus apps
541
+
542
+ Publishing splits in two, and which one you are decides the `steps` you want.
543
+
544
+ **A library, package or tool publishes its source.** The registry receives what is already
545
+ in the repository — `npm publish`, `cargo publish`, `uv publish` — and there is nothing to
546
+ build first. release-kit does the whole thing:
547
+
548
+ ```json
549
+ {}
550
+ ```
551
+
552
+ Defaults are already correct: version → changelog → tag → push → publish → release.
553
+
554
+ **An app has to be built before anything can be published.** Binaries, installers, bundles,
555
+ container images: the artifact does not exist until something makes it. That build belongs
556
+ to a build tool, and it changes where release-kit stops.
557
+
558
+ ### Apps with a simple build
559
+
560
+ If the build runs before the release and leaves files on disk, release-kit can attach them
561
+ itself. Nothing else is needed:
562
+
563
+ ```json
564
+ { "assets": ["dist/app-macos.zip", "dist/app-linux.tar.gz"], "publish": null }
565
+ ```
566
+
567
+ Preflight fails if a listed asset is missing, so a release cannot quietly ship without its
568
+ binaries.
569
+
570
+ ### Apps with a real build pipeline
571
+
572
+ goreleaser, cargo-dist and electron-builder build for many targets and create the GitHub
573
+ release themselves, with the artifacts attached. That is their job. release-kit's work ends
574
+ at the pushed tag:
575
+
576
+ ```json
577
+ { "steps": ["version", "changelog", "tag", "push"], "notesFile": "dist-notes.md" }
578
+ ```
579
+
580
+ Nothing after `push` — no `publish`, no `release`. The tag push is the handoff, and it is
581
+ what triggers the build workflow:
582
+
583
+ ```yaml
584
+ on:
585
+ push:
586
+ tags: ['v*']
587
+
588
+ jobs:
589
+ build:
590
+ steps:
591
+ - uses: actions/checkout@v5
592
+ with: { fetch-depth: 0 }
593
+ - run: goreleaser release --clean --release-notes dist-notes.md
594
+ ```
595
+
596
+ **Do not leave `release` in `steps` here.** goreleaser creates the GitHub release itself; if
597
+ release-kit has already created one for that tag, goreleaser fails. Exactly one of them
598
+ should own it, and it should be the one attaching the binaries.
599
+
600
+ `notesFile` exists for this handoff: goreleaser's `--release-notes` takes a file and skips
601
+ its own changelog generation. The notes are also in the annotated tag, but reading them back
602
+ with `git tag --format='%(contents)'` embeds the signature when tags are signed, which then
603
+ appears in your published release notes. `notesFile` writes the text itself.
604
+
605
+ ### Both at once
606
+
607
+ A project can be both — a Rust crate that also ships binaries, say. Publish the library from
608
+ release-kit and let the build tool handle the binaries and the release:
609
+
610
+ ```json
611
+ { "publish": "cargo publish", "steps": ["version", "changelog", "tag", "push", "publish"] }
612
+ ```
613
+
531
614
  ## 🔄 Keeping vendored copies in sync
532
615
 
533
616
  Installed as a dependency, updates come from your package manager and there is nothing to
@@ -552,6 +635,11 @@ including a directory that is not a repository.
552
635
  - Whatever the `publish` command needs — for the default, a live `npm login` session
553
636
  (two hours) or an OIDC trusted-publishing environment
554
637
 
638
+ ## 🗺 Roadmap
639
+
640
+ Known defects, missing infrastructure, and the ideas that were considered and declined —
641
+ with the reasoning — are in [ROADMAP.md](ROADMAP.md).
642
+
555
643
  ## 🤝 Contributing
556
644
 
557
645
  The tool releases itself, so a change ships the same way it would in any consuming project:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entro314labs/release-kit",
3
- "version": "2.3.1",
3
+ "version": "2.3.3",
4
4
  "description": "Single-file, zero-dependency release mechanism for JS/TS/Node projects: version bump, changelog roll, commit, annotated tag, push, publish, GitHub release",
5
5
  "keywords": [
6
6
  "changelog",
package/release.mjs CHANGED
@@ -62,10 +62,13 @@ import { createInterface } from 'node:readline/promises'
62
62
  * the repository when unset; null when it versions by tag alone
63
63
  * versionFiles array further files whose version is kept in sync; each is a path
64
64
  * or { path, pattern }
65
- * publish string publish command; null to skip publishing entirely
65
+ * publish string publish command. Detected from the version source when unset,
66
+ * and only where it is unambiguous; null to publish nothing
66
67
  * commitMessage string release commit subject
67
68
  * releaseTitle string GitHub release title
68
69
  * assets string[] files attached to the GitHub release
70
+ * notesFile string write the resolved release notes here, for a build tool that
71
+ * takes them as a file (goreleaser --release-notes, and similar)
69
72
  * versioning string how `auto` derives a bump: "conventional", or
70
73
  * always-patch / always-minor / always-major to never infer
71
74
  * assistant string|object drafting CLI for commit messages and notes. A key of
@@ -102,11 +105,12 @@ const DEFAULTS = {
102
105
  changelog: 'CHANGELOG.md',
103
106
  versionFile: undefined,
104
107
  versionFiles: [],
105
- publish: 'npm publish --tag %d',
108
+ publish: undefined,
106
109
  commitMessage: 'chore(release): %t',
107
110
  releaseTitle: '%t',
108
111
  assets: [],
109
112
  assistant: null,
113
+ notesFile: null,
110
114
  versioning: 'conventional',
111
115
  }
112
116
 
@@ -148,6 +152,7 @@ Flags:
148
152
  --dist-tag <name> override the npm dist-tag (default: derived from the version)
149
153
  --dry-run print every step and execute nothing
150
154
  --yes, -y skip the confirmation prompt
155
+ --notes-file <path> write the resolved release notes to a file for the next tool
151
156
  --assistant <name> drafting CLI to use: auto, none, claude, codex
152
157
  --assistant-model <name>
153
158
  model the assistant runs with (e.g. sonnet, opus)
@@ -381,14 +386,16 @@ function commitsSinceLastTag() {
381
386
  const range = lastTag ? `${lastTag}..HEAD` : 'HEAD'
382
387
  // %B is the whole message: bodies carry BREAKING CHANGE and Release-As footers. A record
383
388
  // separator keeps multi-line messages parseable when splitting the log back apart.
384
- const raw = tryRead('git', ['log', `--format=%B%x1e`, range]) ?? ''
389
+ // %h first, then the message: the hash is what links each bullet back to its commit.
390
+ const raw = tryRead('git', ['log', `--format=%h%x1f%B%x1e`, range]) ?? ''
385
391
  const commits = raw
386
- .split('\u001e')
392
+ .split('\u001E')
387
393
  .map((entry) => entry.trim())
388
394
  .filter(Boolean)
389
395
  .map((entry) => {
390
- const [subject, ...rest] = entry.split('\n')
391
- return { subject: subject.trim(), body: rest.join('\n').trim() }
396
+ const [hash, message = ''] = entry.split('\u001F')
397
+ const [subject, ...rest] = message.split('\n')
398
+ return { hash: hash.trim(), subject: subject.trim(), body: rest.join('\n').trim() }
392
399
  })
393
400
  .filter(
394
401
  ({ subject }) =>
@@ -398,7 +405,8 @@ function commitsSinceLastTag() {
398
405
  !/^wip\b/i.test(subject) &&
399
406
  !/^(fixup|squash)!/.test(subject),
400
407
  )
401
- return { lastTag, commits, subjects: commits.map((c) => c.subject) }
408
+ const kept = withoutRevertedCommits(commits)
409
+ return { lastTag, commits: kept, subjects: kept.map((c) => c.subject) }
402
410
  }
403
411
 
404
412
  /**
@@ -426,16 +434,31 @@ const CHANGELOG_SECTIONS = [
426
434
  * @returns {{type: string, scope: string|null, breaking: boolean, subject: string,
427
435
  * releaseAs: string|null} | null} null when the subject is not Conventional Commits
428
436
  */
429
- function parseCommit(subject, body = '') {
437
+ function parseCommit(subject, body = '', hash = '') {
430
438
  const match = /^([a-z]+)(?:\(([^)]+)\))?(!)?: (.+)$/i.exec(subject)
431
439
  if (!match) return null
432
440
  const [, type, scope, bang, text] = match
441
+ const shortHash = hash.slice(0, 7)
433
442
  // A breaking change is marked either by `!` in the header or a BREAKING CHANGE footer.
434
443
  const breaking = !!bang || /^BREAKING[ -]CHANGE:/m.test(body)
435
444
  // `Release-As: 1.2.3` in a commit body pins the next version, so the decision can live
436
445
  // in git history rather than on the command line.
437
446
  const releaseAs = /^Release-As:\s*v?(\S+)/im.exec(body)?.[1] ?? null
438
- return { type: type.toLowerCase(), scope: scope ?? null, breaking, subject: text, releaseAs }
447
+ // Issues this commit closes, so the notes can link them the way a reader expects.
448
+ const closes = [...`${subject}\n${body}`.matchAll(CLOSES)].map((m) => m[1])
449
+ // A BREAKING CHANGE footer usually explains the break far better than the subject does.
450
+ const breakingNote =
451
+ /^BREAKING[ -]CHANGE:\s*([\s\S]+?)(?=\n\n|$)/m.exec(body)?.[1]?.trim() ?? null
452
+ return {
453
+ type: type.toLowerCase(),
454
+ scope: scope ?? null,
455
+ breaking,
456
+ subject: text,
457
+ hash: shortHash,
458
+ releaseAs,
459
+ closes: [...new Set(closes)],
460
+ breakingNote,
461
+ }
439
462
  }
440
463
 
441
464
  /**
@@ -448,7 +471,7 @@ function parseCommit(subject, body = '') {
448
471
  * @returns {{bump: string, breaking: string[], features: string[], releaseAs: string|null}}
449
472
  */
450
473
  function inferBump(commits, currentVersion, strategy = 'conventional') {
451
- const parsed = commits.map((c) => parseCommit(c.subject, c.body)).filter(Boolean)
474
+ const parsed = commits.map((c) => parseCommit(c.subject, c.body, c.hash)).filter(Boolean)
452
475
  const releaseAs = parsed.find((c) => c.releaseAs)?.releaseAs ?? null
453
476
  const breaking = parsed.filter((c) => c.breaking).map((c) => c.subject)
454
477
  const features = parsed
@@ -472,14 +495,27 @@ function inferBump(commits, currentVersion, strategy = 'conventional') {
472
495
  *
473
496
  * @returns {string | null} markdown body, or null when nothing visible changed
474
497
  */
475
- function changelogFromCommits(commits) {
476
- const parsed = commits.map((c) => parseCommit(c.subject, c.body)).filter(Boolean)
498
+ function changelogFromCommits(commits, links = null) {
499
+ const parsed = commits.map((c) => parseCommit(c.subject, c.body, c.hash)).filter(Boolean)
477
500
  const lines = []
478
501
 
502
+ /** One bullet: scope, text, a link to the commit, and any issues it closes. */
503
+ const bullet = (commit, text) => {
504
+ const scope = commit.scope ? `**${commit.scope}:** ` : ''
505
+ const parts = []
506
+ if (links && commit.hash) parts.push(`([${commit.hash}](${links.commit}/${commit.hash}))`)
507
+ if (commit.closes.length) {
508
+ const issues = commit.closes.map((n) => (links ? `[#${n}](${links.issue}/${n})` : `#${n}`))
509
+ parts.push(`closes ${issues.join(', ')}`)
510
+ }
511
+ return `- ${scope}${text}${parts.length ? ` ${parts.join(', ')}` : ''}`
512
+ }
513
+
479
514
  const breaking = parsed.filter((c) => c.breaking)
480
515
  if (breaking.length) {
481
516
  lines.push('### ⚠ BREAKING CHANGES', '')
482
- for (const c of breaking) lines.push(`- ${c.scope ? `**${c.scope}:** ` : ''}${c.subject}`)
517
+ // The footer explains the break; the subject only says what changed.
518
+ for (const c of breaking) lines.push(bullet(c, c.breakingNote ?? c.subject))
483
519
  lines.push('')
484
520
  }
485
521
 
@@ -490,13 +526,68 @@ function changelogFromCommits(commits) {
490
526
  )
491
527
  if (!inSection.length) continue
492
528
  lines.push(`### ${section}`, '')
493
- for (const c of inSection) lines.push(`- ${c.scope ? `**${c.scope}:** ` : ''}${c.subject}`)
529
+ for (const c of inSection) lines.push(bullet(c, c.subject))
494
530
  lines.push('')
495
531
  }
496
532
 
497
533
  return lines.length ? lines.join('\n').trim() : null
498
534
  }
499
535
 
536
+ /**
537
+ * Per-forge URL shapes and the words that close an issue, following
538
+ * @semantic-release/release-notes-generator's hosts-config. The path segments genuinely
539
+ * differ: Bitbucket uses /issue/ and /commits/ where GitHub uses /issues/ and /commit/.
540
+ */
541
+ const HOSTS = {
542
+ 'github.com': { issue: 'issues', commit: 'commit' },
543
+ 'gitlab.com': { issue: 'issues', commit: 'commit' },
544
+ 'bitbucket.org': { issue: 'issue', commit: 'commits' },
545
+ }
546
+ const DEFAULT_HOST = { issue: 'issues', commit: 'commit' }
547
+
548
+ /** Words that mark an issue reference as closed by the commit. */
549
+ const CLOSES = /\b(?:close[sd]?|closing|fix(?:e[sd])?|fixing|resolve[sd]?|resolving)\s+#(\d+)/gi
550
+
551
+ /**
552
+ * The repository behind `origin`, for building links.
553
+ *
554
+ * Handles both URL forms git uses: `https://host/owner/repo.git` and the SCP-like
555
+ * `git@host:owner/repo.git`, which is not a URL and does not parse as one.
556
+ *
557
+ * @returns {{base: string, issue: string, commit: string} | null}
558
+ */
559
+ function remoteLinks(remote) {
560
+ const url = tryRead('git', ['remote', 'get-url', remote])
561
+ if (!url) return null
562
+ const scp = /^(?:[^@]+@)?([^:/]+):(.+?)(?:\.git)?$/.exec(url.replace(/^ssh:\/\//, ''))
563
+ const web = /^[a-z+]+:\/\/(?:[^@]+@)?([^/]+)\/(.+?)(?:\.git)?$/i.exec(url)
564
+ const [, host, path] = web ?? scp ?? []
565
+ if (!host || !path) return null
566
+ const shape = HOSTS[host.toLowerCase()] ?? DEFAULT_HOST
567
+ return {
568
+ base: `https://${host}/${path}`,
569
+ issue: `https://${host}/${path}/${shape.issue}`,
570
+ commit: `https://${host}/${path}/${shape.commit}`,
571
+ }
572
+ }
573
+
574
+ /**
575
+ * Drop commits that were reverted within the same release, and the reverts themselves —
576
+ * neither belongs in notes describing what changed. A `git revert` records the reverted
577
+ * hash in its body, which is what pairs them up.
578
+ */
579
+ function withoutRevertedCommits(commits) {
580
+ const reverted = new Set()
581
+ for (const { body } of commits) {
582
+ const match = /This reverts commit ([0-9a-f]{7,40})/i.exec(body ?? '')
583
+ if (match) reverted.add(match[1].slice(0, 7))
584
+ }
585
+ if (!reverted.size) return commits
586
+ return commits.filter(
587
+ (c) => !reverted.has((c.hash ?? '').slice(0, 7)) && !/This reverts commit/i.test(c.body ?? ''),
588
+ )
589
+ }
590
+
500
591
  const CONVENTIONAL_TYPES = 'build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test'
501
592
  const CONVENTIONAL_RE = new RegExp(`^(${CONVENTIONAL_TYPES})(\\([^)]+\\))?!?: .+`)
502
593
 
@@ -756,9 +847,19 @@ function changelogSection(text, version) {
756
847
  * @returns {string | null} the updated document, or null when there is nothing to roll
757
848
  */
758
849
  function rollUnreleased(text, version, date) {
850
+ // A heading for this version already exists — possibly with an empty body, which
851
+ // `changelogSection` reports as absent. Rolling again would duplicate the heading.
852
+ if (new RegExp(`^##\\s+\\[?v?${escapeRe(version)}\\]?(?![\\w.-])`, 'm').test(text)) return null
853
+
759
854
  const heading = /^##\s+\[?Unreleased\]?[^\n]*$/im
760
855
  const match = heading.exec(text)
761
856
  if (!match) return null
857
+
858
+ // An empty [Unreleased] has nothing to promote; rolling it produces an empty section.
859
+ const rest = text.slice(match.index + match[0].length)
860
+ const next = /^## /m.exec(rest)
861
+ const body = (next ? rest.slice(0, next.index) : rest).trim()
862
+ if (!body) return null
762
863
  const released = `## [Unreleased]\n\n## [${version}] - ${date}`
763
864
  return text.slice(0, match.index) + released + text.slice(match.index + match[0].length)
764
865
  }
@@ -891,6 +992,7 @@ const skippedSteps = option('--skip')
891
992
  const explicitDistTag = option('--dist-tag')
892
993
  const requestedPreid = option('--preid')
893
994
  const autoCommit = flag('--commit')
995
+ const requestedNotesFile = option('--notes-file')
894
996
  const requestedAssistant = option('--assistant')
895
997
  const requestedModel = option('--assistant-model')
896
998
  const requestedEffort = option('--assistant-effort')
@@ -920,8 +1022,10 @@ if (flag('--sync')) {
920
1022
  const projectRoot = resolve(target)
921
1023
  const destination = join(projectRoot, 'scripts', basename(self))
922
1024
  if (destination === self) continue
923
- if (!existsSync(join(projectRoot, 'package.json'))) {
924
- warn(`${target}: no package.json — skipped`)
1025
+ // Any directory can hold a vendored copy: a manifest is only needed to wire up a
1026
+ // script, which Rust, Python and Go projects do not have and do not need.
1027
+ if (!existsSync(projectRoot)) {
1028
+ warn(`${target}: no such directory — skipped`)
925
1029
  continue
926
1030
  }
927
1031
  const current = existsSync(destination) ? readFileSync(destination, 'utf8') : null
@@ -934,9 +1038,15 @@ if (flag('--sync')) {
934
1038
  writeFileSync(destination, source)
935
1039
  }
936
1040
  ok(`${target}: ${current === null ? 'installed' : 'updated'}${dryRun ? ' (dry run)' : ''}`)
937
- const scripts = readJson(join(projectRoot, 'package.json')).scripts ?? {}
938
- if (!scripts.release) {
939
- warn(`${target}: add "release": "node scripts/${basename(self)}" to package.json`)
1041
+
1042
+ const manifestPath = join(projectRoot, 'package.json')
1043
+ if (existsSync(manifestPath)) {
1044
+ const scripts = readJson(manifestPath).scripts ?? {}
1045
+ if (!scripts.release) {
1046
+ warn(`${target}: add "release": "node scripts/${basename(self)}" to package.json`)
1047
+ }
1048
+ } else {
1049
+ note(`${target}: run it with \`node scripts/${basename(self)}\``)
940
1050
  }
941
1051
  }
942
1052
  process.exit(0)
@@ -951,6 +1061,7 @@ const VALUE_OPTIONS = new Set([
951
1061
  '--skip',
952
1062
  '--preid',
953
1063
  '--dist-tag',
1064
+ '--notes-file',
954
1065
  '--assistant',
955
1066
  '--assistant-model',
956
1067
  '--assistant-effort',
@@ -1026,6 +1137,17 @@ function detectVersionFile() {
1026
1137
  return null
1027
1138
  }
1028
1139
 
1140
+ /**
1141
+ * The publish command implied by a project's manifest, but only where one ecosystem
1142
+ * obviously owns it. Python has several publishers (uv, twine, poetry, flit) and Go has
1143
+ * none, so those get nothing rather than a guess — publishing to the wrong registry is a
1144
+ * far worse failure than being asked to configure it.
1145
+ */
1146
+ const PUBLISH_BY_MANIFEST = {
1147
+ 'package.json': 'npm publish --tag %d',
1148
+ 'Cargo.toml': 'cargo publish',
1149
+ }
1150
+
1029
1151
  // An explicit `versionFile: null` means "versions by tag" and must not be re-detected, so
1030
1152
  // the distinction is between the key being absent and the key being set to null.
1031
1153
  const configuredVersionFile = Object.hasOwn(userConfig, 'versionFile')
@@ -1060,6 +1182,15 @@ const goModule = existsSync('go.mod')
1060
1182
  const projectName =
1061
1183
  manifest?.name ?? (versionFile ? readNameFrom(versionFile) : null) ?? goModule ?? basename(root)
1062
1184
 
1185
+ // Same rule as versionFile: an explicit `publish: null` means "publish nothing" and is
1186
+ // never re-detected. Unset means "work it out", and working it out can yield nothing.
1187
+ if (!Object.hasOwn(userConfig, 'publish')) {
1188
+ // Preflight already reports "no publish command configured" when the step runs, so
1189
+ // there is nothing to say here — and `runs` is not resolved this early.
1190
+ config.publish =
1191
+ (versionFile ? PUBLISH_BY_MANIFEST[basename(versionFile.path)] : undefined) ?? null
1192
+ }
1193
+
1063
1194
  // Validate every name that was asked for, not just the ones that survive: a typo in
1064
1195
  // --skip would otherwise delete nothing and silently run the step you meant to drop.
1065
1196
  const requestedStepNames = [
@@ -1285,8 +1416,24 @@ if (bumping && compareVersions(version, currentVersion) <= 0) {
1285
1416
  const dirty = tryRead('git', ['status', '--porcelain'])
1286
1417
  if (dirty === null) fail('could not read git status')
1287
1418
  else if (dirty && runs('commit')) {
1288
- ok(`working tree has ${dirty.split('\n').length} change(s) — will be committed first`)
1419
+ const entries = dirty.split('\n')
1420
+ ok(`working tree has ${entries.length} change(s) — will be committed first`)
1289
1421
  console.log(dim(indent(formatStatus(dirty))))
1422
+ // One commit gets one subject. A change set spanning several top-level directories is
1423
+ // usually several pieces of work, and no honest Conventional Commits subject covers it.
1424
+ const areas = new Set(
1425
+ entries.map(
1426
+ (entry) => entry.trim().split(/\s+/).slice(1).join(' ').replaceAll('"', '').split('/')[0],
1427
+ ),
1428
+ )
1429
+ if (areas.size > 2) {
1430
+ warn(
1431
+ `these span ${areas.size} top-level paths (${[...areas].slice(0, 4).join(', ')}${areas.size > 4 ? ', …' : ''}), ` +
1432
+ 'which is usually more than one piece of work.\n' +
1433
+ ' One commit gets one subject: consider committing them yourself, then ' +
1434
+ 'releasing with --skip commit.',
1435
+ )
1436
+ }
1290
1437
  } else if (dirty) {
1291
1438
  fail(`working tree is not clean:\n${indent(formatStatus(dirty))}`)
1292
1439
  } else ok('working tree clean')
@@ -1467,7 +1614,7 @@ const notesDeferred = !!(dirty && runs('commit') && assistant)
1467
1614
  function draftNotesFor(v) {
1468
1615
  const { lastTag, subjects, commits } = commitsSinceLastTag()
1469
1616
  if (!commits.length) return null
1470
- if (!assistant) return changelogFromCommits(commits)
1617
+ if (!assistant) return changelogFromCommits(commits, remoteLinks(config.remote))
1471
1618
  if (shallow) {
1472
1619
  warn(
1473
1620
  `shallow clone: only ${subjects.length} commit(s) are visible, so the notes will ` +
@@ -1475,7 +1622,10 @@ function draftNotesFor(v) {
1475
1622
  )
1476
1623
  }
1477
1624
  note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
1478
- return draftReleaseNotes(v, subjects, lastTag) ?? changelogFromCommits(commits)
1625
+ return (
1626
+ draftReleaseNotes(v, subjects, lastTag) ??
1627
+ changelogFromCommits(commits, remoteLinks(config.remote))
1628
+ )
1479
1629
  }
1480
1630
  if (config.changelog && existsSync(config.changelog)) {
1481
1631
  const text = readFileSync(config.changelog, 'utf8')
@@ -1515,6 +1665,18 @@ for (const asset of config.assets) {
1515
1665
  else fail(`asset ${asset} does not exist`)
1516
1666
  }
1517
1667
 
1668
+ // Reusing a tag is the resume path, and a resume writes nothing. If this run would still
1669
+ // produce a commit, that commit moves HEAD past the tag and the release ends up tagged at
1670
+ // the wrong revision — which is silent until someone checks out the tag.
1671
+ if (taggedCommit && runs('tag') && (bumping || rolledChangelog)) {
1672
+ fail(
1673
+ `tag ${tag} already exists at HEAD, but this run would still commit ` +
1674
+ `${[bumping && 'a version bump', rolledChangelog && 'a changelog entry'].filter(Boolean).join(' and ')}.\n` +
1675
+ ' That commit would leave the tag behind HEAD. Release a new version, or use ' +
1676
+ '--only with the steps that remain.',
1677
+ )
1678
+ }
1679
+
1518
1680
  if (problems.length) {
1519
1681
  const summary = `${problems.length} preflight check(s) failed:\n - ${problems.join('\n - ')}`
1520
1682
  if (!dryRun) abort(summary)
@@ -1527,6 +1689,29 @@ if (problems.length) {
1527
1689
  // CONFIRM
1528
1690
  // ─────────────────────────────────────────────────────────────────────────────
1529
1691
 
1692
+ /**
1693
+ * Stage the working tree and draft its commit message before the confirmation prompt, so
1694
+ * what gets approved is the message that will actually be written. Staging is the first
1695
+ * mutation, and it is undone if the release is declined — `git add` touches only the index,
1696
+ * never the working tree, so restoring it is exact.
1697
+ */
1698
+ let commitMessage = null
1699
+ let didStage = false
1700
+ if (dirty && runs('commit') && !dryRun) {
1701
+ step('Stage the working tree')
1702
+ mutate('git', ['add', '--all'])
1703
+ didStage = true
1704
+ commitMessage = draftCommitMessage()
1705
+ if (!commitMessage) {
1706
+ mutate('git', ['reset', '--quiet'])
1707
+ abort(
1708
+ `${assistantName} could not draft a Conventional Commits message for these changes.\n\n` +
1709
+ ' Commit them yourself and re-run, or run without --commit.',
1710
+ )
1711
+ }
1712
+ console.log(indent(commitMessage))
1713
+ }
1714
+
1530
1715
  if (!assumeYes && !dryRun && process.stdin.isTTY) {
1531
1716
  if (notes) console.log(`\n${bold('Release notes')}\n${indent(notes)}`)
1532
1717
  const rl = createInterface({ input: process.stdin, output: process.stdout })
@@ -1539,7 +1724,11 @@ if (!assumeYes && !dryRun && process.stdin.isTTY) {
1539
1724
  } finally {
1540
1725
  rl.close()
1541
1726
  }
1542
- if (!/^y(es)?$/i.test(answer.trim())) abort('cancelled')
1727
+ if (!/^y(es)?$/i.test(answer.trim())) {
1728
+ // Leave the index exactly as it was found.
1729
+ if (didStage) mutate('git', ['reset', '--quiet'])
1730
+ abort('cancelled')
1731
+ }
1543
1732
  }
1544
1733
 
1545
1734
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1550,18 +1739,8 @@ const staged = []
1550
1739
 
1551
1740
  if (dirty && runs('commit')) {
1552
1741
  step('Commit the working tree')
1553
- mutate('git', ['add', '--all'])
1554
- // Draft after staging: the message describes what is staged, not what happens to be
1555
- // in the tree. Under --dry-run nothing was staged, so there is nothing to describe.
1556
- const message = dryRun ? null : draftCommitMessage()
1557
- if (!message && !dryRun) {
1558
- abort(
1559
- `${assistantName} could not draft a Conventional Commits message for the staged changes.\n\n` +
1560
- ' Commit them yourself and re-run, or run without --commit.',
1561
- )
1562
- }
1563
- console.log(indent(message ?? '<drafted at run time>'))
1564
- mutate('git', ['commit', '-m', message ?? 'chore: working tree'])
1742
+ if (dryRun) console.log(` ${yellow('would run:')} git commit -m <drafted at run time>`)
1743
+ else mutate('git', ['commit', '-m', commitMessage])
1565
1744
 
1566
1745
  // Now that the commit exists it is part of the release, so the notes can describe it.
1567
1746
  if (notesDeferred && !dryRun) {
@@ -1615,6 +1794,20 @@ if (staged.length) {
1615
1794
  mutate('git', ['commit', '-m', expand(config.commitMessage)])
1616
1795
  }
1617
1796
 
1797
+ /**
1798
+ * Hand the notes to whatever builds and publishes next. goreleaser, cargo-dist and similar
1799
+ * take release notes as a file and then own the GitHub release themselves.
1800
+ *
1801
+ * The notes are also in the annotated tag, but reading them back with `%(contents)` embeds
1802
+ * the signature when tags are signed — this writes the text itself, with no such trap.
1803
+ */
1804
+ const notesFile = requestedNotesFile ?? config.notesFile
1805
+ if (notesFile) {
1806
+ step(`Write release notes to ${notesFile}`)
1807
+ if (dryRun) console.log(` ${yellow('would write')} ${notesFile}`)
1808
+ else writeFileSync(notesFile, `${notes ?? `${projectName} ${tag}`}\n`)
1809
+ }
1810
+
1618
1811
  if (runs('tag') && !taggedCommit) {
1619
1812
  step(`Annotated tag ${tag}`)
1620
1813
  // The notes become the tag annotation too, so a CI release workflow can read them