@entro314labs/release-kit 2.3.2 → 2.4.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 (3) hide show
  1. package/README.md +129 -2
  2. package/package.json +5 -3
  3. package/release.mjs +232 -39
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.
@@ -333,6 +342,11 @@ the version. Only the version itself is rewritten, so comments and formatting su
333
342
  because the TOML match is anchored to the start of a line, a dependency's
334
343
  `serde = { version = "1.0" }` is left alone.
335
344
 
345
+ **Lockfiles are scoped automatically.** A `Cargo.lock` records a version for every
346
+ dependency — hundreds of them — so matching the first `version = "…"` would rewrite an
347
+ unrelated crate. Listing one rewrites only the `[[package]]` block whose name matches the
348
+ crate in the sibling `Cargo.toml`; with no sibling to read, it refuses rather than guesses.
349
+
336
350
  For anything else, give a pattern with one capture group around the version. `versionFiles`
337
351
  takes the same entries, so several files stay in sync across formats:
338
352
 
@@ -343,6 +357,29 @@ takes the same entries, so several files stay in sync across formats:
343
357
  }
344
358
  ```
345
359
 
360
+ A desktop app usually carries the same version in a lot of places at once — a workspace
361
+ manifest, per-platform bundle configs, a crate manifest and its lockfile. They stay in step
362
+ in one release, across three formats, with no scripting:
363
+
364
+ ```json
365
+ {
366
+ "versionFiles": [
367
+ "apps/desktop/package.json",
368
+ "apps/desktop/src-tauri/tauri.conf.json",
369
+ "apps/desktop/src-tauri/tauri.macos.conf.json",
370
+ "apps/desktop/src-tauri/tauri.windows.conf.json",
371
+ "apps/desktop/src-tauri/tauri.linux.conf.json",
372
+ "apps/desktop/src-tauri/Cargo.toml",
373
+ "apps/desktop/src-tauri/Cargo.lock"
374
+ ],
375
+ "publish": null,
376
+ "steps": ["version", "changelog", "tag", "push"]
377
+ }
378
+ ```
379
+
380
+ Stopping at `push` because the tag is what triggers the build pipeline — see
381
+ [Libraries versus apps](#-libraries-versus-apps).
382
+
346
383
  The project name comes from the manifest when there is one (`name` in `package.json`,
347
384
  `Cargo.toml` or `pyproject.toml`), and falls back to the repository directory.
348
385
 
@@ -528,6 +565,80 @@ One row in `ASSISTANTS` in `release.mjs`: the command, the args that make it rea
528
565
  stdin, and how it spells model and effort. Tools whose stdout carries session scaffolding
529
566
  declare `outputFile` and the answer is read from there instead.
530
567
 
568
+ ## 📚 Libraries versus apps
569
+
570
+ Publishing splits in two, and which one you are decides the `steps` you want.
571
+
572
+ **A library, package or tool publishes its source.** The registry receives what is already
573
+ in the repository — `npm publish`, `cargo publish`, `uv publish` — and there is nothing to
574
+ build first. release-kit does the whole thing:
575
+
576
+ ```json
577
+ {}
578
+ ```
579
+
580
+ Defaults are already correct: version → changelog → tag → push → publish → release.
581
+
582
+ **An app has to be built before anything can be published.** Binaries, installers, bundles,
583
+ container images: the artifact does not exist until something makes it. That build belongs
584
+ to a build tool, and it changes where release-kit stops.
585
+
586
+ ### Apps with a simple build
587
+
588
+ If the build runs before the release and leaves files on disk, release-kit can attach them
589
+ itself. Nothing else is needed:
590
+
591
+ ```json
592
+ { "assets": ["dist/app-macos.zip", "dist/app-linux.tar.gz"], "publish": null }
593
+ ```
594
+
595
+ Preflight fails if a listed asset is missing, so a release cannot quietly ship without its
596
+ binaries.
597
+
598
+ ### Apps with a real build pipeline
599
+
600
+ goreleaser, cargo-dist and electron-builder build for many targets and create the GitHub
601
+ release themselves, with the artifacts attached. That is their job. release-kit's work ends
602
+ at the pushed tag:
603
+
604
+ ```json
605
+ { "steps": ["version", "changelog", "tag", "push"], "notesFile": "dist-notes.md" }
606
+ ```
607
+
608
+ Nothing after `push` — no `publish`, no `release`. The tag push is the handoff, and it is
609
+ what triggers the build workflow:
610
+
611
+ ```yaml
612
+ on:
613
+ push:
614
+ tags: ['v*']
615
+
616
+ jobs:
617
+ build:
618
+ steps:
619
+ - uses: actions/checkout@v5
620
+ with: { fetch-depth: 0 }
621
+ - run: goreleaser release --clean --release-notes dist-notes.md
622
+ ```
623
+
624
+ **Do not leave `release` in `steps` here.** goreleaser creates the GitHub release itself; if
625
+ release-kit has already created one for that tag, goreleaser fails. Exactly one of them
626
+ should own it, and it should be the one attaching the binaries.
627
+
628
+ `notesFile` exists for this handoff: goreleaser's `--release-notes` takes a file and skips
629
+ its own changelog generation. The notes are also in the annotated tag, but reading them back
630
+ with `git tag --format='%(contents)'` embeds the signature when tags are signed, which then
631
+ appears in your published release notes. `notesFile` writes the text itself.
632
+
633
+ ### Both at once
634
+
635
+ A project can be both — a Rust crate that also ships binaries, say. Publish the library from
636
+ release-kit and let the build tool handle the binaries and the release:
637
+
638
+ ```json
639
+ { "publish": "cargo publish", "steps": ["version", "changelog", "tag", "push", "publish"] }
640
+ ```
641
+
531
642
  ## 🔄 Keeping vendored copies in sync
532
643
 
533
644
  Installed as a dependency, updates come from your package manager and there is nothing to
@@ -552,8 +663,24 @@ including a directory that is not a repository.
552
663
  - Whatever the `publish` command needs — for the default, a live `npm login` session
553
664
  (two hours) or an OIDC trusted-publishing environment
554
665
 
666
+ ## 🗺 Roadmap
667
+
668
+ Known defects, missing infrastructure, and the ideas that were considered and declined —
669
+ with the reasoning — are in [ROADMAP.md](ROADMAP.md).
670
+
555
671
  ## 🤝 Contributing
556
672
 
673
+ ```sh
674
+ pnpm install
675
+ pnpm test # 63 tests, node --test, no framework
676
+ pnpm check # format + lint + tests, the same gate CI runs
677
+ ```
678
+
679
+ `test/` holds unit suites for the pure functions and an integration suite that builds real
680
+ throwaway repositories with a real bare remote and stubbed `gh`/`npm`. The integration tests
681
+ pin defects found in use, so a name like "refuses to reuse a tag while still producing a
682
+ commit" is describing something that actually happened.
683
+
557
684
  The tool releases itself, so a change ships the same way it would in any consuming project:
558
685
  add a `## [Unreleased]` entry to `CHANGELOG.md`, then run `pnpm release <bump>` from a clone.
