@entro314labs/release-kit 2.8.0 → 2.9.1

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 +292 -50
  2. package/TRAIN.md +13 -0
  3. package/package.json +1 -1
  4. package/release.mjs +1257 -159
  5. package/train.mjs +18 -5
package/release.mjs CHANGED
@@ -24,7 +24,10 @@
24
24
  * - Every step is idempotent. A run interrupted partway through (a publish timeout, a
25
25
  * network failure) can be re-run: an already-written version, an existing tag at HEAD,
26
26
  * an already-published version and an existing release are each detected and skipped.
27
- * There is no cleanup step and no --resume flag.
27
+ * There is no cleanup step and no --resume flag. `auto` re-run that way finishes the
28
+ * unpublished release rather than reporting nothing to do, and once history has moved
29
+ * on past it, the version that does ship carries its commits — a tag is not a release,
30
+ * and work that never reached a registry is still unreleased.
28
31
  *
29
32
  * Configuration is optional. Defaults are the conventions (package.json version,
30
33
  * CHANGELOG.md, main branch, `v` tag prefix, npm publish); a release.config.json beside
@@ -37,6 +40,7 @@ import {
37
40
  existsSync,
38
41
  mkdirSync,
39
42
  mkdtempSync,
43
+ readdirSync,
40
44
  readFileSync,
41
45
  writeFileSync,
42
46
  } from 'node:fs'
@@ -61,9 +65,16 @@ import { createInterface } from 'node:readline/promises'
61
65
  * versionFile string|object|null where the project's version lives. Detected from
62
66
  * the repository when unset; null when it versions by tag alone
63
67
  * versionFiles array further files whose version is kept in sync; each is a path
64
- * or { path, pattern }
65
- * publish string publish command. Detected from the version source when unset,
66
- * and only where it is unambiguous; null to publish nothing
68
+ * or { path, pattern }. Written even where versionFile is null,
69
+ * which is how a language with no version of its own — a Go
70
+ * module — keeps one in source. When neither this nor
71
+ * versionFile is configured, a second root manifest and a
72
+ * conventional version constant already on the same version are
73
+ * detected and kept in step
74
+ * publish string|string[] publish command, or several for a project that
75
+ * releases to more than one registry. Detected from the version
76
+ * sources when unset, and only where unambiguous; null to
77
+ * publish nothing
67
78
  * commitMessage string release commit subject
68
79
  * releaseTitle string GitHub release title
69
80
  * assets string[] files attached to the GitHub release
@@ -91,11 +102,11 @@ import { createInterface } from 'node:readline/promises'
91
102
  * committing, is a mistake the tool should not let you express.
92
103
  *
93
104
  * commit commit a dirty working tree (opt-in; touches work that predates the release)
94
- * version write the version into package.json and versionFiles
105
+ * version write the version into the version source and versionFiles
95
106
  * changelog roll [Unreleased] into the version, or add drafted notes
96
107
  * tag annotated git tag carrying the release notes
97
108
  * push push the branch and the tag together
98
- * publish run the configured publish command
109
+ * publish run the configured publish command(s), in order
99
110
  * release create the GitHub release
100
111
  *
101
112
  * `version` and `changelog` write files; those writes are persisted by a release commit
@@ -134,8 +145,24 @@ const DEFAULTS = {
134
145
  '^(fixup|squash)!',
135
146
  ],
136
147
  verify: null,
148
+ hooks: {},
137
149
  }
138
150
 
151
+ /**
152
+ * The points a project can hang its own commands on, in the order they run.
153
+ *
154
+ * `verify` already covers the one gate that matters most — the project's own tests, run
155
+ * during preflight before anything mutates. What it cannot express is work that has to
156
+ * happen *between* the release's own steps: regenerating a file derived from the version,
157
+ * building an artefact the publish command expects to find, telling something downstream
158
+ * that a release landed.
159
+ *
160
+ * They are command lines rather than callbacks because the config is JSON, and they take
161
+ * the same `%v` `%t` `%n` `%d` tokens the publish command does. A non-zero exit aborts the
162
+ * release exactly where it happened, which is the point of running them there.
163
+ */
164
+ const HOOKS = ['beforeVersion', 'afterVersion', 'beforePublish', 'afterPublish', 'afterRelease']
165
+
139
166
  /**
140
167
  * Prerelease identifiers that map to their own npm dist-tag. An identifier outside this
141
168
  * set has no safe home, so `distTagFor` refuses rather than letting a prerelease fall
@@ -165,7 +192,11 @@ Target (optional; defaults to the version already in package.json):
165
192
  Steps, in the fixed order they run. All but "commit" run by default:
166
193
  ${STEPS.join(' ')}
167
194
 
168
- Subcommands (they check or copy, and never start a release):
195
+ Subcommands (they check, print or copy, and never start a release):
196
+ next [<version>|<bump>]
197
+ print the version that target would release, and stop.
198
+ Only the version reaches stdout, so it substitutes:
199
+ VERSION=$(release-kit next auto)
169
200
  lint-commits [<range>]
170
201
  check commit subjects against Conventional Commits
171
202
  (default range: since the last tag)
@@ -210,9 +241,20 @@ const yellow = (s) => paint('33', s)
210
241
 
211
242
  let stepNumber = 0
212
243
  const step = (title) => console.log(`\n${bold(`[${++stepNumber}] ${title}`)}`)
213
- const ok = (message) => console.log(` ${green('ok')} ${message}`)
214
- const warn = (message) => console.log(` ${yellow('warn')} ${message}`)
215
- const note = (message) => console.log(` ${dim(message)}`)
244
+ /**
245
+ * `next` exists to be substituted into a shell command, so its stdout must carry the
246
+ * version and nothing else. Everything the release would narrate still gets said — on
247
+ * stderr, where a human reads it and `$(...)` does not.
248
+ */
249
+ const PRINT_ONLY = process.argv[2] === 'next'
250
+ const say = (line) => {
251
+ if (PRINT_ONLY) process.stderr.write(`${line}\n`)
252
+ else console.log(line)
253
+ }
254
+
255
+ const ok = (message) => say(` ${green('ok')} ${message}`)
256
+ const warn = (message) => say(` ${yellow('warn')} ${message}`)
257
+ const note = (message) => say(` ${dim(message)}`)
216
258
  const indent = (text) =>
217
259
  text
218
260
  .split('\n')
@@ -315,7 +357,28 @@ const ASSISTANTS = {
315
357
  },
316
358
  codex: {
317
359
  command: 'codex',
318
- args: ['exec', '--skip-git-repo-check', '--sandbox', 'read-only'],
360
+ // A draft needs a bare model, but `codex exec` boots the user's whole session by
361
+ // default — plugins (with their MCP servers, hooks and skills), memories, apps and a
362
+ // notify program — several thousand tokens of context and seconds of startup that a
363
+ // one-shot prose prompt never uses. All four features are stable flags; an unknown
364
+ // flag on some future codex makes the draft fail closed into the deterministic
365
+ // fallback, which is this tool's contract for every assistant failure.
366
+ args: [
367
+ 'exec',
368
+ '--skip-git-repo-check',
369
+ '--sandbox',
370
+ 'read-only',
371
+ '--disable',
372
+ 'plugins',
373
+ '--disable',
374
+ 'hooks',
375
+ '--disable',
376
+ 'memories',
377
+ '--disable',
378
+ 'apps',
379
+ '-c',
380
+ 'notify=[]',
381
+ ],
319
382
  probe: ['--version'],
320
383
  model: (m) => ['-m', m],
321
384
  effort: (e) => ['-c', `model_reasoning_effort="${e}"`],
@@ -411,29 +474,160 @@ function runAssistant(prompt) {
411
474
  }
412
475
  }
413
476
 