559
686
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entro314labs/release-kit",
3
- "version": "2.3.2",
3
+ "version": "2.4.0",
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",
@@ -41,12 +41,14 @@
41
41
  "format:check": "oxfmt --check .",
42
42
  "lint": "oxlint .",
43
43
  "lint:ci": "oxlint --deny-warnings .",
44
- "check": "pnpm run format:check && pnpm run lint:ci",
44
+ "test": "node --test \"test/**/*.test.mjs\"",
45
+ "check": "pnpm run format:check && pnpm run lint:ci && pnpm run test",
45
46
  "release": "node release.mjs"
46
47
  },
47
48
  "devDependencies": {
48
49
  "oxfmt": "^0.63.0",
49
- "oxlint": "^1.78.0"
50
+ "oxlint": "^1.78.0",
51
+ "semver": "^7.8.5"
50
52
  },
51
53
  "engines": {
52
54
  "node": ">=22"
package/release.mjs CHANGED
@@ -41,7 +41,7 @@ import {
41
41
  writeFileSync,
42
42
  } from 'node:fs'
43
43
  import { homedir, tmpdir } from 'node:os'
44
- import { basename, join, relative, resolve, sep } from 'node:path'
44
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path'
45
45
  import { createInterface } from 'node:readline/promises'
46
46
 
47
47
  // ─────────────────────────────────────────────────────────────────────────────
@@ -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
 
@@ -766,7 +857,8 @@ function rollUnreleased(text, version, date) {
766
857
 
767
858
  // An empty [Unreleased] has nothing to promote; rolling it produces an empty section.
768
859
  const rest = text.slice(match.index + match[0].length)
769
- const body = (/^## /m.exec(rest) ? rest.slice(0, /^## /m.exec(rest).index) : rest).trim()
860
+ const next = /^## /m.exec(rest)
861
+ const body = (next ? rest.slice(0, next.index) : rest).trim()
770
862
  if (!body) return null
771
863
  const released = `## [Unreleased]\n\n## [${version}] - ${date}`
772
864
  return text.slice(0, match.index) + released + text.slice(match.index + match[0].length)
@@ -827,9 +919,29 @@ function readNameFrom(entry) {
827
919
  /** Normalise a versionFile / versionFiles entry to { path, pattern }. */
828
920
  const versionSource = (entry) => (typeof entry === 'string' ? { path: entry } : entry)
829
921
 
922
+ /**
923
+ * A lockfile records a version for every dependency — hundreds of them — so the first
924
+ * `version = "…"` in the file belongs to whichever crate sorts first, not to this project.
925
+ * Rewriting it corrupts an unrelated dependency, silently. Scope to the named package block.
926
+ */
927
+ function cargoLockPattern(lockPath) {
928
+ const sibling = join(dirname(lockPath), 'Cargo.toml')
929
+ const crate = existsSync(sibling) ? readNameFrom({ path: sibling }) : null
930
+ if (!crate) {
931
+ throw new Error(
932
+ `${lockPath} lists every dependency's version, so it needs to know which package is ` +
933
+ `yours.\n No Cargo.toml beside it to read the name from — give an explicit ` +
934
+ `pattern:\n { "path": "${lockPath}", "pattern": "name = \\"<crate>\\"\\nversion = ` +
935
+ `\\"(.+)\\"" }`,
936
+ )
937
+ }
938
+ return new RegExp(`\\[\\[package\\]\\]\\nname = "${escapeRe(crate)}"\\nversion = "([^"]*)"`)
939
+ }
940
+
830
941
  /** The regex for a source, or null when the whole file is the version. */
831
942
  function patternFor({ path, pattern }) {
832
943
  if (pattern) return new RegExp(pattern, 'm')
944
+ if (basename(path) === 'Cargo.lock') return cargoLockPattern(path)
833
945
  if (path.endsWith('.json')) return VERSION_PATTERNS.json
834
946
  if (path.endsWith('.toml')) return VERSION_PATTERNS.toml
835
947
  return null
@@ -849,9 +961,10 @@ function readVersionFrom(entry) {
849
961
  * Replace the version in a source file, touching nothing else: only the captured range is
850
962
  * rewritten, so formatting, key order and comments all survive.
851
963
  *
964
+ * @param {{dryRun?: boolean}} [options] report the change without making it
852
965
  * @returns {boolean} whether the file needed changing
853
966
  */
854
- function writeVersionInto(entry, version) {
967
+ function writeVersionInto(entry, version, { dryRun = false } = {}) {
855
968
  const source = versionSource(entry)
856
969
  const text = readFileSync(source.path, 'utf8')
857
970
  const pattern = patternFor(source)
@@ -900,6 +1013,7 @@ const skippedSteps = option('--skip')
900
1013
  const explicitDistTag = option('--dist-tag')
901
1014
  const requestedPreid = option('--preid')
902
1015
  const autoCommit = flag('--commit')
1016
+ const requestedNotesFile = option('--notes-file')
903
1017
  const requestedAssistant = option('--assistant')
904
1018
  const requestedModel = option('--assistant-model')
905
1019
  const requestedEffort = option('--assistant-effort')
@@ -929,8 +1043,10 @@ if (flag('--sync')) {
929
1043
  const projectRoot = resolve(target)
930
1044
  const destination = join(projectRoot, 'scripts', basename(self))
931
1045
  if (destination === self) continue
932
- if (!existsSync(join(projectRoot, 'package.json'))) {
933
- warn(`${target}: no package.json — skipped`)
1046
+ // Any directory can hold a vendored copy: a manifest is only needed to wire up a
1047
+ // script, which Rust, Python and Go projects do not have and do not need.
1048
+ if (!existsSync(projectRoot)) {
1049
+ warn(`${target}: no such directory — skipped`)
934
1050
  continue
935
1051
  }
936
1052
  const current = existsSync(destination) ? readFileSync(destination, 'utf8') : null
@@ -943,9 +1059,15 @@ if (flag('--sync')) {
943
1059
  writeFileSync(destination, source)
944
1060
  }
945
1061
  ok(`${target}: ${current === null ? 'installed' : 'updated'}${dryRun ? ' (dry run)' : ''}`)
946
- const scripts = readJson(join(projectRoot, 'package.json')).scripts ?? {}
947
- if (!scripts.release) {
948
- warn(`${target}: add "release": "node scripts/${basename(self)}" to package.json`)
1062
+
1063
+ const manifestPath = join(projectRoot, 'package.json')
1064
+ if (existsSync(manifestPath)) {
1065
+ const scripts = readJson(manifestPath).scripts ?? {}
1066
+ if (!scripts.release) {
1067
+ warn(`${target}: add "release": "node scripts/${basename(self)}" to package.json`)
1068
+ }
1069
+ } else {
1070
+ note(`${target}: run it with \`node scripts/${basename(self)}\``)
949
1071
  }
950
1072
  }
951
1073
  process.exit(0)
@@ -960,6 +1082,7 @@ const VALUE_OPTIONS = new Set([
960
1082
  '--skip',
961
1083
  '--preid',
962
1084
  '--dist-tag',
1085
+ '--notes-file',
963
1086
  '--assistant',
964
1087
  '--assistant-model',
965
1088
  '--assistant-effort',
@@ -1035,6 +1158,17 @@ function detectVersionFile() {
1035
1158
  return null
1036
1159
  }
1037
1160
 
1161
+ /**
1162
+ * The publish command implied by a project's manifest, but only where one ecosystem
1163
+ * obviously owns it. Python has several publishers (uv, twine, poetry, flit) and Go has
1164
+ * none, so those get nothing rather than a guess — publishing to the wrong registry is a
1165
+ * far worse failure than being asked to configure it.
1166
+ */
1167
+ const PUBLISH_BY_MANIFEST = {
1168
+ 'package.json': 'npm publish --tag %d',
1169
+ 'Cargo.toml': 'cargo publish',
1170
+ }
1171
+
1038
1172
  // An explicit `versionFile: null` means "versions by tag" and must not be re-detected, so
1039
1173
  // the distinction is between the key being absent and the key being set to null.
1040
1174
  const configuredVersionFile = Object.hasOwn(userConfig, 'versionFile')
@@ -1069,6 +1203,15 @@ const goModule = existsSync('go.mod')
1069
1203
  const projectName =
1070
1204
  manifest?.name ?? (versionFile ? readNameFrom(versionFile) : null) ?? goModule ?? basename(root)
1071
1205
 
1206
+ // Same rule as versionFile: an explicit `publish: null` means "publish nothing" and is
1207
+ // never re-detected. Unset means "work it out", and working it out can yield nothing.
1208
+ if (!Object.hasOwn(userConfig, 'publish')) {
1209
+ // Preflight already reports "no publish command configured" when the step runs, so
1210
+ // there is nothing to say here — and `runs` is not resolved this early.
1211
+ config.publish =
1212
+ (versionFile ? PUBLISH_BY_MANIFEST[basename(versionFile.path)] : undefined) ?? null
1213
+ }
1214
+
1072
1215
  // Validate every name that was asked for, not just the ones that survive: a typo in
1073
1216
  // --skip would otherwise delete nothing and silently run the step you meant to drop.
1074
1217
  const requestedStepNames = [
@@ -1294,8 +1437,24 @@ if (bumping && compareVersions(version, currentVersion) <= 0) {
1294
1437
  const dirty = tryRead('git', ['status', '--porcelain'])
1295
1438
  if (dirty === null) fail('could not read git status')
1296
1439
  else if (dirty && runs('commit')) {
1297
- ok(`working tree has ${dirty.split('\n').length} change(s) — will be committed first`)
1440
+ const entries = dirty.split('\n')
1441
+ ok(`working tree has ${entries.length} change(s) — will be committed first`)
1298
1442
  console.log(dim(indent(formatStatus(dirty))))
1443
+ // One commit gets one subject. A change set spanning several top-level directories is
1444
+ // usually several pieces of work, and no honest Conventional Commits subject covers it.
1445
+ const areas = new Set(
1446
+ entries.map(
1447
+ (entry) => entry.trim().split(/\s+/).slice(1).join(' ').replaceAll('"', '').split('/')[0],
1448
+ ),
1449
+ )
1450
+ if (areas.size > 2) {
1451
+ warn(
1452
+ `these span ${areas.size} top-level paths (${[...areas].slice(0, 4).join(', ')}${areas.size > 4 ? ', …' : ''}), ` +
1453
+ 'which is usually more than one piece of work.\n' +
1454
+ ' One commit gets one subject: consider committing them yourself, then ' +
1455
+ 'releasing with --skip commit.',
1456
+ )
1457
+ }
1299
1458
  } else if (dirty) {
1300
1459
  fail(`working tree is not clean:\n${indent(formatStatus(dirty))}`)
1301
1460
  } else ok('working tree clean')
@@ -1476,7 +1635,7 @@ const notesDeferred = !!(dirty && runs('commit') && assistant)
1476
1635
  function draftNotesFor(v) {
1477
1636
  const { lastTag, subjects, commits } = commitsSinceLastTag()
1478
1637
  if (!commits.length) return null
1479
- if (!assistant) return changelogFromCommits(commits)
1638
+ if (!assistant) return changelogFromCommits(commits, remoteLinks(config.remote))
1480
1639
  if (shallow) {
1481
1640
  warn(
1482
1641
  `shallow clone: only ${subjects.length} commit(s) are visible, so the notes will ` +
@@ -1484,7 +1643,10 @@ function draftNotesFor(v) {
1484
1643
  )
1485
1644
  }
1486
1645
  note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
1487
- return draftReleaseNotes(v, subjects, lastTag) ?? changelogFromCommits(commits)
1646
+ return (
1647
+ draftReleaseNotes(v, subjects, lastTag) ??
1648
+ changelogFromCommits(commits, remoteLinks(config.remote))
1649
+ )
1488
1650
  }
1489
1651
  if (config.changelog && existsSync(config.changelog)) {
1490
1652
  const text = readFileSync(config.changelog, 'utf8')
@@ -1548,6 +1710,29 @@ if (problems.length) {
1548
1710
  // CONFIRM
1549
1711
  // ─────────────────────────────────────────────────────────────────────────────
1550
1712
 
1713
+ /**
1714
+ * Stage the working tree and draft its commit message before the confirmation prompt, so
1715
+ * what gets approved is the message that will actually be written. Staging is the first
1716
+ * mutation, and it is undone if the release is declined — `git add` touches only the index,
1717
+ * never the working tree, so restoring it is exact.
1718
+ */
1719
+ let commitMessage = null
1720
+ let didStage = false
1721
+ if (dirty && runs('commit') && !dryRun) {
1722
+ step('Stage the working tree')
1723
+ mutate('git', ['add', '--all'])
1724
+ didStage = true
1725
+ commitMessage = draftCommitMessage()
1726
+ if (!commitMessage) {
1727
+ mutate('git', ['reset', '--quiet'])
1728
+ abort(
1729
+ `${assistantName} could not draft a Conventional Commits message for these changes.\n\n` +
1730
+ ' Commit them yourself and re-run, or run without --commit.',
1731
+ )
1732
+ }
1733
+ console.log(indent(commitMessage))
1734
+ }
1735
+
1551
1736
  if (!assumeYes && !dryRun && process.stdin.isTTY) {
1552
1737
  if (notes) console.log(`\n${bold('Release notes')}\n${indent(notes)}`)
1553
1738
  const rl = createInterface({ input: process.stdin, output: process.stdout })
@@ -1560,7 +1745,11 @@ if (!assumeYes && !dryRun && process.stdin.isTTY) {
1560
1745
  } finally {
1561
1746
  rl.close()
1562
1747
  }
1563
- if (!/^y(es)?$/i.test(answer.trim())) abort('cancelled')
1748
+ if (!/^y(es)?$/i.test(answer.trim())) {
1749
+ // Leave the index exactly as it was found.
1750
+ if (didStage) mutate('git', ['reset', '--quiet'])
1751
+ abort('cancelled')
1752
+ }
1564
1753
  }
1565
1754
 
1566
1755
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1571,18 +1760,8 @@ const staged = []
1571
1760
 
1572
1761
  if (dirty && runs('commit')) {
1573
1762
  step('Commit the working tree')
1574
- mutate('git', ['add', '--all'])
1575
- // Draft after staging: the message describes what is staged, not what happens to be
1576
- // in the tree. Under --dry-run nothing was staged, so there is nothing to describe.
1577
- const message = dryRun ? null : draftCommitMessage()
1578
- if (!message && !dryRun) {
1579
- abort(
1580
- `${assistantName} could not draft a Conventional Commits message for the staged changes.\n\n` +
1581
- ' Commit them yourself and re-run, or run without --commit.',
1582
- )
1583
- }
1584
- console.log(indent(message ?? '<drafted at run time>'))
1585
- mutate('git', ['commit', '-m', message ?? 'chore: working tree'])
1763
+ if (dryRun) console.log(` ${yellow('would run:')} git commit -m <drafted at run time>`)
1764
+ else mutate('git', ['commit', '-m', commitMessage])
1586
1765
 
1587
1766
  // Now that the commit exists it is part of the release, so the notes can describe it.
1588
1767
  if (notesDeferred && !dryRun) {
@@ -1596,7 +1775,7 @@ if (bumping) {
1596
1775
  for (const entry of [versionFile, ...config.versionFiles]) {
1597
1776
  const source = versionSource(entry)
1598
1777
  if (!existsSync(source.path)) abort(`versionFiles entry ${source.path} does not exist`)
1599
- if (writeVersionInto(source, version)) {
1778
+ if (writeVersionInto(source, version, { dryRun })) {
1600
1779
  staged.push(source.path)
1601
1780
  console.log(` ${dryRun ? yellow('would write') : dim('wrote')} ${source.path}`)
1602
1781
  }
@@ -1636,6 +1815,20 @@ if (staged.length) {
1636
1815
  mutate('git', ['commit', '-m', expand(config.commitMessage)])
1637
1816
  }
1638
1817
 
1818
+ /**
1819
+ * Hand the notes to whatever builds and publishes next. goreleaser, cargo-dist and similar
1820
+ * take release notes as a file and then own the GitHub release themselves.
1821
+ *
1822
+ * The notes are also in the annotated tag, but reading them back with `%(contents)` embeds
1823
+ * the signature when tags are signed — this writes the text itself, with no such trap.
1824
+ */
1825
+ const notesFile = requestedNotesFile ?? config.notesFile
1826
+ if (notesFile) {
1827
+ step(`Write release notes to ${notesFile}`)
1828
+ if (dryRun) console.log(` ${yellow('would write')} ${notesFile}`)
1829
+ else writeFileSync(notesFile, `${notes ?? `${projectName} ${tag}`}\n`)
1830
+ }
1831
+
1639
1832
  if (runs('tag') && !taggedCommit) {
1640
1833
  step(`Annotated tag ${tag}`)
1641
1834
  // The notes become the tag annotation too, so a CI release workflow can read them