414
- /** Commit subjects since the last tag, with release and merge commits filtered out. */
415
- function commitsSinceLastTag() {
477
+ /**
478
+ * The repository's release tags that are reachable from HEAD, highest version first.
479
+ *
480
+ * `git describe --tags --abbrev=0` answers a different question — "the nearest tag of any
481
+ * kind" — and it is wrong in two ways that were both observed. A repository carrying tags
482
+ * that are not releases gets one of those as its baseline: a single rolling `latest-beta`
483
+ * marker, which tauri-release-kit maintains for its update channels, made a release abort
484
+ * with "no releasable commits since latest-beta". And "nearest ancestor" is not "latest
485
+ * release": a patch tagged on top of a later minor drags the baseline backwards.
486
+ *
487
+ * Only tags carrying the configured prefix and a parseable version count, and they are
488
+ * ordered by semver precedence rather than by position in the history. `--merged HEAD`
489
+ * keeps a tag made on another branch out of this branch's history, and degrades correctly
490
+ * in a shallow clone: a tag whose commit was not fetched is simply not listed.
491
+ *
492
+ * @returns {{name: string, version: string}[]}
493
+ */
494
+ function releaseTags(prefix = config.tagPrefix ?? '') {
495
+ const listed = tryRead('git', ['tag', '--list', `${prefix}*`, '--merged', 'HEAD']) ?? ''
496
+ return listed
497
+ .split('\n')
498
+ .map((name) => name.trim())
499
+ .filter(Boolean)
500
+ .map((name) => ({ name, version: name.slice(prefix.length) }))
501
+ .filter(({ version }) => parseVersion(version))
502
+ .sort((a, b) => compareVersions(b.version, a.version))
503
+ }
504
+
505
+ /**
506
+ * The tag a release reads its history from.
507
+ *
508
+ * @param {{stable?: boolean, shipped?: boolean}} [options] `stable` when the version being
509
+ * released has no prerelease identifier, which rolls the release candidates leading to it
510
+ * up into it: their work is what is shipping now, and reading from the last candidate
511
+ * describes only the gap between the last two candidates. Promoting `2.0.0-rc.2` to
512
+ * `2.0.0` that way produced empty notes, because the one commit in range was the release
513
+ * chore. Releasing a candidate keeps the full ordering, so each candidate's notes say what
514
+ * changed in that candidate rather than repeating the whole cycle.
515
+ *
516
+ * `shipped` skips tags whose version never reached the registry — see
517
+ * `absorbedReleaseTags` for why, and `versionShipped` for how that is established.
518
+ * @returns {string | null}
519
+ */
520
+ function lastReleaseTag({ stable = false, prefix, shipped = false } = {}) {
521
+ const tags = releaseTags(prefix)
522
+ const eligible = stable ? tags.filter(({ version }) => !parseVersion(version).pre.length) : tags
523
+ if (!shipped) return eligible[0]?.name ?? null
524
+ for (const tag of eligible) {
525
+ const state = versionShipped(tag.version)
526
+ if (state === true) return tag.name
527
+ // Nothing could answer. Walking further asks the same unanswerable question about older
528
+ // versions, and treating silence as "never published" would reach back to the first
529
+ // commit in the repository — so this reads history exactly as it did before.
530
+ if (state === null) break
531
+ }
532
+ // Either the registry went quiet, or no tag this project ever made is on it — a project
533
+ // that tags and publishes by hand looks exactly like that. Neither is evidence that the
534
+ // last release failed, so the newest tag stays the baseline.
535
+ return eligible[0]?.name ?? null
536
+ }
537
+
538
+ /**
539
+ * The tags this release is about to absorb: versions that were tagged, pushed and written
540
+ * into the changelog, and then never published.
541
+ *
542
+ * Their commits are still unreleased work — the tag says otherwise, and that is what made
543
+ * them disappear. `2.0.1` failed to publish, `2.0.2` read its history from the `v2.0.1` tag
544
+ * and shipped notes covering one commit, and the ten commits `2.0.1` was made of are named
545
+ * in no release anyone can install. Reading from the last *shipped* tag puts them back in
546
+ * range, both for the notes and for the bump `auto` infers from them.
547
+ *
548
+ * @returns {{name: string, version: string}[]} newest first, empty in the ordinary case
549
+ */
550
+ function absorbedReleaseTags({ stable = false } = {}) {
551
+ const tags = releaseTags()
552
+ const eligible = stable ? tags.filter(({ version }) => !parseVersion(version).pre.length) : tags
553
+ const baseline = lastReleaseTag({ stable, shipped: true })
554
+ const absorbed = []
555
+ for (const tag of eligible) {
556
+ if (tag.name === baseline) break
557
+ if (versionShipped(tag.version) !== false) break
558
+ absorbed.push(tag)
559
+ }
560
+ return absorbed
561
+ }
562
+
563
+ /** Commit subjects since the last release tag, with release and merge commits filtered out. */
564
+ function commitsSinceLastTag(options) {
416
565
  const ignored = (config.ignoreCommits ?? []).map((pattern) => new RegExp(pattern, 'i'))
417
- const lastTag = tryRead('git', ['describe', '--tags', '--abbrev=0'])
566
+ const lastTag = lastReleaseTag(options)
418
567
  const range = lastTag ? `${lastTag}..HEAD` : 'HEAD'
419
568
  // %B is the whole message: bodies carry BREAKING CHANGE and Release-As footers. A record
420
569
  // separator keeps multi-line messages parseable when splitting the log back apart.
421
- // %h first, then the message: the hash is what links each bullet back to its commit.
422
- const raw = tryRead('git', ['log', `--format=%h%x1f%B%x1e`, range]) ?? ''
570
+ // %h first, then the author, then the message: the hash is what links each bullet back
571
+ // to its commit, and the author is what says who is new here.
572
+ const raw = tryRead('git', ['log', `--format=%h%x1f%an%x1f%ae%x1f%B%x1e`, range]) ?? ''
423
573
  const commits = raw
424
574
  .split('\u001E')
425
575
  .map((entry) => entry.trim())
426
576
  .filter(Boolean)
427
577
  .map((entry) => {
428
- const [hash, message = ''] = entry.split('\u001F')
578
+ const [hash, author = '', email = '', message = ''] = entry.split('\u001F')
429
579
  const [subject, ...rest] = message.split('\n')
430
- return { hash: hash.trim(), subject: subject.trim(), body: rest.join('\n').trim() }
580
+ return {
581
+ hash: hash.trim(),
582
+ author: author.trim(),
583
+ email: email.trim().toLowerCase(),
584
+ subject: subject.trim(),
585
+ body: rest.join('\n').trim(),
586
+ }
431
587
  })
432
588
  // Bookkeeping rather than change: the previous release's own commit, merges that
433
589
  // duplicate the branch they bring in, and markers meant to be autosquashed away.
434
590
  .filter(({ subject }) => subject && !ignored.some((re) => re.test(subject)))
435
591
  const kept = withoutRevertedCommits(commits)
436
- return { lastTag, commits: kept, subjects: kept.map((c) => c.subject) }
592
+ return {
593
+ lastTag,
594
+ commits: kept,
595
+ subjects: kept.map((c) => c.subject),
596
+ contributors: newContributors(lastTag, kept),
597
+ }
598
+ }
599
+
600
+ /**
601
+ * The people whose first commit to this repository is in this release.
602
+ *
603
+ * git-cliff derives this from the forge's API, which needs a token, a network and a
604
+ * forge. The repository already knows: an author absent from every commit before the
605
+ * previous tag has not contributed before. That answer is exact, offline, and the same on
606
+ * GitHub, GitLab and a bare remote — and it degrades with a shallow clone exactly as the
607
+ * rest of the notes do, which is already warned about.
608
+ *
609
+ * The first release has no "before", so everyone would be new and the section would say
610
+ * nothing; it is skipped there.
611
+ *
612
+ * @returns {string[]} display names, GitHub handles where the email carries one
613
+ */
614
+ function newContributors(lastTag, commits) {
615
+ if (!lastTag || !commits.length) return []
616
+ const before = new Set(
617
+ (tryRead('git', ['log', '--format=%ae', lastTag]) ?? '')
618
+ .split('\n')
619
+ .map((email) => email.trim().toLowerCase())
620
+ .filter(Boolean),
621
+ )
622
+ const seen = new Map()
623
+ for (const { author, email } of commits) {
624
+ if (!email || before.has(email) || seen.has(email)) continue
625
+ // A GitHub noreply address carries the account handle, which is what a reader can
626
+ // actually follow; anything else falls back to the name on the commit.
627
+ const handle = /^(?:\d+\+)?([^@]+)@users\.noreply\.github\.com$/.exec(email)?.[1]
628
+ seen.set(email, handle ? `@${handle}` : author)
629
+ }
630
+ return [...seen.values()].filter(Boolean)
437
631
  }
438
632
 
439
633
  /**
@@ -528,7 +722,7 @@ function inferBump(commits, currentVersion, strategy = 'conventional') {
528
722
  *
529
723
  * @returns {string | null} markdown body, or null when nothing visible changed
530
724
  */
531
- function changelogFromCommits(commits, links = null, hidden = []) {
725
+ function changelogFromCommits(commits, links = null, hidden = [], contributors = []) {
532
726
  const parsed = commits.map((c) => parseCommit(c.subject, c.body, c.hash)).filter(Boolean)
533
727
  const lines = []
534
728
 
@@ -577,6 +771,14 @@ function changelogFromCommits(commits, links = null, hidden = []) {
577
771
  lines.push('')
578
772
  }
579
773
 
774
+ // Last, and only when there is something above it: a list of names is not release notes
775
+ // on its own, and a release with no described changes should still say so.
776
+ if (lines.length && contributors.length) {
777
+ lines.push('### New Contributors', '')
778
+ for (const name of contributors) lines.push(`- ${name} made their first contribution`)
779
+ lines.push('')
780
+ }
781
+
580
782
  return lines.length ? lines.join('\n').trim() : null
581
783
  }
582
784
 
@@ -586,11 +788,28 @@ function changelogFromCommits(commits, links = null, hidden = []) {
586
788
  * differ: Bitbucket uses /issue/ and /commits/ where GitHub uses /issues/ and /commit/.
587
789
  */
588
790
  const HOSTS = {
589
- 'github.com': { issue: 'issues', commit: 'commit' },
590
- 'gitlab.com': { issue: 'issues', commit: 'commit' },
591
- 'bitbucket.org': { issue: 'issue', commit: 'commits' },
791
+ 'github.com': {
792
+ issue: 'issues',
793
+ commit: 'commit',
794
+ compare: 'compare/%f...%t',
795
+ tag: 'releases/tag/%t',
796
+ },
797
+ 'gitlab.com': { issue: 'issues', commit: 'commit', compare: 'compare/%f...%t', tag: '-/tags/%t' },
798
+ // Bitbucket reverses the operands and separates them with two dots, and keeps tags under
799
+ // /commits/tag/ rather than a releases page it does not have.
800
+ 'bitbucket.org': {
801
+ issue: 'issue',
802
+ commit: 'commits',
803
+ compare: 'branches/compare/%t..%f',
804
+ tag: 'commits/tag/%t',
805
+ },
806
+ }
807
+ const DEFAULT_HOST = {
808
+ issue: 'issues',
809
+ commit: 'commit',
810
+ compare: 'compare/%f...%t',
811
+ tag: 'releases/tag/%t',
592
812
  }
593
- const DEFAULT_HOST = { issue: 'issues', commit: 'commit' }
594
813
 
595
814
  /** Words that mark an issue reference as closed by the commit. */
596
815
  const CLOSES = /\b(?:close[sd]?|closing|fix(?:e[sd])?|fixing|resolve[sd]?|resolving)\s+#(\d+)/gi
@@ -611,10 +830,13 @@ function remoteLinks(remote) {
611
830
  const [, host, path] = web ?? scp ?? []
612
831
  if (!host || !path) return null
613
832
  const shape = HOSTS[host.toLowerCase()] ?? DEFAULT_HOST
833
+ const base = `https://${host}/${path}`
614
834
  return {
615
- base: `https://${host}/${path}`,
616
- issue: `https://${host}/${path}/${shape.issue}`,
617
- commit: `https://${host}/${path}/${shape.commit}`,
835
+ base,
836
+ issue: `${base}/${shape.issue}`,
837
+ commit: `${base}/${shape.commit}`,
838
+ compare: (from, to) => `${base}/${shape.compare.replace('%f', from).replace('%t', to)}`,
839
+ tag: (name) => `${base}/${shape.tag.replace('%t', name)}`,
618
840
  }
619
841
  }
620
842
 
@@ -871,6 +1093,103 @@ function mutate(command, args, options = {}) {
871
1093
  }
872
1094
  }
873
1095
 
1096
+ /**
1097
+ * Lockfiles that record the releasing project's own version, and the command that brings
1098
+ * each back into step.
1099
+ *
1100
+ * A lockfile is not rewritten by pattern like a manifest is: `package-lock.json` carries
1101
+ * the version in two places, `uv.lock` carries it inside the `[[package]]` block for the
1102
+ * project among all its dependencies, and both formats change shape between tool versions.
1103
+ * The tool that owns the file is the only thing that can be trusted to edit it, so each
1104
+ * one is refreshed by running that tool.
1105
+ *
1106
+ * `manifest` scopes the refresh: a polyglot repository can hold a `uv.lock` for a Python
1107
+ * component that this release is not versioning, and regenerating it would put an
1108
+ * unrelated change in the release commit.
1109
+ *
1110
+ * pnpm and Cargo are deliberately absent. `pnpm-lock.yaml` records no root version, so it
1111
+ * never goes stale; `Cargo.lock` does, and is already kept in step as a version file with
1112
+ * a pattern scoped to the crate.
1113
+ */
1114
+ const LOCKFILES = [
1115
+ {
1116
+ path: 'package-lock.json',
1117
+ manifest: 'package.json',
1118
+ command: ['npm', ['install', '--package-lock-only', '--ignore-scripts', '--silent']],
1119
+ },
1120
+ {
1121
+ path: 'npm-shrinkwrap.json',
1122
+ manifest: 'package.json',
1123
+ command: ['npm', ['install', '--package-lock-only', '--ignore-scripts', '--silent']],
1124
+ },
1125
+ { path: 'uv.lock', manifest: 'pyproject.toml', command: ['uv', ['lock', '--quiet']] },
1126
+ ]
1127
+
1128
+ /**
1129
+ * Bring every lockfile belonging to a manifest this release wrote back into step.
1130
+ *
1131
+ * A missing tool is a warning rather than an abort: the lockfile is left exactly as stale
1132
+ * as it already was, which is the behaviour without this step at all, and no release
1133
+ * should die because a lock tool is not installed on the machine cutting it.
1134
+ *
1135
+ * @param {string[]} written paths the version step wrote
1136
+ */
1137
+ function refreshLockfiles(written) {
1138
+ const done = new Set()
1139
+ for (const { path, manifest, command } of LOCKFILES) {
1140
+ if (!existsSync(path) || !written.includes(manifest)) continue
1141
+ const [tool, args] = command
1142
+ // npm writes whichever of the two lockfiles the project has; running it twice is one
1143
+ // pointless install, not two different edits.
1144
+ if (!done.has(tool)) {
1145
+ if (!dryRun && !succeeds(tool, ['--version'])) {
1146
+ warn(`${path} records the version and ${tool} is not installed — leaving it stale`)
1147
+ continue
1148
+ }
1149
+ mutate(tool, args)
1150
+ done.add(tool)
1151
+ }
1152
+ staged.push(path)
1153
+ }
1154
+ }
1155
+
1156
+ /**
1157
+ * Push the release commit and its tag as one transaction.
1158
+ *
1159
+ * `--follow-tags` and `--atomic` answer different questions: the first decides *which*
1160
+ * refs are sent, the second decides whether they land together. With only the first, a
1161
+ * server is free to accept the branch and reject the tag — which is precisely the split
1162
+ * this step exists to prevent, leaving a release commit on the remote with no tag, or a
1163
+ * tag with no commit behind it.
1164
+ *
1165
+ * Not every server implements the atomic capability, so a refusal on those grounds falls
1166
+ * back to the plain push. Nothing else does: a rejected non-fast-forward retried without
1167
+ * `--atomic` would push one ref and not the other, which is worse than failing.
1168
+ */
1169
+ function pushBranchAndTag(branchRef) {
1170
+ const args = ['push', '--follow-tags', config.remote, branchRef]
1171
+ const atomic = ['push', '--follow-tags', '--atomic', config.remote, branchRef]
1172
+ const line = formatCommand('git', atomic)
1173
+ if (dryRun) {
1174
+ console.log(` ${yellow('would run:')} ${line}`)
1175
+ return
1176
+ }
1177
+ console.log(` ${dim(`$ ${line}`)}`)
1178
+ try {
1179
+ execFileSync('git', atomic, { stdio: ['pipe', 'inherit', 'pipe'] })
1180
+ return
1181
+ } catch (err) {
1182
+ const stderr = `${err.stderr ?? ''}`
1183
+ process.stderr.write(stderr)
1184
+ if (!/atomic/i.test(stderr)) abortMidRelease(line)
1185
+ }
1186
+ warn(
1187
+ `${config.remote} does not support atomic pushes — sending the branch and tag in one ` +
1188
+ 'call, but not as one transaction',
1189
+ )
1190
+ mutate('git', args)
1191
+ }
1192
+
874
1193
  /**
875
1194
  * Mutating shell command, for configured strings like `publish` that are written as a
876
1195
  * whole command line rather than an argv. Shell metacharacters are the author's to own.
@@ -1072,6 +1391,65 @@ function insertChangelogSection(text, version, date, body) {
1072
1391
  return `${trimmed}\n\n${entry}`
1073
1392
  }
1074
1393
 
1394
+ /**
1395
+ * Write the link reference definitions a Keep a Changelog document's headings depend on.
1396
+ *
1397
+ * `## [1.2.3]` is a markdown link *reference*: without a matching `[1.2.3]: <url>` at the
1398
+ * foot of the file it renders as literal bracketed text. Sections were being written in
1399
+ * that shape and the definitions were never written at all, so every heading in every
1400
+ * changelog this tool has ever rolled is a dead reference.
1401
+ *
1402
+ * Every bracketed heading in the document gets one, not only the version being released,
1403
+ * so a changelog that never had them is repaired in one release rather than from here on.
1404
+ * Each version links to the diff since the version below it; the oldest links to its own
1405
+ * tag, having no predecessor to compare against. `[Unreleased]` compares the newest
1406
+ * version against `HEAD`.
1407
+ *
1408
+ * Definitions for labels that are not headings are left exactly where they are — those are
1409
+ * the author's own links, and this owns only what it can derive.
1410
+ *
1411
+ * @param {ReturnType<typeof remoteLinks>} links
1412
+ * @returns {string} the document with its definitions rewritten
1413
+ */
1414
+ function withChangelogLinks(text, links, tagPrefix = '') {
1415
+ if (!links) return text
1416
+ const labels = [...text.matchAll(/^## \[([^\]]+)\]/gm)].map((m) => m[1])
1417
+ if (!labels.length) return text
1418
+
1419
+ const versions = labels
1420
+ .filter((label) => parseVersion(label.replace(/^v/, '')))
1421
+ .sort((a, b) => compareVersions(b.replace(/^v/, ''), a.replace(/^v/, '')))
1422
+ const unreleased = labels.find((label) => /^unreleased$/i.test(label))
1423
+
1424
+ const tagged = (label) => `${tagPrefix}${label.replace(/^v/, '')}`
1425
+ const definitions = []
1426
+ if (unreleased && versions.length) {
1427
+ definitions.push(`[${unreleased}]: ${links.compare(tagged(versions[0]), 'HEAD')}`)
1428
+ }
1429
+ versions.forEach((version, index) => {
1430
+ const previous = versions[index + 1]
1431
+ definitions.push(
1432
+ `[${version}]: ${
1433
+ previous ? links.compare(tagged(previous), tagged(version)) : links.tag(tagged(version))
1434
+ }`,
1435
+ )
1436
+ })
1437
+ if (!definitions.length) return text
1438
+
1439
+ // Drop the existing definitions for the labels being rewritten, wherever they sit, so
1440
+ // running this twice produces the same document rather than a second copy.
1441
+ const managed = new Set([...(unreleased ? [unreleased] : []), ...versions])
1442
+ const body = text
1443
+ .split('\n')
1444
+ .filter((line) => {
1445
+ const label = /^\[([^\]]+)\]:\s/.exec(line)?.[1]
1446
+ return !(label && managed.has(label))
1447
+ })
1448
+ .join('\n')
1449
+
1450
+ return `${body.trimEnd()}\n\n${definitions.join('\n')}\n`
1451
+ }
1452
+
1075
1453
  /**
1076
1454
  * Promote `## [Unreleased]` to a released version and reopen an empty one above it.
1077
1455
  *
@@ -1152,6 +1530,73 @@ function readNameFrom(entry) {
1152
1530
  /** Normalise a versionFile / versionFiles entry to { path, pattern }. */
1153
1531
  const versionSource = (entry) => (typeof entry === 'string' ? { path: entry } : entry)
1154
1532
 
1533
+ /**
1534
+ * Expand a path that may contain `*` segments into the paths that exist, in sorted order.
1535
+ *
1536
+ * A desktop app carries the same version in a per-platform config for every platform it
1537
+ * ships, and a Cargo workspace lists its members as `crates/*`. Writing those out one by
1538
+ * one is the config the glob replaces. `*` matches within a single path segment, which is
1539
+ * what both of those shapes need and is all Go's `filepath.Glob` — the shape changie's
1540
+ * `replacements` use — offers either.
1541
+ *
1542
+ * @returns {string[]} matching paths; a pattern with no `*` yields itself when it exists
1543
+ */
1544
+ function expandPaths(pattern) {
1545
+ if (!pattern.includes('*')) return existsSync(pattern) ? [pattern] : []
1546
+ const absolute = pattern.startsWith('/')
1547
+ let current = [absolute ? '/' : '.']
1548
+ for (const segment of pattern.split('/').filter(Boolean)) {
1549
+ const next = []
1550
+ if (segment.includes('*')) {
1551
+ const shape = new RegExp(`^${segment.split('*').map(escapeRe).join('[^/]*')}$`)
1552
+ for (const dir of current) {
1553
+ let entries
1554
+ try {
1555
+ entries = readdirSync(dir).sort()
1556
+ } catch {
1557
+ continue
1558
+ }
1559
+ for (const entry of entries) if (shape.test(entry)) next.push(join(dir, entry))
1560
+ }
1561
+ } else {
1562
+ for (const dir of current) {
1563
+ const joined = join(dir, segment)
1564
+ if (existsSync(joined)) next.push(joined)
1565
+ }
1566
+ }
1567
+ current = next
1568
+ }
1569
+ return current
1570
+ }
1571
+
1572
+ /**
1573
+ * The crates in a Cargo workspace whose version this bump owns: the members that inherit
1574
+ * it with `version.workspace = true`, which is how a workspace keeps its crates in step.
1575
+ *
1576
+ * A member pinning its own number is versioned separately and is left out, the same rule
1577
+ * the companion-manifest detection uses.
1578
+ *
1579
+ * @returns {string[]} crate names
1580
+ */
1581
+ function workspaceCrates(manifestPath) {
1582
+ const text = readFileSync(manifestPath, 'utf8')
1583
+ const members = /^members\s*=\s*\[([\s\S]*?)\]/m.exec(text)?.[1]
1584
+ if (!members) return []
1585
+ const root = dirname(manifestPath)
1586
+ const names = []
1587
+ for (const entry of members.matchAll(/"([^"]+)"/g)) {
1588
+ for (const dir of expandPaths(join(root, entry[1]))) {
1589
+ const memberManifest = join(dir, 'Cargo.toml')
1590
+ if (!existsSync(memberManifest)) continue
1591
+ const member = readFileSync(memberManifest, 'utf8')
1592
+ if (!/^version(?:\.workspace)?\s*=\s*\{?\s*workspace\s*=\s*true/m.test(member)) continue
1593
+ const name = NAME_PATTERNS.toml.exec(member)?.[1]
1594
+ if (name) names.push(name)
1595
+ }
1596
+ }
1597
+ return names
1598
+ }
1599
+
1155
1600
  /**
1156
1601
  * A lockfile records a version for every dependency — hundreds of them — so the first
1157
1602
  * `version = "…"` in the file belongs to whichever crate sorts first, not to this project.
@@ -1159,21 +1604,27 @@ const versionSource = (entry) => (typeof entry === 'string' ? { path: entry } :
1159
1604
  */
1160
1605
  function cargoLockPattern(lockPath) {
1161
1606
  const sibling = join(dirname(lockPath), 'Cargo.toml')
1162
- const crate = existsSync(sibling) ? readNameFrom({ path: sibling }) : null
1163
- if (!crate) {
1607
+ // A workspace root carries no `[package]` of its own; what it owns is every member that
1608
+ // inherits the version with `version.workspace = true`, and the lockfile records a
1609
+ // block for each of them.
1610
+ const crates = existsSync(sibling)
1611
+ ? [...new Set([readNameFrom({ path: sibling }), ...workspaceCrates(sibling)].filter(Boolean))]
1612
+ : []
1613
+ if (!crates.length) {
1164
1614
  throw new Error(
1165
1615
  `${lockPath} lists every dependency's version, so it needs to know which package is ` +
1166
- `yours.\n No Cargo.toml beside it to read the name from — give an explicit ` +
1167
- `pattern:\n { "path": "${lockPath}", "pattern": "name = \\"<crate>\\"\\nversion = ` +
1168
- `\\"(.+)\\"" }`,
1616
+ `yours.\n No Cargo.toml beside it naming a crate or a workspace member that ` +
1617
+ `inherits the version — give an explicit pattern:\n { "path": "${lockPath}", ` +
1618
+ `"pattern": "name = \\"<crate>\\"\\nversion = \\"(.+)\\"" }`,
1169
1619
  )
1170
1620
  }
1171
- return new RegExp(`\\[\\[package\\]\\]\\nname = "${escapeRe(crate)}"\\nversion = "([^"]*)"`)
1621
+ const names = crates.map(escapeRe).join('|')
1622
+ return new RegExp(`\\[\\[package\\]\\]\\nname = "(?:${names})"\\nversion = "([^"]*)"`, 'g')
1172
1623
  }
1173
1624
 
1174
1625
  /** The regex for a source, or null when the whole file is the version. */
1175
- function patternFor({ path, pattern }) {
1176
- if (pattern) return new RegExp(pattern, 'm')
1626
+ function patternFor({ path, pattern, all = false }) {
1627
+ if (pattern) return new RegExp(pattern, all ? 'mg' : 'm')
1177
1628
  if (basename(path) === 'Cargo.lock') return cargoLockPattern(path)
1178
1629
  if (path.endsWith('.json')) return VERSION_PATTERNS.json
1179
1630
  if (path.endsWith('.toml')) return VERSION_PATTERNS.toml
@@ -1184,31 +1635,176 @@ function patternFor({ path, pattern }) {
1184
1635
  function readVersionFrom(entry) {
1185
1636
  const source = versionSource(entry)
1186
1637
  const text = readFileSync(source.path, 'utf8')
1187
- const pattern = patternFor(source)
1188
- if (!pattern) return text.trim() || null
1189
- const match = pattern.exec(text)
1638
+ const { kind, shape } = versionMode(source, text)
1639
+ if (kind === 'bare') return text.trim() || null
1640
+ if (kind === 'markers') {
1641
+ // The first line under a `version` marker is the one that carries the whole version;
1642
+ // a `major` or `date` marker carries only a piece of it.
1643
+ let scope = null
1644
+ for (const line of text.split('\n')) {
1645
+ const starting = MARKER_START.exec(line)?.[1]
1646
+ if (starting) {
1647
+ scope = starting
1648
+ continue
1649
+ }
1650
+ if (scope && MARKER_END.test(line)) {
1651
+ scope = null
1652
+ continue
1653
+ }
1654
+ const active = MARKER_INLINE.exec(line)?.[1] ?? scope
1655
+ if (active === 'version') {
1656
+ const found = MARKER_VERSION.exec(line)?.[0]
1657
+ if (found) return found
1658
+ }
1659
+ }
1660
+ return null
1661
+ }
1662
+ const match = shape.exec(text)
1190
1663
  return match ? match[1] : null
1191
1664
  }
1192
1665
 
1666
+ /**
1667
+ * Version markers: a comment naming the version, put on the line that carries it.
1668
+ *
1669
+ * A `pattern` can already reach any file, but writing one is a regex per file, and the
1670
+ * files that most want keeping in step — a README install line, a badge URL, a Dockerfile
1671
+ * tag, a Helm chart — are exactly the ones where a regex is fiddliest to get right and
1672
+ * easiest to get subtly wrong. release-please solved this with a marker comment on the
1673
+ * line instead, and the convention travels: the file says which of its numbers is the
1674
+ * version, so nothing outside it has to describe where that number sits.
1675
+ *
1676
+ * npm i acme@1.2.3 <!-- x-release-kit-version -->
1677
+ * FROM acme:1.2 # x-release-kit-minor
1678
+ * Released 2026-08-20 <!-- x-release-kit-date -->
1679
+ *
1680
+ * A block form covers a run of lines, for a fenced example that should not carry a comment
1681
+ * on every line:
1682
+ *
1683
+ * <!-- x-release-kit-start-version -->
1684
+ * ```sh
1685
+ * npm i acme@1.2.3
1686
+ * ```
1687
+ * <!-- x-release-kit-end -->
1688
+ */
1689
+ // `version-date` comes first: the alternation is ordered, and `version` would otherwise
1690
+ // match its prefix and leave the date alone.
1691
+ const SCOPES = 'version-date|major|minor|patch|version|date'
1692
+ const MARKER_INLINE = new RegExp(`x-release-kit-(${SCOPES})\\b`)
1693
+ const MARKER_START = new RegExp(`x-release-kit-start-(${SCOPES})\\b`)
1694
+ const MARKER_END = /x-release-kit-end\b/
1695
+ const MARKER_VERSION = /\d+\.\d+\.\d+(?:-[0-9a-z.-]+)?(?:\+[0-9a-z.-]+)?/i
1696
+ const MARKER_NUMBER = /\b\d+\b/
1697
+ const MARKER_DATE = /\d{4}-\d{2}-\d{2}/
1698
+
1699
+ /** Whether a file opts into marker rewriting at all. */
1700
+ const hasVersionMarkers = (text) => MARKER_INLINE.test(text) || MARKER_START.test(text)
1701
+
1702
+ /**
1703
+ * Rewrite the marked numbers in a file.
1704
+ *
1705
+ * A marker whose line carries nothing to replace is left alone rather than guessed at: a
1706
+ * heading above a block, or a comment on its own line, is a normal thing to find.
1707
+ *
1708
+ * @returns {string} the rewritten text
1709
+ */
1710
+ function applyVersionMarkers(text, version, date) {
1711
+ const { major, minor, patch } = parseVersion(version)
1712
+ const replacements = {
1713
+ version: [MARKER_VERSION, version],
1714
+ major: [MARKER_NUMBER, String(major)],
1715
+ minor: [MARKER_NUMBER, String(minor)],
1716
+ patch: [MARKER_NUMBER, String(patch)],
1717
+ date: [MARKER_DATE, date],
1718
+ }
1719
+ // One line carrying both, which is the shape of an AppStream <release> tag.
1720
+ const versionAndDate = (line) => line.replace(MARKER_VERSION, version).replace(MARKER_DATE, date)
1721
+ let scope = null
1722
+ return text
1723
+ .split('\n')
1724
+ .map((line) => {
1725
+ const inline = MARKER_INLINE.exec(line)?.[1]
1726
+ const starting = MARKER_START.exec(line)?.[1]
1727
+ // A start marker opens a block; its own line is not rewritten, since the marker
1728
+ // comment is the whole content of it.
1729
+ if (starting) {
1730
+ scope = starting
1731
+ return line
1732
+ }
1733
+ if (scope && MARKER_END.test(line)) {
1734
+ scope = null
1735
+ return line
1736
+ }
1737
+ const active = inline ?? scope
1738
+ if (!active) return line
1739
+ if (active === 'version-date') return versionAndDate(line)
1740
+ const [shape, value] = replacements[active]
1741
+ return line.replace(shape, value)
1742
+ })
1743
+ .join('\n')
1744
+ }
1745
+
1746
+ /**
1747
+ * Where a file keeps its version, most specific first: the `pattern` the entry was
1748
+ * configured with, the markers the file carries, the shape its extension implies, and —
1749
+ * for a plain `VERSION` file — being nothing but the version.
1750
+ *
1751
+ * Reading and writing both go through this, so they can never disagree about which of a
1752
+ * file's numbers is the version.
1753
+ *
1754
+ * @returns {{kind: 'pattern'|'markers'|'bare', shape: RegExp|null}}
1755
+ */
1756
+ function versionMode(source, text) {
1757
+ if (source.pattern) return { kind: 'pattern', shape: patternFor(source) }
1758
+ if (hasVersionMarkers(text)) return { kind: 'markers', shape: null }
1759
+ const inferred = patternFor(source)
1760
+ return inferred ? { kind: 'pattern', shape: inferred } : { kind: 'bare', shape: null }
1761
+ }
1762
+
1193
1763
  /**
1194
1764
  * Replace the version in a source file, touching nothing else: only the captured range is
1195
1765
  * rewritten, so formatting, key order and comments all survive.
1196
1766
  *
1197
- * @param {{dryRun?: boolean}} [options] report the change without making it
1767
+ * Four ways a file says where its version is, most specific first: the `pattern` it was
1768
+ * configured with, the markers it carries, the shape its extension implies, and — for a
1769
+ * plain `VERSION` file — being nothing but the version.
1770
+ *
1771
+ * @param {{dryRun?: boolean, date?: string}} [options] report the change without making
1772
+ * it; the date written for a `x-release-kit-date` marker
1198
1773
  * @returns {boolean} whether the file needed changing
1199
1774
  */
1200
- function writeVersionInto(entry, version, { dryRun = false } = {}) {
1775
+ function writeVersionInto(entry, version, { dryRun = false, date } = {}) {
1201
1776
  const source = versionSource(entry)
1202
1777
  const text = readFileSync(source.path, 'utf8')
1203
- const pattern = patternFor(source)
1778
+ const { kind, shape } = versionMode(source, text)
1204
1779
 
1205
1780
  let updated
1206
- if (pattern) {
1207
- const match = pattern.exec(text)
1208
- if (!match) throw new Error(`${source.path} has no version matching ${pattern}`)
1209
- const start = match.index + match[0].indexOf(match[1])
1210
- updated = text.slice(0, start) + version + text.slice(start + match[1].length)
1781
+ if (kind === 'pattern') {
1782
+ if (!shape.test(text)) {
1783
+ if (source.optional) return false
1784
+ throw new Error(`${source.path} has no version matching ${shape}`)
1785
+ }
1786
+ shape.lastIndex = 0
1787
+ // A global pattern rewrites every match rather than the first: a Cargo.lock records
1788
+ // one block per crate, and a workspace bump owns all the members inheriting from it.
1789
+ updated = text.replace(shape, (match, captured) => {
1790
+ const at = match.indexOf(captured)
1791
+ return match.slice(0, at) + version + match.slice(at + captured.length)
1792
+ })
1793
+ } else if (kind === 'markers') {
1794
+ updated = applyVersionMarkers(text, version, date ?? new Date().toISOString().slice(0, 10))
1211
1795
  } else {
1796
+ // The last resort overwrites the file with the version, which is right for a VERSION
1797
+ // file and catastrophic for anything else. A file that is not already just a version
1798
+ // was listed by mistake, or wants a marker or a pattern — say so rather than shred it.
1799
+ const existing = text.trim()
1800
+ if (existing && !parseVersion(existing)) {
1801
+ throw new Error(
1802
+ `${source.path} is not a file containing only a version, and carries no ` +
1803
+ 'x-release-kit-version marker.\n Writing the version into it would replace ' +
1804
+ 'everything else in it. Mark the line that holds the version, or give the entry ' +
1805
+ 'a "pattern".',
1806
+ )
1807
+ }
1212
1808
  updated = `${version}\n`
1213
1809
  }
1214
1810
 
@@ -1221,7 +1817,9 @@ function writeVersionInto(entry, version, { dryRun = false } = {}) {
1221
1817
  // ARGUMENTS
1222
1818
  // ─────────────────────────────────────────────────────────────────────────────
1223
1819
 
1224
- const argv = process.argv.slice(2)
1820
+ // `next` is a modifier on the ordinary target resolution, not a mode of its own: it takes
1821
+ // the same target argument and stops once the version is known.
1822
+ const argv = process.argv.slice(PRINT_ONLY ? 3 : 2)
1225
1823
  const BUMPS = new Set([
1226
1824
  'auto',
1227
1825
  'major',
@@ -1313,9 +1911,9 @@ if (flag('--sync')) {
1313
1911
  if (argv[0] === 'lint-commits') {
1314
1912
  const rest = argv.slice(1)
1315
1913
  const subjectAt = rest.indexOf('--subject')
1316
- const ignored = { ...DEFAULTS, ...readUserConfig() }.ignoreCommits.map(
1317
- (pattern) => new RegExp(pattern, 'i'),
1318
- )
1914
+ // This subcommand runs before `config` is bound, so it resolves its own.
1915
+ const lintConfig = { ...DEFAULTS, ...readUserConfig() }
1916
+ const ignored = lintConfig.ignoreCommits.map((pattern) => new RegExp(pattern, 'i'))
1319
1917
 
1320
1918
  let subjects
1321
1919
  let scope
@@ -1328,7 +1926,7 @@ if (argv[0] === 'lint-commits') {
1328
1926
  scope = null
1329
1927
  } else {
1330
1928
  if (!tryRead('git', ['rev-parse', '--show-toplevel'])) abort('not inside a git repository')
1331
- const lastTag = tryRead('git', ['describe', '--tags', '--abbrev=0'])
1929
+ const lastTag = lastReleaseTag({ prefix: lintConfig.tagPrefix ?? '' })
1332
1930
  const range =
1333
1931
  rest.find((arg) => !arg.startsWith('-')) ?? (lastTag ? `${lastTag}..HEAD` : 'HEAD')
1334
1932
  // Merges carry no prose of their own, and %s is enough: nothing here reads the body.
@@ -1434,6 +2032,14 @@ const userConfig = readUserConfig()
1434
2032
  const config = { ...DEFAULTS, ...userConfig }
1435
2033
  const unknownKeys = Object.keys(config).filter((key) => !(key in DEFAULTS))
1436
2034
  if (unknownKeys.length) abort(`release.config.json has unknown keys: ${unknownKeys.join(', ')}`)
2035
+ // A misspelled hook name is a hook that silently never runs, which is the failure mode
2036
+ // this file refuses everywhere else it takes a name.
2037
+ const unknownHooks = Object.keys(config.hooks ?? {}).filter((key) => !HOOKS.includes(key))
2038
+ if (unknownHooks.length) {
2039
+ abort(
2040
+ `release.config.json has unknown hooks: ${unknownHooks.join(', ')}\n Known: ${HOOKS.join(', ')}`,
2041
+ )
2042
+ }
1437
2043
 
1438
2044
  /**
1439
2045
  * Which steps run: config provides the baseline, --only replaces it, --skip subtracts, and
@@ -1455,17 +2061,94 @@ const parseStepList = (value) =>
1455
2061
  const manifest = existsSync('package.json') ? readJson('package.json') : null
1456
2062
 
1457
2063
  /**
1458
- * Where this project keeps its version, when the config does not say. Checked in order of
1459
- * how definitively each file identifies a repository. `go.mod` resolves to null because Go
1460
- * modules carry no version — the tag is the version.
2064
+ * Files that identify a repository, most definitive first. The first one present is where
2065
+ * the version is read from and written to. `go.mod` is absent because Go modules carry no
2066
+ * version — the tag is the version.
1461
2067
  */
2068
+ const MANIFESTS = ['package.json', 'pyproject.toml', 'Cargo.toml', 'VERSION']
2069
+
2070
+ /** Where this project keeps its version, when the config does not say. */
1462
2071
  function detectVersionFile() {
1463
- for (const candidate of ['package.json', 'pyproject.toml', 'Cargo.toml', 'VERSION']) {
1464
- if (existsSync(candidate)) return candidate
1465
- }
2072
+ for (const candidate of MANIFESTS) if (existsSync(candidate)) return candidate
1466
2073
  return null
1467
2074
  }
1468
2075
 
2076
+ /**
2077
+ * Manifests that name an ecosystem, so a second one present means a second registry. A
2078
+ * bare VERSION file is deliberately not one: it names nothing, and a repository keeping an
2079
+ * unrelated VERSION beside its manifest should not be told the two disagree.
2080
+ */
2081
+ const ECOSYSTEM_MANIFESTS = ['package.json', 'pyproject.toml', 'Cargo.toml']
2082
+
2083
+ /**
2084
+ * Some repositories release one source tree to two ecosystems at once — a Tauri plugin is
2085
+ * a crate and an npm package, a maturin project is a crate and a wheel — and carry the
2086
+ * version in both manifests. Those bump together with no config.
2087
+ *
2088
+ * The safety rule is that they must already agree. Two manifests on different versions are
2089
+ * two independent version lines, and dragging one to the other's number is a silent, wrong
2090
+ * release; say so and touch nothing instead.
2091
+ *
2092
+ * @returns {string[]} further files to keep in step with the primary version source
2093
+ */
2094
+ function detectCompanionFiles(primaryPath, primaryVersion) {
2095
+ const companions = []
2096
+ for (const candidate of ECOSYSTEM_MANIFESTS) {
2097
+ if (candidate === primaryPath || !existsSync(candidate)) continue
2098
+ const found = readVersionFrom({ path: candidate })
2099
+ if (found !== primaryVersion) {
2100
+ warn(
2101
+ `${candidate} is at ${found ?? 'no readable version'} while ${primaryPath} is at ` +
2102
+ `${primaryVersion}, so they are versioned separately — leaving ${candidate} alone.\n` +
2103
+ ' Add it to "versionFiles" in release.config.json to bump them together.',
2104
+ )
2105
+ continue
2106
+ }
2107
+ companions.push(candidate)
2108
+ // The lockfile pins the crate's own version too, so a bump leaves it stale.
2109
+ if (candidate === 'Cargo.toml' && existsSync('Cargo.lock')) companions.push('Cargo.lock')
2110
+ }
2111
+ return companions
2112
+ }
2113
+
2114
+ /**
2115
+ * Where a language records no version of its own, a project that wants `--version` to work
2116
+ * keeps one in source instead: a Go module has only `go.mod`, which carries no version at
2117
+ * all, so the number lives in a `version.go` the tag is supposed to match.
2118
+ *
2119
+ * These mirror the tag rather than define it — `go get` resolves a tag, not a constant —
2120
+ * so they are detected as files to keep in step, never as the source of truth.
2121
+ *
2122
+ * A candidate is adopted only when it already carries the current version, the same rule
2123
+ * the companion manifests use. Here it does a second job: it rules out the `var Version =
2124
+ * "dev"` placeholder that a build replaces with -ldflags, which is not a version to bump
2125
+ * and is common enough that warning about it every release would be pure noise. Mismatches
2126
+ * are therefore skipped silently, unlike a manifest on its own version line.
2127
+ *
2128
+ * Anything outside this table is three lines of `versionFiles` config with a `pattern`;
2129
+ * this covers the convention that comes up without one.
2130
+ */
2131
+ const VERSION_MIRRORS = [
2132
+ {
2133
+ // `const Version = "1.2.0"`, `var Version = "1.2.0"`, and the same inside a const
2134
+ // block or with an explicit `string` type.
2135
+ pattern: '^\\s*(?:const\\s+|var\\s+)?[Vv]ersion\\s*(?:string\\s*)?=\\s*"(.+)"',
2136
+ paths: ['version.go', 'internal/version/version.go', 'pkg/version/version.go'],
2137
+ },
2138
+ ]
2139
+
2140
+ /** @returns {{path: string, pattern: string}[]} source files already carrying `version` */
2141
+ function detectVersionMirrors(version) {
2142
+ const found = []
2143
+ for (const { paths, pattern } of VERSION_MIRRORS) {
2144
+ for (const path of paths) {
2145
+ if (!existsSync(path)) continue
2146
+ if (readVersionFrom({ path, pattern }) === version) found.push({ path, pattern })
2147
+ }
2148
+ }
2149
+ return found
2150
+ }
2151
+
1469
2152
  /**
1470
2153
  * The publish command implied by a project's manifest, but only where one ecosystem
1471
2154
  * obviously owns it. Python has several publishers (uv, twine, poetry, flit) and Go has
@@ -1477,6 +2160,26 @@ const PUBLISH_BY_MANIFEST = {
1477
2160
  'Cargo.toml': 'cargo publish',
1478
2161
  }
1479
2162
 
2163
+ /**
2164
+ * A manifest that must not be published says so in itself. Detection honours that: a
2165
+ * private package or an unpublishable crate is one this repository releases by tag alone,
2166
+ * and detecting a command for it would attempt the one thing the manifest forbids.
2167
+ */
2168
+ function detectablePublish(path) {
2169
+ if (basename(path) === 'package.json') return !readJson(path).private
2170
+ if (basename(path) === 'Cargo.toml') {
2171
+ const text = readFileSync(path, 'utf8')
2172
+ if (/^publish\s*=\s*false/m.test(text)) return false
2173
+ // A crate built only as a cdylib is a native extension module — what maturin and
2174
+ // napi-rs compile into a wheel or a .node — not a library anyone depends on from
2175
+ // crates.io. Its version travels with the package it is built into, which is why the
2176
+ // two match; publishing it to crates.io is the one thing nobody asked for.
2177
+ const crateTypes = /^crate-type\s*=\s*\[([^\]]*)\]/m.exec(text)?.[1]
2178
+ if (crateTypes?.includes('cdylib') && !crateTypes.includes('rlib')) return false
2179
+ }
2180
+ return true
2181
+ }
2182
+
1480
2183
  // An explicit `versionFile: null` means "versions by tag" and must not be re-detected, so
1481
2184
  // the distinction is between the key being absent and the key being set to null.
1482
2185
  const configuredVersionFile = Object.hasOwn(userConfig, 'versionFile')
@@ -1502,11 +2205,7 @@ if (versionFile && !existsSync(versionFile.path)) {
1502
2205
  * the version has to be typed out in full every time.
1503
2206
  */
1504
2207
  function versionFromLastTag() {
1505
- const tag = tryRead('git', ['describe', '--tags', '--abbrev=0'])
1506
- if (!tag) return null
1507
- const bare =
1508
- config.tagPrefix && tag.startsWith(config.tagPrefix) ? tag.slice(config.tagPrefix.length) : tag
1509
- return parseVersion(bare) ? bare : null
2208
+ return releaseTags()[0]?.version ?? null
1510
2209
  }
1511
2210
 
1512
2211
  const currentVersion = versionFile ? readVersionFrom(versionFile) : versionFromLastTag()
@@ -1524,13 +2223,73 @@ const goModule = existsSync('go.mod')
1524
2223
  const projectName =
1525
2224
  manifest?.name ?? (versionFile ? readNameFrom(versionFile) : null) ?? goModule ?? basename(root)
1526
2225
 
2226
+ /**
2227
+ * Manifests found beside the primary one that carry the same version. Detected only when
2228
+ * the project has said nothing about either key: a project that listed its own
2229
+ * `versionFiles` has already answered this question, and quietly appending to that answer
2230
+ * would release files it deliberately left out.
2231
+ */
2232
+ const detecting =
2233
+ !!currentVersion &&
2234
+ !Object.hasOwn(userConfig, 'versionFile') &&
2235
+ !Object.hasOwn(userConfig, 'versionFiles')
2236
+ const companionFiles = detecting
2237
+ ? [
2238
+ ...(versionFile ? detectCompanionFiles(versionFile.path, currentVersion) : []),
2239
+ ...detectVersionMirrors(currentVersion),
2240
+ ]
2241
+ : []
2242
+ if (companionFiles.length) {
2243
+ config.versionFiles = companionFiles
2244
+ note(
2245
+ `also versioned in ${companionFiles.map((entry) => versionSource(entry).path).join(', ')} ` +
2246
+ '(detected)',
2247
+ )
2248
+ }
2249
+
2250
+ /**
2251
+ * Every file the version is written into: the source of truth first, then the files kept
2252
+ * in step with it. A repository that versions by tag alone has no source of truth here and
2253
+ * may still have mirrors to write — a Go module's `version.go` is exactly that — so this
2254
+ * is what the version step works from, rather than `versionFile` being required.
2255
+ */
2256
+ /**
2257
+ * Every file the version is written into, with `*` in a `versionFiles` path expanded to
2258
+ * the files it matches.
2259
+ *
2260
+ * A pattern matching nothing is an error rather than a quiet skip: it was written to keep
2261
+ * files in step, and silently keeping none of them in step is the failure it was meant to
2262
+ * prevent. `versionFile` is never globbed — the source of truth is one file, and a glob
2263
+ * that resolved to two would make which one wins an accident of directory order.
2264
+ */
2265
+ const versionTargets = [
2266
+ ...(versionFile ? [versionSource(versionFile)] : []),
2267
+ ...config.versionFiles.map(versionSource).flatMap((source) => {
2268
+ if (!source.path.includes('*')) return [source]
2269
+ const matched = expandPaths(source.path)
2270
+ if (!matched.length) abort(`versionFiles pattern ${source.path} matched no files`)
2271
+ // A glob says "every file of this shape", and some of them legitimately carry no
2272
+ // version — a Tauri per-OS overlay holds only the keys it overrides. Being unable to
2273
+ // write one is expected here, unlike a path someone named on purpose.
2274
+ return matched.map((path) => Object.assign({}, source, { path, optional: true }))
2275
+ }),
2276
+ ]
2277
+
1527
2278
  // Same rule as versionFile: an explicit `publish: null` means "publish nothing" and is
1528
2279
  // never re-detected. Unset means "work it out", and working it out can yield nothing.
1529
2280
  if (!Object.hasOwn(userConfig, 'publish')) {
1530
2281
  // Preflight already reports "no publish command configured" when the step runs, so
1531
2282
  // there is nothing to say here — and `runs` is not resolved this early.
1532
- config.publish =
1533
- (versionFile ? PUBLISH_BY_MANIFEST[basename(versionFile.path)] : undefined) ?? null
2283
+ //
2284
+ // Two manifests releasing in step means two registries: the npm command comes first
2285
+ // because it is the recoverable one — npm allows an unpublish for 72 hours, crates.io
2286
+ // never does — so a half-finished publish leaves the undoable half undone.
2287
+ const detected = [versionFile, ...companionFiles]
2288
+ .filter(Boolean)
2289
+ .map((entry) => versionSource(entry).path)
2290
+ .filter((path) => PUBLISH_BY_MANIFEST[basename(path)] && detectablePublish(path))
2291
+ .map((path) => PUBLISH_BY_MANIFEST[basename(path)])
2292
+ config.publish = detected.length ? detected : null
1534
2293
  }
1535
2294
 
1536
2295
  // Validate every name that was asked for, not just the ones that survive: a typo in
@@ -1550,6 +2309,20 @@ if (skippedSteps) for (const name of parseStepList(skippedSteps)) steps.delete(n
1550
2309
  if (autoCommit) steps.add('commit')
1551
2310
  const runs = (name) => steps.has(name)
1552
2311
 
2312
+ /**
2313
+ * True when the commit step is off because a config `steps` list omits it — as opposed to
2314
+ * being switched off for this run with --skip or --only. Configs written before `commit`
2315
+ * became a default step omit it without ever having chosen to, so a dirty-tree refusal
2316
+ * caused by one deserves a hint that the flag-driven refusal does not: the flag user just
2317
+ * asked for exactly this.
2318
+ */
2319
+ const commitExcludedByConfig =
2320
+ !runs('commit') &&
2321
+ !onlySteps &&
2322
+ !(skippedSteps && parseStepList(skippedSteps).includes('commit')) &&
2323
+ Array.isArray(config.steps) &&
2324
+ !config.steps.includes('commit')
2325
+
1553
2326
  /**
1554
2327
  * The drafting tool, resolved from --assistant then config. "auto" picks the first one
1555
2328
  * present on PATH; a named tool must be known and installed, otherwise it is an error
@@ -1585,11 +2358,152 @@ if (assistantChoice !== 'none' && assistantChoice !== null) {
1585
2358
  }
1586
2359
  const assistant = assistantName ? ASSISTANTS[assistantName] : null
1587
2360
 
2361
+ /**
2362
+ * Registries whose preflight can be run, keyed by the first word of the publish command.
2363
+ * Each declares how that CLI answers "who am I", "does this version already exist" and
2364
+ * "is this package there at all"; any may be null when the tool has no such notion. A
2365
+ * publish command outside this table (vsce, a shell pipeline) is run as written with no
2366
+ * preflight — it cannot be introspected, and guessing would invent failures.
2367
+ *
2368
+ * `exists` is what separates "that version was never published" from "the registry did not
2369
+ * answer". Both make the version lookup exit non-zero, and only the first one means the
2370
+ * release is unfinished — see `versionShipped`.
2371
+ */
2372
+ const REGISTRIES = {
2373
+ npm: {
2374
+ whoami: ['whoami'],
2375
+ published: (name, v) => ['view', `${name}@${v}`, 'version'],
2376
+ exists: (name) => ['view', name, 'version'],
2377
+ },
2378
+ pnpm: {
2379
+ whoami: ['whoami'],
2380
+ published: (name, v) => ['view', `${name}@${v}`, 'version'],
2381
+ exists: (name) => ['view', name, 'version'],
2382
+ },
2383
+ bun: {
2384
+ whoami: ['pm', 'whoami'],
2385
+ published: (name, v) => ['pm', 'view', `${name}@${v}`, 'version'],
2386
+ exists: (name) => ['pm', 'view', name, 'version'],
2387
+ },
2388
+ // uv authenticates with a token from the environment rather than a logged-in session,
2389
+ // and skips duplicate uploads itself via --check-url, so there is no version lookup.
2390
+ uv: { env: ['UV_PUBLISH_TOKEN', 'UV_PUBLISH_PASSWORD'], login: 'set UV_PUBLISH_TOKEN' },
2391
+ // cargo has no "who am I": crates.io auth is a token, either in the environment or in
2392
+ // the credentials file `cargo login` writes. `cargo info` is the version lookup, and
2393
+ // exits non-zero for a version the index does not carry (cargo 1.82+).
2394
+ cargo: {
2395
+ env: ['CARGO_REGISTRY_TOKEN', 'CARGO_REGISTRIES_CRATES_IO_TOKEN'],
2396
+ credentials: [
2397
+ join(homedir(), '.cargo', 'credentials.toml'),
2398
+ join(homedir(), '.cargo', 'credentials'),
2399
+ ],
2400
+ login: 'run `cargo login`, or set CARGO_REGISTRY_TOKEN',
2401
+ published: (name, v) => ['info', `${name}@${v}`],
2402
+ exists: (name) => ['info', name],
2403
+ },
2404
+ // For Go the tag is the release; `go list` warms the module proxy and doubles as the
2405
+ // check for whether this version is already resolvable.
2406
+ go: {
2407
+ published: (name, v) => ['list', '-m', `${name}@${v}`],
2408
+ exists: (name) => ['list', '-m', `${name}@latest`],
2409
+ },
2410
+ }
2411
+
2412
+ /**
2413
+ * Which manifest records the name a registry knows this project by, when it is not the one
2414
+ * `projectName` came from. They are not always the same string: a Tauri plugin publishes as
2415
+ * `@tauri-apps/plugin-x` on npm and `tauri-plugin-x` on crates.io, so looking the crate up
2416
+ * under its npm name would report every version as unpublished.
2417
+ */
2418
+ const NAME_MANIFEST_BY_CLI = { cargo: 'Cargo.toml' }
2419
+
2420
+ function registryName(cli) {
2421
+ const manifest = NAME_MANIFEST_BY_CLI[cli]
2422
+ if (!manifest) return projectName
2423
+ const source = versionTargets.find((entry) => basename(entry.path) === manifest)
2424
+ return (source && readNameFrom(source)) ?? projectName
2425
+ }
2426
+
2427
+ /**
2428
+ * `publish` is one command or several, because one source tree can own a package in more
2429
+ * than one ecosystem. They run in the configured order.
2430
+ */
2431
+ const publishList = config.publish == null ? [] : [config.publish].flat()
2432
+ if (publishList.some((entry) => typeof entry !== 'string')) {
2433
+ abort('publish must be a command string, an array of command strings, or null')
2434
+ }
2435
+
2436
+ /**
2437
+ * One answer per version, per run: the lookups are network calls, and the same version is
2438
+ * asked about by the baseline walk and again by preflight.
2439
+ */
2440
+ const shippedCache = new Map()
2441
+
2442
+ /**
2443
+ * Whether a version actually reached every registry this project publishes to.
2444
+ *
2445
+ * A tag is not a release. The tag and the push happen before the publish, so a publish that
2446
+ * fails — a failing prepublish gate, an expired npm session, a network drop — leaves the
2447
+ * version tagged, pushed and changelogged but absent from the registry. Nothing downstream
2448
+ * has it, and until this could be asked, nothing upstream knew.
2449
+ *
2450
+ * "Not there" and "could not ask" are the same exit code from every one of these CLIs, and
2451
+ * conflating them is dangerous in one direction only: reading an unreachable registry as
2452
+ * "nothing was ever published" would drag the notes baseline back through the whole
2453
+ * history. The bare-name lookup separates them — a package whose own name resolves is a
2454
+ * registry that answered.
2455
+ *
2456
+ * @param {string} v
2457
+ * @returns {boolean | null} null when nothing here can answer
2458
+ */
2459
+ function versionShipped(v) {
2460
+ if (shippedCache.has(v)) return shippedCache.get(v)
2461
+ let answer = null
2462
+ for (const template of publishList) {
2463
+ const cli = template.trim().split(/\s+/)[0]
2464
+ const registry = REGISTRIES[cli]
2465
+ if (!registry?.published || !registry.exists) continue
2466
+ const name = registryName(cli)
2467
+ if (succeeds(cli, registry.published(name, v))) {
2468
+ answer ??= true
2469
+ continue
2470
+ }
2471
+ // One registry missing the version is enough: the release did not finish everywhere,
2472
+ // and the half that is missing is the half still owed to its consumers.
2473
+ if (succeeds(cli, registry.exists(name))) {
2474
+ answer = false
2475
+ break
2476
+ }
2477
+ answer = null
2478
+ break
2479
+ }
2480
+ shippedCache.set(v, answer)
2481
+ return answer
2482
+ }
2483
+
2484
+ /**
2485
+ * The release that was started and never finished: the newest tag, sitting at HEAD, whose
2486
+ * version never reached the registry.
2487
+ *
2488
+ * Re-running the same command is the documented way to recover from a release that died
2489
+ * partway through, and `auto` was the one target that could not: it resolves a version from
2490
+ * the commits since the last tag, finds none, and aborts with "nothing to release" — while
2491
+ * the thing left to do is the publish the previous run never got to.
2492
+ *
2493
+ * @returns {{name: string, version: string} | null}
2494
+ */
2495
+ function unfinishedRelease() {
2496
+ const [newest] = releaseTags()
2497
+ if (!newest || versionShipped(newest.version) !== false) return null
2498
+ const at = tryRead('git', ['rev-list', '-n', '1', newest.name])
2499
+ return at && at === tryRead('git', ['rev-parse', 'HEAD']) ? newest : null
2500
+ }
2501
+
1588
2502
  // ─────────────────────────────────────────────────────────────────────────────
1589
2503
  // RESOLVE THE TARGET VERSION
1590
2504
  // ─────────────────────────────────────────────────────────────────────────────
1591
2505
 
1592
- console.log(
2506
+ say(
1593
2507
  bold(`${projectName} release`) +
1594
2508
  (dryRun ? ` ${yellow('(dry run — nothing will execute)')}` : ''),
1595
2509
  )
@@ -1597,6 +2511,9 @@ console.log(
1597
2511
  /** What `auto` inferred, kept so preflight can show the reasoning. */
1598
2512
  let autoBump = null
1599
2513
 
2514
+ /** The tag of a previous release this run is finishing rather than starting. */
2515
+ let resuming = null
2516
+
1600
2517
  let version
1601
2518
  if (!target) {
1602
2519
  if (!currentVersion) {
@@ -1613,24 +2530,35 @@ if (!target) {
1613
2530
  'from.\n Pass the first version explicitly: release-kit 0.1.0',
1614
2531
  )
1615
2532
  }
1616
- const { commits, lastTag } = commitsSinceLastTag()
1617
- if (!commits.length) {
1618
- abort(
1619
- `no releasable commits since ${lastTag ?? 'the start of the project'} — nothing to release`,
1620
- )
1621
- }
1622
- autoBump = inferBump(commits, currentVersion, config.versioning)
1623
- if (autoBump.releaseAs) {
1624
- if (!parseVersion(autoBump.releaseAs)) {
1625
- abort(`Release-As: ${autoBump.releaseAs} in a commit is not a semver version`)
1626
- }
1627
- version = autoBump.releaseAs
2533
+ // A release that died after the tag and before the publish is finished by re-running the
2534
+ // same command — but only while nothing new has happened. A commit or a working tree that
2535
+ // `--commit` is about to turn into one moves HEAD past the tag, and publishing then would
2536
+ // ship a tree the tag does not describe; that work belongs in the next version, which is
2537
+ // what the baseline below makes sure it is released as.
2538
+ const wouldCommitMore = !!tryRead('git', ['status', '--porcelain']) && runs('commit')
2539
+ const pending = wouldCommitMore ? null : unfinishedRelease()
2540
+ if (pending) {
2541
+ ;({ name: resuming, version } = pending)
1628
2542
  } else {
1629
- version = incrementVersion(
1630
- currentVersion,
1631
- autoBump.bump,
1632
- requestedPreid ?? preidOf(currentVersion),
1633
- )
2543
+ const { commits, lastTag } = commitsSinceLastTag({ shipped: true })
2544
+ if (!commits.length) {
2545
+ abort(
2546
+ `no releasable commits since ${lastTag ?? 'the start of the project'} — nothing to release`,
2547
+ )
2548
+ }
2549
+ autoBump = inferBump(commits, currentVersion, config.versioning)
2550
+ if (autoBump.releaseAs) {
2551
+ if (!parseVersion(autoBump.releaseAs)) {
2552
+ abort(`Release-As: ${autoBump.releaseAs} in a commit is not a semver version`)
2553
+ }
2554
+ version = autoBump.releaseAs
2555
+ } else {
2556
+ version = incrementVersion(
2557
+ currentVersion,
2558
+ autoBump.bump,
2559
+ requestedPreid ?? preidOf(currentVersion),
2560
+ )
2561
+ }
1634
2562
  }
1635
2563
  } else if (BUMPS.has(target)) {
1636
2564
  if (!currentVersion) {
@@ -1653,8 +2581,13 @@ if (!target) {
1653
2581
  }
1654
2582
 
1655
2583
  const tag = `${config.tagPrefix}${version}`
2584
+ if (PRINT_ONLY) {
2585
+ console.log(version)
2586
+ process.exit(0)
2587
+ }
2588
+
1656
2589
  const isPrerelease = parseVersion(version).pre.length > 0
1657
- const bumping = !!versionFile && version !== currentVersion && runs('version')
2590
+ const bumping = versionTargets.length > 0 && version !== currentVersion && runs('version')
1658
2591
 
1659
2592
  let distTag
1660
2593
  try {
@@ -1681,32 +2614,55 @@ const expand = (template) => expandWith(template, (value) => value)
1681
2614
  const shellQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`
1682
2615
  const expandShell = (template) => expandWith(template, shellQuote)
1683
2616
 
1684
- const publishCommand = runs('publish') && config.publish ? expandShell(config.publish) : null
2617
+ /**
2618
+ * Run a lifecycle hook, if the project configured one.
2619
+ *
2620
+ * Anything a hook leaves modified is staged for the release commit. Preflight has already
2621
+ * established that the tree was clean (or that the `commit` step is committing all of it),
2622
+ * so a file that is dirty now was produced by this release and belongs in it — which is
2623
+ * what makes `afterVersion` useful for regenerating a file derived from the version.
2624
+ *
2625
+ * @param {string} name one of HOOKS
2626
+ */
2627
+ function runHook(name) {
2628
+ const configured = config.hooks?.[name]
2629
+ if (!configured) return
2630
+ const commands = Array.isArray(configured) ? configured : [configured]
2631
+ step(`Hook ${name}`)
2632
+ for (const command of commands) mutateShell(expandShell(command))
2633
+ if (dryRun) return
2634
+ for (const path of dirtyPaths()) if (!staged.includes(path)) staged.push(path)
2635
+ }
1685
2636
 
1686
2637
  /**
1687
- * Registries whose preflight can be run, keyed by the first word of the publish command.
1688
- * Each declares how that CLI answers "who am I" and "does this version already exist";
1689
- * either may be null when the tool has no such notion. A publish command outside this
1690
- * table (vsce, a shell pipeline) is run as written with no preflight — it cannot be
1691
- * introspected, and guessing would invent failures.
2638
+ * Paths git reports as changed, whatever the change is.
2639
+ *
2640
+ * The status column cannot be sliced at a fixed offset: the capture is trimmed, which
2641
+ * strips the leading space off the first entry only, so ` M file` arrives as `M file`
2642
+ * while the rest keep theirs. Split on the gap after the code instead.
1692
2643
  */
1693
- const REGISTRIES = {
1694
- npm: { whoami: ['whoami'], published: (name, v) => ['view', `${name}@${v}`, 'version'] },
1695
- pnpm: { whoami: ['whoami'], published: (name, v) => ['view', `${name}@${v}`, 'version'] },
1696
- bun: {
1697
- whoami: ['pm', 'whoami'],
1698
- published: (name, v) => ['pm', 'view', `${name}@${v}`, 'version'],
1699
- },
1700
- // uv authenticates with a token from the environment rather than a logged-in session,
1701
- // and skips duplicate uploads itself via --check-url, so there is no version lookup.
1702
- uv: { env: ['UV_PUBLISH_TOKEN', 'UV_PUBLISH_PASSWORD'], login: 'set UV_PUBLISH_TOKEN' },
1703
- // For Go the tag is the release; `go list` warms the module proxy and doubles as the
1704
- // check for whether this version is already resolvable.
1705
- go: { published: (name, v) => ['list', '-m', `${name}@${v}`] },
2644
+ function dirtyPaths() {
2645
+ return (
2646
+ (tryRead('git', ['status', '--porcelain']) ?? '')
2647
+ .split('\n')
2648
+ .map((line) => /^\s*\S{1,2}\s+(.+)$/.exec(line)?.[1]?.trim())
2649
+ .filter(Boolean)
2650
+ // A rename reads as "old -> new"; the new path is the one to stage.
2651
+ .map((path) => path.split(' -> ').at(-1))
2652
+ )
1706
2653
  }
1707
2654
 
1708
- const publishCli = publishCommand?.trim().split(/\s+/)[0]
1709
- const registry = publishCli ? REGISTRIES[publishCli] : null
2655
+ /** Each publish command with the CLI it drives, that CLI's preflight row, and its name. */
2656
+ const publishTargets = runs('publish')
2657
+ ? publishList.map((template) => {
2658
+ const command = expandShell(template)
2659
+ const cli = command.trim().split(/\s+/)[0]
2660
+ return { command, cli, registry: REGISTRIES[cli] ?? null, name: registryName(cli) }
2661
+ })
2662
+ : []
2663
+
2664
+ /** npm-family commands are the ones a `"private": true` package.json forbids. */
2665
+ const NPM_CLIS = new Set(['npm', 'pnpm', 'bun'])
1710
2666
 
1711
2667
  /**
1712
2668
  * CI publishing over OIDC ("trusted publishing") carries no token at all: `whoami` fails
@@ -1751,10 +2707,68 @@ if (autoBump) {
1751
2707
  }
1752
2708
  }
1753
2709
 
1754
- if (bumping && compareVersions(version, currentVersion) <= 0) {
2710
+ if (resuming) {
2711
+ ok(`finishing ${resuming}: it was tagged and pushed, but never reached the registry`)
2712
+ }
2713
+
2714
+ // A previous release that never shipped is not history — its commits are still owed to
2715
+ // whoever installs this package, and they are in this release's range because of it. Say
2716
+ // so: the changelog keeps the section that was written for that version, and a section
2717
+ // naming a version no registry carries is worth a human deciding about.
2718
+ const absorbed = absorbedReleaseTags({ stable: !isPrerelease }).filter(
2719
+ (entry) => entry.version !== version,
2720
+ )
2721
+ if (absorbed.length) {
2722
+ const names = absorbed.map((entry) => entry.name).join(', ')
2723
+ const many = absorbed.length > 1
2724
+ const existingChangelog =
2725
+ config.changelog && existsSync(config.changelog) ? readFileSync(config.changelog, 'utf8') : null
2726
+ const documented = absorbed
2727
+ .filter((entry) => existingChangelog && changelogSection(existingChangelog, entry.version))
2728
+ .map((entry) => entry.version)
2729
+ const stale = documented.length
2730
+ ? `\n ${config.changelog} still documents ${documented.join(', ')} — ${
2731
+ documented.length > 1 ? 'versions' : 'a version'
2732
+ } no registry carries. Fold ${
2733
+ documented.length > 1 ? 'those sections' : 'that section'
2734
+ } into ${version} by hand.`
2735
+ : ''
2736
+ warn(
2737
+ `${names} ${many ? 'were' : 'was'} tagged but never published, so ${version} ships ${
2738
+ many ? 'their' : 'its'
2739
+ } commits as well as its own.${stale}`,
2740
+ )
2741
+ }
2742
+
2743
+ // Writing the version is the first mutating step, and it used to discover a file it
2744
+ // could not write *while writing the others* — aborting with a raw stack trace after
2745
+ // some of them had already changed. Every target is checked here instead.
2746
+ if (bumping) {
2747
+ for (const source of versionTargets) {
2748
+ if (!existsSync(source.path)) {
2749
+ fail(`versionFiles entry ${source.path} does not exist`)
2750
+ continue
2751
+ }
2752
+ if (source.optional) continue
2753
+ const text = readFileSync(source.path, 'utf8')
2754
+ const { kind, shape } = versionMode(source, text)
2755
+ if (kind === 'pattern' && !shape.test(text)) {
2756
+ fail(
2757
+ `${source.path} has no version for release-kit to replace.\n` +
2758
+ ' Remove it from versionFiles, mark the line with x-release-kit-version, ' +
2759
+ 'or give the entry a "pattern".',
2760
+ )
2761
+ }
2762
+ }
2763
+ }
2764
+
2765
+ if (bumping && currentVersion && compareVersions(version, currentVersion) <= 0) {
1755
2766
  fail(`${version} is not greater than the current version ${currentVersion}`)
1756
- } else if (bumping) {
2767
+ } else if (bumping && currentVersion) {
1757
2768
  ok(`version ${currentVersion} → ${version}`)
2769
+ } else if (bumping) {
2770
+ // No manifest and no tag to read a version from, but files to write one into.
2771
+ ok(`writing ${version} into ${versionTargets.map((source) => source.path).join(', ')}`)
1758
2772
  } else if (versionFile) {
1759
2773
  ok(`releasing the version already in ${versionFile.path} (${version})`)
1760
2774
  } else {
@@ -1789,7 +2803,12 @@ else if (dirty && runs('commit')) {
1789
2803
  )
1790
2804
  }
1791
2805
  } else if (dirty) {
1792
- fail(`working tree is not clean:\n${indent(formatStatus(dirty))}`)
2806
+ const hint = commitExcludedByConfig
2807
+ ? '\n The steps list in release.config.json omits `commit` (it may predate ' +
2808
+ 'commit becoming\n a default step). Add "commit" to it, or pass --commit ' +
2809
+ 'to commit these now.'
2810
+ : ''
2811
+ fail(`working tree is not clean:\n${indent(formatStatus(dirty))}${hint}`)
1793
2812
  } else ok('working tree clean')
1794
2813
 
1795
2814
  if (assistant) {
@@ -1848,7 +2867,7 @@ if (!succeeds('git', ['remote', 'get-url', config.remote])) {
1848
2867
  // that is a failure; commit-derived notes merely come out partial, so that is a warning.
1849
2868
  let shallowHidesHistory = false
1850
2869
  if (shallow) {
1851
- const reachableTag = tryRead('git', ['describe', '--tags', '--abbrev=0'])
2870
+ const reachableTag = lastReleaseTag()
1852
2871
  shallowHidesHistory = !reachableTag
1853
2872
  if (reachableTag) {
1854
2873
  ok(
@@ -1953,37 +2972,70 @@ if (!runs('release')) {
1953
2972
  if (releaseExists) note(`a GitHub release for ${tag} already exists — will skip that step`)
1954
2973
  }
1955
2974
 
1956
- let alreadyPublished = false
1957
- if (!publishCommand) {
1958
- note(runs('publish') ? 'no publish command configured' : 'publish step not selected')
1959
- } else if (manifest?.private) {
1960
- fail('package.json is private but a publish command is configured')
1961
- } else if (!registry) {
1962
- ok(`publish: ${publishCommand}`)
1963
- } else {
2975
+ /** Whether one publish CLI can publish at all: OIDC, a token, or a live session. */
2976
+ function checkCredentials({ cli, registry, command }) {
1964
2977
  if (isTrustedPublishing) {
1965
- ok('trusted publishing (OIDC) — no token needed')
1966
- } else if (registry.env) {
1967
- // Token-in-the-environment auth: there is no session to interrogate, only credentials.
2978
+ ok(`${cli}: trusted publishing (OIDC) — no token needed`)
2979
+ // Provenance is the other half of what OIDC makes possible: a signed attestation
2980
+ // tying the published artefact to the workflow and commit that produced it. It is
2981
+ // not added to the command here — npm generates it for a trusted publish on its own,
2982
+ // and forcing the flag fails outright for a private package or a registry that
2983
+ // cannot receive one. Saying so is what turns "available" into "used".
2984
+ if (NPM_CLIS.has(cli) && !/--provenance\b/.test(command ?? '')) {
2985
+ note(
2986
+ `${cli}: OIDC also allows a signed provenance attestation — add --provenance to ` +
2987
+ 'the publish command if the registry accepts one and the package is public',
2988
+ )
2989
+ }
2990
+ return
2991
+ }
2992
+ if (registry.env) {
2993
+ // Token auth: there is no session to interrogate, only credentials to find.
1968
2994
  const found = registry.env.find((name) => process.env[name])
1969
- if (found) ok(`${publishCli} credentials found (${found})`)
1970
- else fail(`${publishCli} has no publish credentials — ${registry.login}`)
1971
- } else if (registry.whoami) {
1972
- const user = tryRead(publishCli, registry.whoami)
2995
+ if (found) {
2996
+ ok(`${cli} credentials found (${found})`)
2997
+ return
2998
+ }
2999
+ const file = registry.credentials?.find((path) => existsSync(path))
3000
+ if (file) ok(`${cli} credentials found (${file})`)
3001
+ else fail(`${cli} has no publish credentials — ${registry.login}`)
3002
+ return
3003
+ }
3004
+ if (registry.whoami) {
3005
+ const user = tryRead(cli, registry.whoami)
1973
3006
  if (user === null) {
1974
3007
  // npm replaced long-lived tokens with two-hour sessions in December 2025, so the
1975
3008
  // usual cause is an expired session rather than a missing login.
1976
3009
  fail(
1977
- `${publishCli} is not authenticated — run \`${publishCli} login\`. ` +
3010
+ `${cli} is not authenticated — run \`${cli} login\`. ` +
1978
3011
  'npm logins are two-hour sessions, so an earlier one may have expired.',
1979
3012
  )
1980
- } else ok(`${publishCli} authenticated (${user || 'unknown user'})`)
3013
+ } else ok(`${cli} authenticated (${user || 'unknown user'})`)
1981
3014
  }
3015
+ }
1982
3016
 
1983
- if (registry.published) {
1984
- alreadyPublished = succeeds(publishCli, registry.published(projectName, version))
1985
- if (alreadyPublished) {
1986
- note(`${projectName}@${version} is already published — will skip the publish step`)
3017
+ /** Commands whose version is already on the registry, so the publish step skips them. */
3018
+ const alreadyPublished = new Set()
3019
+ if (!publishTargets.length) {
3020
+ note(runs('publish') ? 'no publish command configured' : 'publish step not selected')
3021
+ } else if (manifest?.private && publishTargets.some((target) => NPM_CLIS.has(target.cli))) {
3022
+ fail('package.json is private but an npm publish command is configured')
3023
+ } else {
3024
+ // One CLI can appear more than once; interrogating it twice says the same thing twice.
3025
+ const authenticated = new Set()
3026
+ for (const target of publishTargets) {
3027
+ ok(`publish: ${target.command}`)
3028
+ if (!target.registry) continue
3029
+ if (!authenticated.has(target.cli)) {
3030
+ authenticated.add(target.cli)
3031
+ checkCredentials(target)
3032
+ }
3033
+ if (
3034
+ target.registry.published &&
3035
+ succeeds(target.cli, target.registry.published(target.name, version))
3036
+ ) {
3037
+ alreadyPublished.add(target.command)
3038
+ note(`${target.name}@${version} is already published — will skip \`${target.command}\``)
1987
3039
  }
1988
3040
  }
1989
3041
  }
@@ -2027,13 +3079,29 @@ let draftedNotes = null
2027
3079
  */
2028
3080
  const notesDeferred = !!(dirty && runs('commit'))
2029
3081
 
3082
+ /**
3083
+ * Set only when preflight found nothing to release with and left the drafting to the
3084
+ * post-commit step. Deferral has to be recorded rather than re-derived from `notesDeferred`
3085
+ * there: a dirty tree is what makes drafting possible to defer, not what makes it necessary.
3086
+ * A hand-written changelog section has already answered the question, and re-drafting over
3087
+ * it would discard the notes the confirmation prompt showed and append a second section for
3088
+ * the same version.
3089
+ */
3090
+ let notesPending = false
3091
+
2030
3092
  /**
2031
3093
  * Notes for a version, in descending order of how much they can be trusted:
2032
3094
  * an assistant's prose when one is configured, otherwise the commits grouped by
2033
3095
  * Conventional Commit type. Only when neither yields anything does GitHub generate them.
2034
3096
  */
2035
3097
  function draftNotesFor(v) {
2036
- const { lastTag, subjects, commits } = commitsSinceLastTag()
3098
+ // A stable release absorbs the candidates that led to it: their commits are what it
3099
+ // ships, and reading from the last candidate leaves the notes describing the gap
3100
+ // between two candidates rather than the release.
3101
+ const { lastTag, subjects, commits, contributors } = commitsSinceLastTag({
3102
+ stable: !isPrerelease,
3103
+ shipped: true,
3104
+ })
2037
3105
  if (!commits.length) return null
2038
3106
 
2039
3107
  // Notes are built from Conventional Commits, so anything not written that way is simply
@@ -2048,7 +3116,12 @@ function draftNotesFor(v) {
2048
3116
  }
2049
3117
  // An explicitly named source wins over the assistant being merely available.
2050
3118
  if (!assistant || notesSource === 'commits')
2051
- return changelogFromCommits(commits, remoteLinks(config.remote), config.hiddenTypes)
3119
+ return changelogFromCommits(
3120
+ commits,
3121
+ remoteLinks(config.remote),
3122
+ config.hiddenTypes,
3123
+ contributors,
3124
+ )
2052
3125
  if (shallowHidesHistory) {
2053
3126
  warn(
2054
3127
  `shallow clone: only ${subjects.length} commit(s) are visible, so the notes will ` +
@@ -2058,7 +3131,7 @@ function draftNotesFor(v) {
2058
3131
  note(`drafting notes from ${subjects.length} commit(s) with ${assistantName}...`)
2059
3132
  return (
2060
3133
  draftReleaseNotes(v, commits, lastTag, remoteLinks(config.remote)) ??
2061
- changelogFromCommits(commits, remoteLinks(config.remote), config.hiddenTypes)
3134
+ changelogFromCommits(commits, remoteLinks(config.remote), config.hiddenTypes, contributors)
2062
3135
  )
2063
3136
  }
2064
3137
  const changelogText =
@@ -2091,6 +3164,7 @@ if (notesSource === 'github') {
2091
3164
  // Generate, either because nothing was written or because a source was named.
2092
3165
  if (!notes) {
2093
3166
  if (notesDeferred) {
3167
+ notesPending = true
2094
3168
  ok(`release notes will be ${assistant ? 'drafted' : 'generated'} after the commit`)
2095
3169
  } else {
2096
3170
  draftedNotes = draftNotesFor(version)
@@ -2119,13 +3193,19 @@ for (const asset of config.assets) {
2119
3193
 
2120
3194
  // Reusing a tag is the resume path, and a resume writes nothing. If this run would still
2121
3195
  // produce a commit, that commit moves HEAD past the tag and the release ends up tagged at
2122
- // the wrong revision — which is silent until someone checks out the tag.
2123
- if (taggedCommit && runs('tag') && (bumping || rolledChangelog)) {
3196
+ // the wrong revision — which is silent until someone checks out the tag, and worse for the
3197
+ // working tree: `publish` sends what is on disk now, not what the tag describes.
3198
+ const wouldCommit = [
3199
+ dirty && runs('commit') && 'the working tree',
3200
+ bumping && 'a version bump',
3201
+ rolledChangelog && 'a changelog entry',
3202
+ ].filter(Boolean)
3203
+ if (taggedCommit && runs('tag') && wouldCommit.length) {
2124
3204
  fail(
2125
3205
  `tag ${tag} already exists at HEAD, but this run would still commit ` +
2126
- `${[bumping && 'a version bump', rolledChangelog && 'a changelog entry'].filter(Boolean).join(' and ')}.\n` +
2127
- ' That commit would leave the tag behind HEAD. Release a new version, or use ' +
2128
- '--only with the steps that remain.',
3206
+ `${wouldCommit.join(' and ')}.\n` +
3207
+ ' That commit would leave the tag behind HEAD, and publish a tree it does not ' +
3208
+ 'describe.\n Release a new version, or use --only with the steps that remain.',
2129
3209
  )
2130
3210
  }
2131
3211
 
@@ -2211,33 +3291,38 @@ if (dirty && runs('commit')) {
2211
3291
  else mutate('git', ['commit', '-m', commitMessage])
2212
3292
 
2213
3293
  // Now that the commit exists it is part of the release, so the notes can describe it.
2214
- if (notesDeferred && !dryRun) {
3294
+ if (notesPending && !dryRun) {
2215
3295
  draftedNotes = draftNotesFor(version)
2216
3296
  if (draftedNotes) notes = draftedNotes
2217
3297
  }
2218
3298
  }
2219
3299
 
3300
+ runHook('beforeVersion')
3301
+
2220
3302
  if (bumping) {
2221
3303
  step(`Write version ${version}`)
2222
- for (const entry of [versionFile, ...config.versionFiles]) {
2223
- const source = versionSource(entry)
3304
+ const releaseDate = new Date().toISOString().slice(0, 10)
3305
+ for (const source of versionTargets) {
2224
3306
  if (!existsSync(source.path)) abort(`versionFiles entry ${source.path} does not exist`)
2225
- if (writeVersionInto(source, version, { dryRun })) {
3307
+ if (writeVersionInto(source, version, { dryRun, date: releaseDate })) {
2226
3308
  staged.push(source.path)
2227
3309
  console.log(` ${dryRun ? yellow('would write') : dim('wrote')} ${source.path}`)
2228
3310
  }
2229
3311
  }
2230
- // A package-lock.json embeds the root version twice, so it goes stale on a bump.
2231
- if (existsSync('package-lock.json')) {
2232
- mutate('npm', ['install', '--package-lock-only', '--ignore-scripts', '--silent'])
2233
- staged.push('package-lock.json')
2234
- }
3312
+ refreshLockfiles(versionTargets.map((source) => source.path))
2235
3313
  }
2236
3314
 
3315
+ // After the version is on disk and before the release commit, so a file the hook
3316
+ // regenerates from the version rides in that commit rather than being left behind.
3317
+ runHook('afterVersion')
3318
+
3319
+ /** The version headings a changelog carries are dead link references without these. */
3320
+ const linked = (text) => withChangelogLinks(text, remoteLinks(config.remote), config.tagPrefix)
3321
+
2237
3322
  if (rolledChangelog && runs('changelog')) {
2238
3323
  step(`Roll ${config.changelog} to ${version}`)
2239
3324
  if (dryRun) console.log(` ${yellow('would write')} ${config.changelog}`)
2240
- else writeFileSync(config.changelog, rolledChangelog)
3325
+ else writeFileSync(config.changelog, linked(rolledChangelog))
2241
3326
  staged.push(config.changelog)
2242
3327
  } else if (runs('changelog') && draftedNotes && config.changelog && existsSync(config.changelog)) {
2243
3328
  step(`Add the drafted ${version} section to ${config.changelog}`)
@@ -2245,11 +3330,13 @@ if (rolledChangelog && runs('changelog')) {
2245
3330
  else {
2246
3331
  writeFileSync(
2247
3332
  config.changelog,
2248
- insertChangelogSection(
2249
- readFileSync(config.changelog, 'utf8'),
2250
- version,
2251
- new Date().toISOString().slice(0, 10),
2252
- draftedNotes,
3333
+ linked(
3334
+ insertChangelogSection(
3335
+ readFileSync(config.changelog, 'utf8'),
3336
+ version,
3337
+ new Date().toISOString().slice(0, 10),
3338
+ draftedNotes,
3339
+ ),
2253
3340
  ),
2254
3341
  )
2255
3342
  }
@@ -2294,15 +3381,25 @@ if (runs('tag') && !taggedCommit) {
2294
3381
 
2295
3382
  if (runs('push')) {
2296
3383
  step(`Push branch and tag to ${config.remote}`)
2297
- // --follow-tags sends the commit and the tag in one call; pushing them separately is how
2298
- // a tag ends up on the remote without its commit, or a release without its tag.
2299
- mutate('git', ['push', '--follow-tags', config.remote, branch ?? 'HEAD'])
3384
+ pushBranchAndTag(branch ?? 'HEAD')
2300
3385
  }
2301
3386
 
2302
- if (publishCommand && !alreadyPublished) {
2303
- step(`Publish to the registry (dist-tag ${distTag})`)
2304
- mutateShell(publishCommand)
3387
+ // After the tag is pushed and before anything is published: the point where an artefact
3388
+ // the publish command expects to find has to exist.
3389
+ if (publishTargets.length) runHook('beforePublish')
3390
+
3391
+ let publishedSomething = false
3392
+ for (const target of publishTargets) {
3393
+ if (alreadyPublished.has(target.command)) continue
3394
+ step(
3395
+ `Publish ${target.name} (${target.cli}${NPM_CLIS.has(target.cli) ? `, dist-tag ${distTag}` : ''})`,
3396
+ )
3397
+ mutateShell(target.command)
3398
+ publishedSomething = true
2305
3399
  }
3400
+ // Only when something was actually published: a re-run that skipped every already-published
3401
+ // target published nothing, and telling downstream otherwise is a lie it may act on.
3402
+ if (publishedSomething) runHook('afterPublish')
2306
3403
 
2307
3404
  if (runs('release') && !releaseExists) {
2308
3405
  step(`GitHub release ${tag}`)
@@ -2318,6 +3415,7 @@ if (runs('release') && !releaseExists) {
2318
3415
  ...config.assets,
2319
3416
  ]
2320
3417
  mutate('gh', args, notes ? { input: `${notes}\n` } : {})
3418
+ runHook('afterRelease')
2321
3419
  }
2322
3420
 
2323
3421
  /**
@@ -2337,7 +3435,7 @@ function emitOutputs() {
2337
3435
  name: projectName,
2338
3436
  'dist-tag': distTag,
2339
3437
  steps: STEPS.filter(runs).join(','),
2340
- published: String(!!publishCommand && !alreadyPublished),
3438
+ published: String(publishTargets.some((target) => !alreadyPublished.has(target.command))),
2341
3439
  'release-url': releaseUrl,
2342
3440
  }
2343
3441
  try {